diff --git a/iosApp/iosApp/App/AppState.swift b/iosApp/iosApp/App/AppState.swift index d2acc7d..7ab414b 100644 --- a/iosApp/iosApp/App/AppState.swift +++ b/iosApp/iosApp/App/AppState.swift @@ -1,10 +1,11 @@ import Foundation import Shared +import UIKit @MainActor @Observable final class AppState { - let bootstrap: IosBootstrap + let bootstrap: Bootstrap private var subscriptions: [FlowSubscription] = [] @@ -22,7 +23,7 @@ final class AppState { let networkMonitor = NetworkMonitor() init() { - bootstrap = IosBootstrap.companion.create(storage: IosAppStorage()) + bootstrap = Bootstrap.companion.create(storage: KeychainStorage()) } func start() { @@ -84,6 +85,18 @@ final class AppState { 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) { NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content()) } diff --git a/iosApp/iosApp/Services/IosAppStorage.swift b/iosApp/iosApp/Services/KeychainStorage.swift similarity index 98% rename from iosApp/iosApp/Services/IosAppStorage.swift rename to iosApp/iosApp/Services/KeychainStorage.swift index 96056e2..127a21a 100644 --- a/iosApp/iosApp/Services/IosAppStorage.swift +++ b/iosApp/iosApp/Services/KeychainStorage.swift @@ -2,7 +2,7 @@ import Foundation import Security import Shared -final class IosAppStorage: AppStorage { +final class KeychainStorage: AppStorage { private let defaults = UserDefaults.standard private let service = "su.reya.coop" diff --git a/iosApp/iosApp/Views/Chat/ChatView.swift b/iosApp/iosApp/Views/Chat/ChatView.swift index c1468e5..3dd42c5 100644 --- a/iosApp/iosApp/Views/Chat/ChatView.swift +++ b/iosApp/iosApp/Views/Chat/ChatView.swift @@ -12,12 +12,16 @@ struct ChatView: View { @State private var viewModel: ChatViewModel? @State private var input = "" @State private var photoItem: PhotosPickerItem? - @State private var authorNames: [String: String] = [:] + @State private var authorProfiles: [String: Profile] = [:] private var showScreener: Bool { (viewModel?.requireScreening ?? false) && appState.settings?.screening == true } + private var isGroup: Bool { + viewModel?.room?.isGroup() == true + } + var body: some View { VStack(spacing: 0) { messageList @@ -32,7 +36,6 @@ struct ChatView: View { inputBar } } - .navigationTitle(viewModel?.roomUi?.name ?? "Chat") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { @@ -44,7 +47,7 @@ struct ChatView: View { AvatarView( name: viewModel?.roomUi?.name ?? "?", picture: viewModel?.roomUi?.picture, - size: 28 + size: 30 ) Text(viewModel?.roomUi?.name ?? "Chat") .font(.caption) @@ -75,33 +78,31 @@ struct ChatView: View { private var messageList: some View { ScrollView { - LazyVStack(spacing: 8) { + LazyVStack(spacing: 2) { ForEach(groupedMessages, id: \.0) { group, messages in - Text(group) - .font(.caption) + Text(headerTitle(group: group, first: messages.first)) + .font(.caption2) .foregroundStyle(.secondary) - .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 14) - ForEach(messages, id: \.stableId) { event in - let isMine = event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex() - 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 } - ) + ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in + messageCell(event: event, at: index, in: messages) } } + + 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) + .scrollDismissesKeyboard(.interactively) .overlay { if viewModel?.loading == true { 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 { - VStack(spacing: 8) { + VStack(spacing: 0) { if let replyingTo = viewModel?.replyingTo { HStack(spacing: 8) { RoundedRectangle(cornerRadius: 2) @@ -132,37 +167,47 @@ struct ChatView: View { .foregroundStyle(.secondary) } } - .padding(.horizontal) - .padding(.top, 8) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) } - HStack(spacing: 12) { + HStack(alignment: .bottom, spacing: 10) { PhotosPicker(selection: $photoItem, matching: .images) { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(.tint) + Image(systemName: "plus") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 34, height: 34) + .background(Color(.secondarySystemFill), in: Circle()) } TextField("Message", text: $input, axis: .vertical) - .lineLimit(1...5) - .padding(.horizontal, 12) + .lineLimit(1...6) + .padding(.horizontal, 14) .padding(.vertical, 8) - .background(Color(.secondarySystemBackground), in: Capsule()) + .background { + Capsule() + .strokeBorder(Color(.systemGray4), lineWidth: 1) + } - Button { - viewModel?.send(input, appState: appState) - input = "" - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.title2) - .foregroundStyle(input.isEmpty ? Color(.systemGray3) : Color.accentColor) + if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + viewModel?.send(input, appState: appState) + input = "" + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(Color(.systemBlue)) + } + .transition(.scale.combined(with: .opacity)) } - .disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .padding(.horizontal) + .padding(.horizontal, 12) .padding(.vertical, 8) + .background(.bar) + .animation(.snappy(duration: 0.2), value: input.isEmpty) } - .background(.bar) } private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] { @@ -182,6 +227,11 @@ struct ChatView: View { 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 { guard let media = appState.settings?.media else { return true } 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? { let selfHex = appState.bootstrap.currentPublicKey()?.toHex() return room.members.first { $0.toHex() != selfHex } ?? room.members.first @@ -204,21 +258,19 @@ struct ChatView: View { if hex == appState.bootstrap.currentPublicKey()?.toHex() { return appState.currentUserProfile?.name ?? "You" } - if let cached = authorNames[hex] { - return cached + if let profile = authorProfiles[hex] { + return profile.name } - let short = event.author().short() - loadAuthorName(pubkey: event.author(), hex: hex) - return short + loadAuthorProfile(pubkey: event.author(), hex: hex) + return event.author().short() } - private func loadAuthorName(pubkey: Nostr_sdk_kmpPublicKey, hex: String) { - guard authorNames[hex] == nil else { return } - authorNames[hex] = pubkey.short() + private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) { + guard authorProfiles[hex] == nil else { return } let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in Task { @MainActor in if let profile { - authorNames[hex] = profile.name + authorProfiles[hex] = profile } } } diff --git a/iosApp/iosApp/Views/Chat/MessageBubble.swift b/iosApp/iosApp/Views/Chat/MessageBubble.swift index 895c448..9267a04 100644 --- a/iosApp/iosApp/Views/Chat/MessageBubble.swift +++ b/iosApp/iosApp/Views/Chat/MessageBubble.swift @@ -5,6 +5,12 @@ struct MessageBubble: View { let event: Nostr_sdk_kmpUnsignedEvent let isMine: 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 repliedAuthorName: String? let onReply: () -> Void @@ -24,26 +30,23 @@ struct MessageBubble: View { } var body: some View { - HStack { - if isMine { Spacer(minLength: 40) } + HStack(alignment: .bottom, spacing: 6) { + if isMine { + Spacer(minLength: 48) + } else { + avatarGutter + } + + VStack(alignment: isMine ? .trailing : .leading, spacing: 2) { + if showAuthorName, let authorName { + Text(authorName) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 16) + } - VStack(alignment: isMine ? .trailing : .leading, spacing: 4) { if let repliedMessage { - HStack(spacing: 6) { - 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) - .lineLimit(2) - .foregroundStyle(.secondary) - } - } - .padding(8) - .background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + replyPreview(repliedMessage) } ForEach(imageUrls, id: \.absoluteString) { url in @@ -60,66 +63,115 @@ struct MessageBubble: View { } } .frame(maxWidth: 260) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } if !text.isEmpty { Text(text) - .padding(.horizontal, 12) + .padding(.horizontal, 14) .padding(.vertical, 8) - .background( - isMine ? Color.accentColor : Color(.secondarySystemBackground), - in: BubbleShape(isMine: isMine) - ) .foregroundStyle(isMine ? .white : .primary) + .background( + isMine ? Color(.systemBlue) : Color(.systemGray5), + in: BubbleShape(isMine: isMine, tail: isLastOfRun) + ) } if showTimestamp { Text(event.createdAt().formatAsTime()) .font(.caption2) .foregroundStyle(.secondary) - } - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.15)) { - showTimestamp.toggle() - } - } - .contextMenu { - Button { - UIPasteboard.general.string = event.content() - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - Button { - onReply() - } label: { - Label("Reply", systemImage: "arrowshape.turn.up.left") + .padding(.horizontal, 16) } } - if !isMine { Spacer(minLength: 40) } + if !isMine { Spacer(minLength: 48) } } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.15)) { + showTimestamp.toggle() + } + } + .contextMenu { + Button { + UIPasteboard.general.string = event.content() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Button { + onReply() + } label: { + Label("Reply", systemImage: "arrowshape.turn.up.left") + } + } + } + + @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 { let isMine: Bool + let tail: Bool func path(in rect: CGRect) -> Path { - let radius: CGFloat = 16 - let tail: CGFloat = 4 - let corners: UIRectCorner = isMine - ? [.topLeft, .topRight, .bottomLeft] - : [.topLeft, .topRight, .bottomRight] + let radius: CGFloat = 18 + let small: CGFloat = tail ? 4 : radius - let path = UIBezierPath( - roundedRect: rect, - byRoundingCorners: corners, - cornerRadii: CGSize(width: radius, height: radius) + let topLeft: CGFloat = radius + let topRight: CGFloat = radius + let bottomLeft: CGFloat = isMine ? radius : small + 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 - return Path(path.cgPath) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight)) + 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 } } diff --git a/iosApp/iosApp/Views/Home/HomeView.swift b/iosApp/iosApp/Views/Home/HomeView.swift index 6594594..6e2ab22 100644 --- a/iosApp/iosApp/Views/Home/HomeView.swift +++ b/iosApp/iosApp/Views/Home/HomeView.swift @@ -4,7 +4,6 @@ import Shared struct HomeView: View { @Environment(AppState.self) private var appState @State private var showProfileSheet = false - @State private var showScanner = false @State private var showRelayWarning = false private var ongoingRooms: [Room] { @@ -25,31 +24,32 @@ struct HomeView: View { Button { appState.path.append(.requestList) } label: { - HStack(spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(requestUnread > 0 ? Color.accentColor : .clear) + .frame(width: 10, height: 10) + Image(systemName: "tray.full") .font(.title2) - .foregroundStyle(.tint) - .frame(width: 44) + .foregroundStyle(.secondary) + .frame(width: 48, height: 48) + .background(Color(.secondarySystemFill), in: Circle()) - VStack(alignment: .leading, spacing: 2) { - Text("New Requests") - .font(.headline) - Text("\(requestRooms.count) request\(requestRooms.count == 1 ? "" : "s")") + VStack(alignment: .leading, spacing: 3) { + Text("Message Requests") + .font(.body.weight(requestUnread > 0 ? .semibold : .regular)) + Text("\(requestRooms.count)") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() - if requestUnread > 0 { - Text("\(requestUnread)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.accentColor, in: Capsule()) - } + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(.systemGray3)) } + .padding(.vertical, 4) } .tint(.primary) } @@ -72,75 +72,42 @@ struct HomeView: View { ProgressView() } else if appState.chatRooms.isEmpty { ContentUnavailableView( - "No chats yet", + "No Messages", systemImage: "bubble.left.and.bubble.right", - description: Text("Start a new chat to begin messaging") + description: Text("Start a new conversation") ) } } .navigationTitle("Coop") .toolbar { - ToolbarItem(placement: .principal) { - HStack(spacing: 8) { - Text("Coop").font(.headline) - if appState.isSyncing { - ProgressView().controlSize(.small) - } - } - } ToolbarItem(placement: .topBarLeading) { - Button { - showScanner = true - } label: { - Image(systemName: "qrcode.viewfinder") - } - } - ToolbarItem(placement: .topBarTrailing) { Button { showProfileSheet = true } label: { AvatarView( name: appState.currentUserProfile?.name ?? "?", picture: appState.currentUserProfile?.picture, - size: 32 + size: 30 ) } } - } - .safeAreaInset(edge: .bottom) { - Button { - appState.path.append(.newChat) - } label: { - Label("New Chat", systemImage: "square.and.pencil") - .font(.headline) - .padding(.horizontal, 20) - .padding(.vertical, 12) + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + if appState.isSyncing { + ProgressView().controlSize(.small) + } + Button { + appState.path.append(.newChat) + } label: { + Image(systemName: "square.and.pencil") + } + } } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.capsule) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.trailing) } .sheet(isPresented: $showProfileSheet) { ProfileSheetView() .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) { RelayWarningSheet() } diff --git a/iosApp/iosApp/Views/Home/RoomRow.swift b/iosApp/iosApp/Views/Home/RoomRow.swift index 71fecee..93c41db 100644 --- a/iosApp/iosApp/Views/Home/RoomRow.swift +++ b/iosApp/iosApp/Views/Home/RoomRow.swift @@ -7,34 +7,37 @@ struct RoomRow: View { @State private var ui: RoomUiState? @State private var subscription: FlowSubscription? + private var unread: Bool { room.unreadCount > 0 } + var body: some View { - HStack(spacing: 12) { - AvatarView(name: ui?.name ?? "?", picture: ui?.picture) + HStack(spacing: 10) { + Circle() + .fill(unread ? Color.accentColor : .clear) + .frame(width: 10, height: 10) - VStack(alignment: .leading, spacing: 2) { - Text(ui?.name ?? "Loading...") - .font(.headline) - .lineLimit(1) - Text(room.lastMessage ?? "") - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } + AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48) - Spacer() + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(ui?.name ?? "Loading...") + .font(.body.weight(unread ? .semibold : .regular)) + .lineLimit(1) - VStack(alignment: .trailing, spacing: 4) { - Text(room.createdAt.ago()) - .font(.caption) - .foregroundStyle(.secondary) - if room.unreadCount > 0 { - Text("\(room.unreadCount)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.accentColor, in: Capsule()) + Spacer() + + Text(room.createdAt.ago()) + .font(.subheadline) + .foregroundStyle(.secondary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(.systemGray3)) } + + Text(room.lastMessage ?? "") + .font(.subheadline.weight(unread ? .semibold : .regular)) + .foregroundStyle(unread ? .primary : .secondary) + .lineLimit(2) } } .padding(.vertical, 4) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index c715078..d64ce46 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct iOSApp: App { @State private var appState = AppState() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -18,6 +19,16 @@ struct iOSApp: App { .onOpenURL { url in appState.handle(url) } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + appState.resume() + case .background: + appState.pause() + default: + break + } + } } } } diff --git a/shared/src/iosMain/kotlin/su/reya/coop/IosBootstrap.kt b/shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt similarity index 86% rename from shared/src/iosMain/kotlin/su/reya/coop/IosBootstrap.kt rename to shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt index 182b108..38eaa6b 100644 --- a/shared/src/iosMain/kotlin/su/reya/coop/IosBootstrap.kt +++ b/shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt @@ -10,9 +10,13 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine 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.RelayMetadata import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.nostr.Nostr @@ -37,7 +41,7 @@ data class RelayLists( val outbox: List, ) -class IosBootstrap private constructor( +class Bootstrap private constructor( val scope: CoroutineScope, val nostr: Nostr, val settingsRepository: SettingsRepository, @@ -46,7 +50,7 @@ class IosBootstrap private constructor( val profileCache: ProfileCache, ) { companion object { - fun create(storage: AppStorage): IosBootstrap { + fun create(storage: AppStorage): Bootstrap { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val nostr = NostrManager.instance val settingsRepository = SettingsRepository(storage, scope) @@ -61,7 +65,7 @@ class IosBootstrap private constructor( ) val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope) val profileCache = ProfileCache(nostr) - return IosBootstrap( + return Bootstrap( scope = scope, nostr = nostr, settingsRepository = settingsRepository, @@ -73,11 +77,23 @@ class IosBootstrap private constructor( } private var notificationsJob: Job? = null + private var dbPath: String? = null + private var onNewMessage: ((UnsignedEvent) -> Unit)? = null + fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) { + this.dbPath = dbPath + this.onNewMessage = onNewMessage + startNotificationLoop() + } + + private fun startNotificationLoop() { if (notificationsJob?.isActive == true) return + val path = dbPath ?: return + val messageCallback = onNewMessage ?: return + notificationsJob = scope.launch { runCatching { - nostr.init(dbPath) + nostr.init(path) nostr.connectBootstrapRelays() nostr.handleNotifications( onMetadataUpdate = { pubkey, metadata -> @@ -88,7 +104,7 @@ class IosBootstrap private constructor( }, onNewMessage = { event -> nostr.emitNewEvent(event) - onNewMessage(event) + messageCallback(event) }, ) }.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 Flow.watch(onEach: (T) -> Unit): FlowSubscription { val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } } return FlowSubscription(job)