This commit is contained in:
2026-08-06 16:42:40 +07:00
parent e3fec77359
commit 7eb4dd8e81
8 changed files with 340 additions and 199 deletions
+15 -2
View File
@@ -1,10 +1,11 @@
import Foundation import Foundation
import Shared import Shared
import UIKit
@MainActor @MainActor
@Observable @Observable
final class AppState { final class AppState {
let bootstrap: IosBootstrap let bootstrap: Bootstrap
private var subscriptions: [FlowSubscription] = [] private var subscriptions: [FlowSubscription] = []
@@ -22,7 +23,7 @@ final class AppState {
let networkMonitor = NetworkMonitor() let networkMonitor = NetworkMonitor()
init() { init() {
bootstrap = IosBootstrap.companion.create(storage: IosAppStorage()) bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
} }
func start() { func start() {
@@ -84,6 +85,18 @@ final class AppState {
path = [] path = []
} }
func resume() {
Task { try? await bootstrap.resume() }
}
func pause() {
let taskId = UIApplication.shared.beginBackgroundTask()
Task {
try? await bootstrap.pause()
UIApplication.shared.endBackgroundTask(taskId)
}
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) { private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content()) NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
} }
@@ -2,7 +2,7 @@ import Foundation
import Security import Security
import Shared import Shared
final class IosAppStorage: AppStorage { final class KeychainStorage: AppStorage {
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
private let service = "su.reya.coop" private let service = "su.reya.coop"
+98 -46
View File
@@ -12,12 +12,16 @@ struct ChatView: View {
@State private var viewModel: ChatViewModel? @State private var viewModel: ChatViewModel?
@State private var input = "" @State private var input = ""
@State private var photoItem: PhotosPickerItem? @State private var photoItem: PhotosPickerItem?
@State private var authorNames: [String: String] = [:] @State private var authorProfiles: [String: Profile] = [:]
private var showScreener: Bool { private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true (viewModel?.requireScreening ?? false) && appState.settings?.screening == true
} }
private var isGroup: Bool {
viewModel?.room?.isGroup() == true
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
messageList messageList
@@ -32,7 +36,6 @@ struct ChatView: View {
inputBar inputBar
} }
} }
.navigationTitle(viewModel?.roomUi?.name ?? "Chat")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
@@ -44,7 +47,7 @@ struct ChatView: View {
AvatarView( AvatarView(
name: viewModel?.roomUi?.name ?? "?", name: viewModel?.roomUi?.name ?? "?",
picture: viewModel?.roomUi?.picture, picture: viewModel?.roomUi?.picture,
size: 28 size: 30
) )
Text(viewModel?.roomUi?.name ?? "Chat") Text(viewModel?.roomUi?.name ?? "Chat")
.font(.caption) .font(.caption)
@@ -75,33 +78,31 @@ struct ChatView: View {
private var messageList: some View { private var messageList: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 8) { LazyVStack(spacing: 2) {
ForEach(groupedMessages, id: \.0) { group, messages in ForEach(groupedMessages, id: \.0) { group, messages in
Text(group) Text(headerTitle(group: group, first: messages.first))
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 14)
ForEach(messages, id: \.stableId) { event in ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
let isMine = event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex() messageCell(event: event, at: index, in: messages)
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: isMine,
showImages: showImages,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
} }
} }
if let last = viewModel?.messages.last, isMine(last) {
Text("Delivered")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top, 2)
} }
.padding(.horizontal) }
.padding(.horizontal, 12)
} }
.defaultScrollAnchor(.bottom) .defaultScrollAnchor(.bottom)
.scrollDismissesKeyboard(.interactively)
.overlay { .overlay {
if viewModel?.loading == true { if viewModel?.loading == true {
ProgressView() ProgressView()
@@ -109,8 +110,42 @@ struct ChatView: View {
} }
} }
@ViewBuilder
private func messageCell(
event: Nostr_sdk_kmpUnsignedEvent,
at index: Int,
in messages: [Nostr_sdk_kmpUnsignedEvent]
) -> some View {
let mine = isMine(event)
let authorHex = event.author().toHex()
let nextIsSameAuthor = index + 1 < messages.count &&
messages[index + 1].author().toHex() == authorHex
let prevIsSameAuthor = index > 0 &&
messages[index - 1].author().toHex() == authorHex
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: mine,
showImages: showImages,
isFirstOfRun: !prevIsSameAuthor,
isLastOfRun: !nextIsSameAuthor,
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
showAuthorAvatar: isGroup && !mine,
authorName: authorName(for: event),
authorPicture: authorProfiles[authorHex]?.picture,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
}
private var inputBar: some View { private var inputBar: some View {
VStack(spacing: 8) { VStack(spacing: 0) {
if let replyingTo = viewModel?.replyingTo { if let replyingTo = viewModel?.replyingTo {
HStack(spacing: 8) { HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
@@ -132,37 +167,47 @@ struct ChatView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.padding(.horizontal) .padding(.horizontal, 16)
.padding(.top, 8) .padding(.vertical, 8)
.background(.bar)
} }
HStack(spacing: 12) { HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $photoItem, matching: .images) { PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus.circle.fill") Image(systemName: "plus")
.font(.title2) .font(.system(size: 18, weight: .medium))
.foregroundStyle(.tint) .foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.background(Color(.secondarySystemFill), in: Circle())
} }
TextField("Message", text: $input, axis: .vertical) TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...5) .lineLimit(1...6)
.padding(.horizontal, 12) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(Color(.secondarySystemBackground), in: Capsule()) .background {
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
}
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button { Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel?.send(input, appState: appState) viewModel?.send(input, appState: appState)
input = "" input = ""
} label: { } label: {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.title2) .font(.system(size: 32))
.foregroundStyle(input.isEmpty ? Color(.systemGray3) : Color.accentColor) .foregroundStyle(Color(.systemBlue))
} }
.disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .transition(.scale.combined(with: .opacity))
} }
.padding(.horizontal) }
.padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
}
.background(.bar) .background(.bar)
.animation(.snappy(duration: 0.2), value: input.isEmpty)
}
} }
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] { private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
@@ -182,6 +227,11 @@ struct ChatView: View {
return groups return groups
} }
private func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
guard let first else { return group }
return "\(group) \(first.createdAt().formatAsTime())"
}
private var showImages: Bool { private var showImages: Bool {
guard let media = appState.settings?.media else { return true } guard let media = appState.settings?.media else { return true }
switch media { switch media {
@@ -194,6 +244,10 @@ struct ChatView: View {
} }
} }
private func isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
}
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? { private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex() let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first return room.members.first { $0.toHex() != selfHex } ?? room.members.first
@@ -204,21 +258,19 @@ struct ChatView: View {
if hex == appState.bootstrap.currentPublicKey()?.toHex() { if hex == appState.bootstrap.currentPublicKey()?.toHex() {
return appState.currentUserProfile?.name ?? "You" return appState.currentUserProfile?.name ?? "You"
} }
if let cached = authorNames[hex] { if let profile = authorProfiles[hex] {
return cached return profile.name
} }
let short = event.author().short() loadAuthorProfile(pubkey: event.author(), hex: hex)
loadAuthorName(pubkey: event.author(), hex: hex) return event.author().short()
return short
} }
private func loadAuthorName(pubkey: Nostr_sdk_kmpPublicKey, hex: String) { private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorNames[hex] == nil else { return } guard authorProfiles[hex] == nil else { return }
authorNames[hex] = pubkey.short()
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in Task { @MainActor in
if let profile { if let profile {
authorNames[hex] = profile.name authorProfiles[hex] = profile
} }
} }
} }
+87 -35
View File
@@ -5,6 +5,12 @@ struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool let isMine: Bool
let showImages: Bool let showImages: Bool
let isFirstOfRun: Bool
let isLastOfRun: Bool
let showAuthorName: Bool
let showAuthorAvatar: Bool
let authorName: String?
let authorPicture: String?
let repliedMessage: Nostr_sdk_kmpUnsignedEvent? let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
let repliedAuthorName: String? let repliedAuthorName: String?
let onReply: () -> Void let onReply: () -> Void
@@ -24,26 +30,23 @@ struct MessageBubble: View {
} }
var body: some View { var body: some View {
HStack { HStack(alignment: .bottom, spacing: 6) {
if isMine { Spacer(minLength: 40) } if isMine {
Spacer(minLength: 48)
} else {
avatarGutter
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) { VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
if let repliedMessage { if showAuthorName, let authorName {
HStack(spacing: 6) { Text(authorName)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption.bold())
Text(repliedMessage.content())
.font(.caption) .font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.leading, 16)
} }
}
.padding(8) if let repliedMessage {
.background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) replyPreview(repliedMessage)
} }
ForEach(imageUrls, id: \.absoluteString) { url in ForEach(imageUrls, id: \.absoluteString) { url in
@@ -60,26 +63,30 @@ struct MessageBubble: View {
} }
} }
.frame(maxWidth: 260) .frame(maxWidth: 260)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} }
if !text.isEmpty { if !text.isEmpty {
Text(text) Text(text)
.padding(.horizontal, 12) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(
isMine ? Color.accentColor : Color(.secondarySystemBackground),
in: BubbleShape(isMine: isMine)
)
.foregroundStyle(isMine ? .white : .primary) .foregroundStyle(isMine ? .white : .primary)
.background(
isMine ? Color(.systemBlue) : Color(.systemGray5),
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
)
} }
if showTimestamp { if showTimestamp {
Text(event.createdAt().formatAsTime()) Text(event.createdAt().formatAsTime())
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 16)
} }
} }
if !isMine { Spacer(minLength: 48) }
}
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) { withAnimation(.easeInOut(duration: 0.15)) {
@@ -98,28 +105,73 @@ struct MessageBubble: View {
Label("Reply", systemImage: "arrowshape.turn.up.left") Label("Reply", systemImage: "arrowshape.turn.up.left")
} }
} }
if !isMine { Spacer(minLength: 40) }
} }
@ViewBuilder
private var avatarGutter: some View {
if isLastOfRun, showAuthorAvatar {
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
} else {
Color.clear.frame(width: 26, height: 26)
}
}
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption2.bold())
Text(replied.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(isMine ? .white : .primary)
.padding(.horizontal, 4)
} }
} }
struct BubbleShape: Shape { struct BubbleShape: Shape {
let isMine: Bool let isMine: Bool
let tail: Bool
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
let radius: CGFloat = 16 let radius: CGFloat = 18
let tail: CGFloat = 4 let small: CGFloat = tail ? 4 : radius
let corners: UIRectCorner = isMine
? [.topLeft, .topRight, .bottomLeft]
: [.topLeft, .topRight, .bottomRight]
let path = UIBezierPath( let topLeft: CGFloat = radius
roundedRect: rect, let topRight: CGFloat = radius
byRoundingCorners: corners, let bottomLeft: CGFloat = isMine ? radius : small
cornerRadii: CGSize(width: radius, height: radius) let bottomRight: CGFloat = isMine ? small : radius
var path = Path()
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
path.addArc(
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
) )
_ = tail path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
return Path(path.cgPath) path.addArc(
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
path.addArc(
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
path.addArc(
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
)
path.closeSubpath()
return path
} }
} }
+26 -59
View File
@@ -4,7 +4,6 @@ import Shared
struct HomeView: View { struct HomeView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@State private var showProfileSheet = false @State private var showProfileSheet = false
@State private var showScanner = false
@State private var showRelayWarning = false @State private var showRelayWarning = false
private var ongoingRooms: [Room] { private var ongoingRooms: [Room] {
@@ -25,31 +24,32 @@ struct HomeView: View {
Button { Button {
appState.path.append(.requestList) appState.path.append(.requestList)
} label: { } label: {
HStack(spacing: 12) { HStack(spacing: 10) {
Circle()
.fill(requestUnread > 0 ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
Image(systemName: "tray.full") Image(systemName: "tray.full")
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(.secondary)
.frame(width: 44) .frame(width: 48, height: 48)
.background(Color(.secondarySystemFill), in: Circle())
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 3) {
Text("New Requests") Text("Message Requests")
.font(.headline) .font(.body.weight(requestUnread > 0 ? .semibold : .regular))
Text("\(requestRooms.count) request\(requestRooms.count == 1 ? "" : "s")") Text("\(requestRooms.count)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if requestUnread > 0 { Image(systemName: "chevron.right")
Text("\(requestUnread)") .font(.caption.weight(.semibold))
.font(.caption2.bold()) .foregroundStyle(Color(.systemGray3))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
}
} }
.padding(.vertical, 4)
} }
.tint(.primary) .tint(.primary)
} }
@@ -72,75 +72,42 @@ struct HomeView: View {
ProgressView() ProgressView()
} else if appState.chatRooms.isEmpty { } else if appState.chatRooms.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No chats yet", "No Messages",
systemImage: "bubble.left.and.bubble.right", systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new chat to begin messaging") description: Text("Start a new conversation")
) )
} }
} }
.navigationTitle("Coop") .navigationTitle("Coop")
.toolbar { .toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
Text("Coop").font(.headline)
if appState.isSyncing {
ProgressView().controlSize(.small)
}
}
}
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { Button {
showProfileSheet = true showProfileSheet = true
} label: { } label: {
AvatarView( AvatarView(
name: appState.currentUserProfile?.name ?? "?", name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture, picture: appState.currentUserProfile?.picture,
size: 32 size: 30
) )
} }
} }
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if appState.isSyncing {
ProgressView().controlSize(.small)
} }
.safeAreaInset(edge: .bottom) {
Button { Button {
appState.path.append(.newChat) appState.path.append(.newChat)
} label: { } label: {
Label("New Chat", systemImage: "square.and.pencil") Image(systemName: "square.and.pencil")
.font(.headline) }
.padding(.horizontal, 20) }
.padding(.vertical, 12)
} }
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing)
} }
.sheet(isPresented: $showProfileSheet) { .sheet(isPresented: $showProfileSheet) {
ProfileSheetView() ProfileSheetView()
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
} }
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.sheet(isPresented: $showRelayWarning) { .sheet(isPresented: $showRelayWarning) {
RelayWarningSheet() RelayWarningSheet()
} }
+22 -19
View File
@@ -7,34 +7,37 @@ struct RoomRow: View {
@State private var ui: RoomUiState? @State private var ui: RoomUiState?
@State private var subscription: FlowSubscription? @State private var subscription: FlowSubscription?
var body: some View { private var unread: Bool { room.unreadCount > 0 }
HStack(spacing: 12) {
AvatarView(name: ui?.name ?? "?", picture: ui?.picture)
VStack(alignment: .leading, spacing: 2) { var body: some View {
HStack(spacing: 10) {
Circle()
.fill(unread ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(ui?.name ?? "Loading...") Text(ui?.name ?? "Loading...")
.font(.headline) .font(.body.weight(unread ? .semibold : .regular))
.lineLimit(1) .lineLimit(1)
Text(room.lastMessage ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(room.createdAt.ago()) Text(room.createdAt.ago())
.font(.caption) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if room.unreadCount > 0 {
Text("\(room.unreadCount)") Image(systemName: "chevron.right")
.font(.caption2.bold()) .font(.caption.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(Color(.systemGray3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
} }
Text(room.lastMessage ?? "")
.font(.subheadline.weight(unread ? .semibold : .regular))
.foregroundStyle(unread ? .primary : .secondary)
.lineLimit(2)
} }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
+11
View File
@@ -3,6 +3,7 @@ import SwiftUI
@main @main
struct iOSApp: App { struct iOSApp: App {
@State private var appState = AppState() @State private var appState = AppState()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@@ -18,6 +19,16 @@ struct iOSApp: App {
.onOpenURL { url in .onOpenURL { url in
appState.handle(url) appState.handle(url)
} }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
appState.resume()
case .background:
appState.pause()
default:
break
}
}
} }
} }
} }
@@ -10,9 +10,13 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
@@ -37,7 +41,7 @@ data class RelayLists(
val outbox: List<RelayUrl>, val outbox: List<RelayUrl>,
) )
class IosBootstrap private constructor( class Bootstrap private constructor(
val scope: CoroutineScope, val scope: CoroutineScope,
val nostr: Nostr, val nostr: Nostr,
val settingsRepository: SettingsRepository, val settingsRepository: SettingsRepository,
@@ -46,7 +50,7 @@ class IosBootstrap private constructor(
val profileCache: ProfileCache, val profileCache: ProfileCache,
) { ) {
companion object { companion object {
fun create(storage: AppStorage): IosBootstrap { fun create(storage: AppStorage): Bootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope) val settingsRepository = SettingsRepository(storage, scope)
@@ -61,7 +65,7 @@ class IosBootstrap private constructor(
) )
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope) val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr) val profileCache = ProfileCache(nostr)
return IosBootstrap( return Bootstrap(
scope = scope, scope = scope,
nostr = nostr, nostr = nostr,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
@@ -73,11 +77,23 @@ class IosBootstrap private constructor(
} }
private var notificationsJob: Job? = null private var notificationsJob: Job? = null
private var dbPath: String? = null
private var onNewMessage: ((UnsignedEvent) -> Unit)? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) { fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
this.dbPath = dbPath
this.onNewMessage = onNewMessage
startNotificationLoop()
}
private fun startNotificationLoop() {
if (notificationsJob?.isActive == true) return if (notificationsJob?.isActive == true) return
val path = dbPath ?: return
val messageCallback = onNewMessage ?: return
notificationsJob = scope.launch { notificationsJob = scope.launch {
runCatching { runCatching {
nostr.init(dbPath) nostr.init(path)
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
nostr.handleNotifications( nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata -> onMetadataUpdate = { pubkey, metadata ->
@@ -88,7 +104,7 @@ class IosBootstrap private constructor(
}, },
onNewMessage = { event -> onNewMessage = { event ->
nostr.emitNewEvent(event) nostr.emitNewEvent(event)
onNewMessage(event) messageCallback(event)
}, },
) )
}.onFailure { }.onFailure {
@@ -97,6 +113,33 @@ class IosBootstrap private constructor(
} }
} }
suspend fun resume() {
startNotificationLoop()
nostr.waitUntilInitialized()
nostr.client?.connect()
val pubkey = nostr.signer.publicKeyFlow.value ?: return
runCatching { nostr.profiles.getUserMetadata() }
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) return
relays.forEach { relay ->
nostr.client?.addRelay(relay)
nostr.client?.connectRelay(relay)
}
nostr.messages.updateSyncState { it.copy(isSyncing = true) }
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(pubkey)
val target = relays.associateWith { listOf(filter) }
nostr.client?.subscribe(target = ReqTarget.manual(target), id = "gift-wraps")
}
suspend fun pause() {
nostr.client?.disconnect()
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription { private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } } val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job) return FlowSubscription(job)