diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
index 2de98fc..a6b12f8 100644
--- a/iosApp/Configuration/Config.xcconfig
+++ b/iosApp/Configuration/Config.xcconfig
@@ -4,4 +4,7 @@ PRODUCT_NAME=Coop
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
-MARKETING_VERSION=1.0
\ No newline at end of file
+MARKETING_VERSION=1.0
+
+FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
+OTHER_LDFLAGS=$(inherited) -framework Shared
diff --git a/iosApp/iosApp/App/AppRoute.swift b/iosApp/iosApp/App/AppRoute.swift
new file mode 100644
index 0000000..5b1e0e2
--- /dev/null
+++ b/iosApp/iosApp/App/AppRoute.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+enum AppRoute: Hashable {
+ case home
+ case requestList
+ case contactList
+ case updateProfile
+ case newChat
+ case myQr
+ case relay
+ case settings
+ case chat(id: Int64, screening: Bool)
+ case profile(pubkey: String)
+}
diff --git a/iosApp/iosApp/App/AppState.swift b/iosApp/iosApp/App/AppState.swift
new file mode 100644
index 0000000..7ab414b
--- /dev/null
+++ b/iosApp/iosApp/App/AppState.swift
@@ -0,0 +1,103 @@
+import Foundation
+import Shared
+import UIKit
+
+@MainActor
+@Observable
+final class AppState {
+ let bootstrap: Bootstrap
+
+ private var subscriptions: [FlowSubscription] = []
+
+ var path: [AppRoute] = []
+ var signerRequired: Bool?
+ var isSyncing = false
+ var partialProcessed = false
+ var chatRooms: [Room] = []
+ var accountState: AccountState?
+ var settings: Settings?
+ var currentUserProfile: Profile?
+ var isUpdatingProfile = false
+ var errorMessage: String?
+
+ let networkMonitor = NetworkMonitor()
+
+ init() {
+ bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
+ }
+
+ func start() {
+ let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ let dbDir = docs.appendingPathComponent("nostr", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
+
+ bootstrap.start(dbPath: dbDir.path) { [weak self] event in
+ self?.handleNewMessage(event)
+ }
+
+ subscriptions.append(bootstrap.watchAccountState { [weak self] state in
+ self?.accountState = state
+ self?.signerRequired = state.signerRequired?.boolValue
+ })
+ subscriptions.append(bootstrap.watchChatRooms { [weak self] rooms in
+ self?.chatRooms = rooms
+ })
+ subscriptions.append(bootstrap.watchIsSyncing { [weak self] value in
+ self?.isSyncing = value.boolValue
+ })
+ subscriptions.append(bootstrap.watchPartialProcessed { [weak self] value in
+ self?.partialProcessed = value.boolValue
+ })
+ subscriptions.append(bootstrap.watchSettings { [weak self] settings in
+ self?.settings = settings
+ })
+ subscriptions.append(bootstrap.watchCurrentUserProfile { [weak self] profile in
+ self?.currentUserProfile = profile
+ })
+ subscriptions.append(bootstrap.watchIsUpdatingProfile { [weak self] value in
+ self?.isUpdatingProfile = value.boolValue
+ })
+ subscriptions.append(bootstrap.watchErrors { [weak self] message in
+ self?.errorMessage = message
+ })
+ }
+
+ func handle(_ url: URL) {
+ guard url.scheme == "coop" else { return }
+ switch url.host {
+ case "chat":
+ if let id = Int64(url.pathComponents.dropFirst().first ?? "") {
+ path.append(.chat(id: id, screening: false))
+ }
+ case "profile":
+ let pubkey = url.pathComponents.dropFirst().first ?? ""
+ if !pubkey.isEmpty {
+ path.append(.profile(pubkey: pubkey))
+ }
+ default:
+ break
+ }
+ }
+
+ func logout() {
+ bootstrap.logout()
+ bootstrap.resetState()
+ 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/ContentView.swift b/iosApp/iosApp/ContentView.swift
index a206b8a..2aaac26 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -2,32 +2,80 @@ import SwiftUI
import Shared
struct ContentView: View {
- @State private var showContent = false
- var body: some View {
- VStack {
- Button("Click me!") {
- withAnimation {
- showContent = !showContent
- }
- }
+ @Environment(AppState.self) private var appState
- if showContent {
- VStack(spacing: 16) {
- Image(systemName: "swift")
- .font(.system(size: 200))
- .foregroundColor(.accentColor)
- Text("SwiftUI: \(Greeting().greet())")
+ var body: some View {
+ Group {
+ switch appState.signerRequired {
+ case .none:
+ SplashView()
+ case .some(true):
+ OnboardingView()
+ case .some(false):
+ NavigationStack(path: Bindable(appState).path) {
+ HomeView()
+ .navigationDestination(for: AppRoute.self) { route in
+ destination(for: route)
+ }
}
- .transition(.move(edge: .top).combined(with: .opacity))
}
}
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .padding()
+ .alert("Error", isPresented: Binding(
+ get: { appState.errorMessage != nil },
+ set: { if !$0 { appState.errorMessage = nil } }
+ )) {
+ Button("OK") { appState.errorMessage = nil }
+ } message: {
+ Text(appState.errorMessage ?? "")
+ }
+ .preferredColorScheme(colorScheme)
+ }
+
+ private var colorScheme: ColorScheme? {
+ switch appState.settings?.theme {
+ case Theme.light:
+ return .light
+ case Theme.dark:
+ return .dark
+ default:
+ return nil
+ }
+ }
+
+ @ViewBuilder
+ private func destination(for route: AppRoute) -> some View {
+ switch route {
+ case .home:
+ HomeView()
+ case .requestList:
+ RequestListView()
+ case .contactList:
+ ContactListView()
+ case .updateProfile:
+ UpdateProfileView()
+ case .newChat:
+ NewChatView()
+ case .myQr:
+ MyQrView()
+ case .relay:
+ RelayView()
+ case .settings:
+ SettingsView()
+ case .chat(let id, let screening):
+ ChatView(roomId: id, screening: screening)
+ case .profile(let pubkey):
+ ProfileView(pubkey: pubkey)
+ }
}
}
-struct ContentView_Previews: PreviewProvider {
- static var previews: some View {
- ContentView()
+struct SplashView: View {
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).ignoresSafeArea()
+ Image(systemName: "bubble.left.and.bubble.right.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.tint)
+ }
}
}
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index 11845e1..5e62e6a 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -4,5 +4,18 @@
CADisableMinimumFrameDurationOnPhone
+ NSCameraUsageDescription
+ Coop uses the camera to scan QR codes for adding contacts and importing identities.
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ su.reya.coop
+ CFBundleURLSchemes
+
+ coop
+
+
+
diff --git a/iosApp/iosApp/Services/KeychainStorage.swift b/iosApp/iosApp/Services/KeychainStorage.swift
new file mode 100644
index 0000000..127a21a
--- /dev/null
+++ b/iosApp/iosApp/Services/KeychainStorage.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Security
+import Shared
+
+final class KeychainStorage: AppStorage {
+ private let defaults = UserDefaults.standard
+ private let service = "su.reya.coop"
+
+ func get(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
+ completionHandler(defaults.string(forKey: key), nil)
+ }
+
+ func set(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
+ defaults.set(value, forKey: key)
+ completionHandler(nil)
+ }
+
+ func getSecret(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status == errSecSuccess, let data = item as? Data else {
+ completionHandler(nil, nil)
+ return
+ }
+ completionHandler(String(data: data, encoding: .utf8), nil)
+ }
+
+ func setSecret(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
+ let data = Data(value.utf8)
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ ]
+ if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
+ SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
+ } else {
+ var add = query
+ add[kSecValueData] = data
+ add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ SecItemAdd(add as CFDictionary, nil)
+ }
+ completionHandler(nil)
+ }
+
+ func clear(key: String, completionHandler: @escaping (Error?) -> Void) {
+ defaults.removeObject(forKey: key)
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ ]
+ SecItemDelete(query as CFDictionary)
+ completionHandler(nil)
+ }
+
+ func has(key: String, completionHandler: @escaping (KotlinBoolean?, Error?) -> Void) {
+ if defaults.object(forKey: key) != nil {
+ completionHandler(KotlinBoolean(booleanLiteral: true), nil)
+ return
+ }
+ getSecret(key: key) { secret, _ in
+ completionHandler(KotlinBoolean(booleanLiteral: secret != nil), nil)
+ }
+ }
+}
diff --git a/iosApp/iosApp/Services/KotlinHelpers.swift b/iosApp/iosApp/Services/KotlinHelpers.swift
new file mode 100644
index 0000000..23b0533
--- /dev/null
+++ b/iosApp/iosApp/Services/KotlinHelpers.swift
@@ -0,0 +1,26 @@
+import Foundation
+import Shared
+
+extension Data {
+ func toKotlinByteArray() -> KotlinByteArray {
+ let array = KotlinByteArray(size: Int32(count))
+ for (index, byte) in enumerated() {
+ array.set(index: Int32(index), value: Int8(bitPattern: byte))
+ }
+ return array
+ }
+}
+
+extension KotlinBoolean {
+ var value: Bool { boolValue }
+}
+
+extension String {
+ func isImageUrl() -> Bool {
+ ExtensionsKt.isImageUrl(self)
+ }
+
+ func removeImageUrls() -> String {
+ ExtensionsKt.removeImageUrls(self)
+ }
+}
diff --git a/iosApp/iosApp/Services/NetworkMonitor.swift b/iosApp/iosApp/Services/NetworkMonitor.swift
new file mode 100644
index 0000000..5a00378
--- /dev/null
+++ b/iosApp/iosApp/Services/NetworkMonitor.swift
@@ -0,0 +1,24 @@
+import Foundation
+import Network
+
+@Observable
+final class NetworkMonitor {
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "su.reya.coop.network")
+
+ private(set) var isMobileData = false
+
+ init() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ let mobile = path.usesInterfaceType(.cellular)
+ Task { @MainActor in
+ self?.isMobileData = mobile
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ deinit {
+ monitor.cancel()
+ }
+}
diff --git a/iosApp/iosApp/Services/NotificationService.swift b/iosApp/iosApp/Services/NotificationService.swift
new file mode 100644
index 0000000..46f99fd
--- /dev/null
+++ b/iosApp/iosApp/Services/NotificationService.swift
@@ -0,0 +1,56 @@
+import Foundation
+import UserNotifications
+
+final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
+ static let shared = NotificationService()
+
+ private let center = UNUserNotificationCenter.current()
+
+ var onOpenChat: ((Int64) -> Void)?
+
+ override init() {
+ super.init()
+ center.delegate = self
+ }
+
+ func requestPermissionIfNeeded() {
+ center.getNotificationSettings { settings in
+ if settings.authorizationStatus == .notDetermined {
+ self.center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
+ }
+ }
+ }
+
+ func notifyNewMessage(roomId: Int64, content: String) {
+ let notificationContent = UNMutableNotificationContent()
+ notificationContent.title = "You received a new message"
+ notificationContent.body = content
+ notificationContent.sound = .default
+ notificationContent.userInfo = ["roomId": roomId]
+
+ let request = UNNotificationRequest(
+ identifier: UUID().uuidString,
+ content: notificationContent,
+ trigger: nil
+ )
+ center.add(request)
+ }
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification
+ ) async -> UNNotificationPresentationOptions {
+ []
+ }
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse
+ ) async {
+ if let roomId = response.notification.request.content.userInfo["roomId"] as? Int64 {
+ await MainActor.run {
+ onOpenChat?(roomId)
+ }
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Chat/ChatView.swift b/iosApp/iosApp/Views/Chat/ChatView.swift
new file mode 100644
index 0000000..3dd42c5
--- /dev/null
+++ b/iosApp/iosApp/Views/Chat/ChatView.swift
@@ -0,0 +1,288 @@
+import PhotosUI
+import SwiftUI
+import Shared
+
+struct ChatView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ let roomId: Int64
+ let screening: Bool
+
+ @State private var viewModel: ChatViewModel?
+ @State private var input = ""
+ @State private var photoItem: PhotosPickerItem?
+ @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
+
+ if showScreener, let room = viewModel?.room {
+ ScreenerCard(
+ room: room,
+ onAccept: { viewModel?.requireScreening = false },
+ onReject: { dismiss() }
+ )
+ } else {
+ inputBar
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ if let room = viewModel?.room, let firstMember = otherMember(of: room) {
+ Button {
+ appState.path.append(.profile(pubkey: firstMember.toHex()))
+ } label: {
+ VStack(spacing: 2) {
+ AvatarView(
+ name: viewModel?.roomUi?.name ?? "?",
+ picture: viewModel?.roomUi?.picture,
+ size: 30
+ )
+ Text(viewModel?.roomUi?.name ?? "Chat")
+ .font(.caption)
+ .foregroundStyle(.primary)
+ }
+ }
+ } else {
+ Text(viewModel?.roomUi?.name ?? "Chat").font(.headline)
+ }
+ }
+ }
+ .task {
+ let vm = ChatViewModel(roomId: roomId, screening: screening)
+ viewModel = vm
+ vm.start(appState: appState)
+ }
+ .onDisappear {
+ viewModel?.stop()
+ }
+ .task(id: photoItem) {
+ guard let photoItem else { return }
+ guard let data = try? await photoItem.loadTransferable(type: Data.self) else { return }
+ let contentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
+ viewModel?.sendImage(data, contentType: contentType, appState: appState)
+ self.photoItem = nil
+ }
+ }
+
+ private var messageList: some View {
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ ForEach(groupedMessages, id: \.0) { group, messages in
+ Text(headerTitle(group: group, first: messages.first))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 14)
+
+ 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, 12)
+ }
+ .defaultScrollAnchor(.bottom)
+ .scrollDismissesKeyboard(.interactively)
+ .overlay {
+ if viewModel?.loading == true {
+ ProgressView()
+ }
+ }
+ }
+
+ @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: 0) {
+ if let replyingTo = viewModel?.replyingTo {
+ HStack(spacing: 8) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.accentColor)
+ .frame(width: 3)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Replying to \(authorName(for: replyingTo))")
+ .font(.caption.bold())
+ Text(replyingTo.content())
+ .font(.caption)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button {
+ viewModel?.replyingTo = nil
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(.bar)
+ }
+
+ HStack(alignment: .bottom, spacing: 10) {
+ PhotosPicker(selection: $photoItem, matching: .images) {
+ 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...6)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .background {
+ Capsule()
+ .strokeBorder(Color(.systemGray4), lineWidth: 1)
+ }
+
+ 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))
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.bar)
+ .animation(.snappy(duration: 0.2), value: input.isEmpty)
+ }
+ }
+
+ private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
+ let messages = viewModel?.messages ?? []
+ var groups: [(String, [Nostr_sdk_kmpUnsignedEvent])] = []
+ var currentKey = ""
+
+ for message in messages {
+ let key = message.createdAt().formatAsGroup()
+ if key != currentKey {
+ groups.append((key, [message]))
+ currentKey = key
+ } else {
+ groups[groups.count - 1].1.append(message)
+ }
+ }
+ 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 {
+ case MediaConfig.disabled:
+ return false
+ case MediaConfig.disabledformobiledata:
+ return !appState.networkMonitor.isMobileData
+ default:
+ return true
+ }
+ }
+
+ 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
+ }
+
+ private func authorName(for event: Nostr_sdk_kmpUnsignedEvent) -> String {
+ let hex = event.author().toHex()
+ if hex == appState.bootstrap.currentPublicKey()?.toHex() {
+ return appState.currentUserProfile?.name ?? "You"
+ }
+ if let profile = authorProfiles[hex] {
+ return profile.name
+ }
+ loadAuthorProfile(pubkey: event.author(), hex: hex)
+ return event.author().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 {
+ authorProfiles[hex] = profile
+ }
+ }
+ }
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(30))
+ sub.cancel()
+ }
+ }
+}
+
+private extension Nostr_sdk_kmpUnsignedEvent {
+ var stableId: String {
+ id()?.toHex() ?? "\(createdAt().asSecs())-\(content().hashValue)"
+ }
+}
diff --git a/iosApp/iosApp/Views/Chat/ChatViewModel.swift b/iosApp/iosApp/Views/Chat/ChatViewModel.swift
new file mode 100644
index 0000000..f895c66
--- /dev/null
+++ b/iosApp/iosApp/Views/Chat/ChatViewModel.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+import Shared
+
+@MainActor
+@Observable
+final class ChatViewModel {
+ let roomId: Int64
+
+ var messages: [Nostr_sdk_kmpUnsignedEvent] = []
+ var room: Room?
+ var roomUi: RoomUiState?
+ var loading = true
+ var replyingTo: Nostr_sdk_kmpUnsignedEvent?
+ var requireScreening: Bool
+
+ private var subscriptions: [FlowSubscription] = []
+
+ init(roomId: Int64, screening: Bool) {
+ self.roomId = roomId
+ self.requireScreening = screening
+ }
+
+ func start(appState: AppState) {
+ let bootstrap = appState.bootstrap
+
+ room = bootstrap.getChatRoom(id: roomId)
+ if let room {
+ subscriptions.append(bootstrap.watchRoomUi(
+ room: room,
+ currentUser: bootstrap.currentPublicKey()
+ ) { [weak self] state in
+ Task { @MainActor in self?.roomUi = state }
+ })
+ }
+
+ subscriptions.append(bootstrap.watchNewEvents { [weak self] event in
+ Task { @MainActor in
+ guard let self, event.roomId() == self.roomId else { return }
+ if !self.messages.contains(where: { $0.id()?.toHex() == event.id()?.toHex() }) {
+ self.messages.append(event)
+ self.messages.sort { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
+ }
+ appState.bootstrap.markRoomRead(id: self.roomId)
+ }
+ })
+
+ Task {
+ let loaded = (try? await bootstrap.loadRoomMessages(roomId: roomId)) ?? []
+ messages = loaded.sorted { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
+ loading = false
+ bootstrap.markRoomRead(id: roomId)
+ bootstrap.connectRoom(id: roomId)
+ }
+ }
+
+ func stop() {
+ subscriptions.forEach { $0.cancel() }
+ subscriptions = []
+ }
+
+ func send(_ text: String, appState: AppState) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+
+ if let replyTo = replyingTo, let replyId = replyTo.id() {
+ appState.bootstrap.sendReplyMessage(roomId: roomId, text: trimmed, replyTo: replyId)
+ } else {
+ appState.bootstrap.sendTextMessage(roomId: roomId, text: trimmed)
+ }
+ replyingTo = nil
+ requireScreening = false
+ }
+
+ func sendImage(_ data: Data, contentType: String, appState: AppState) {
+ appState.bootstrap.sendImageMessage(
+ roomId: roomId,
+ file: data.toKotlinByteArray(),
+ contentType: contentType
+ )
+ requireScreening = false
+ }
+}
diff --git a/iosApp/iosApp/Views/Chat/MessageBubble.swift b/iosApp/iosApp/Views/Chat/MessageBubble.swift
new file mode 100644
index 0000000..9267a04
--- /dev/null
+++ b/iosApp/iosApp/Views/Chat/MessageBubble.swift
@@ -0,0 +1,177 @@
+import SwiftUI
+import Shared
+
+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
+
+ @State private var showTimestamp = false
+
+ private var imageUrls: [URL] {
+ guard showImages else { return [] }
+ return event.content()
+ .components(separatedBy: .whitespacesAndNewlines)
+ .filter { $0.isImageUrl() }
+ .compactMap { URL(string: $0) }
+ }
+
+ private var text: String {
+ showImages ? event.content().removeImageUrls() : event.content()
+ }
+
+ var body: some View {
+ 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)
+ }
+
+ if let repliedMessage {
+ replyPreview(repliedMessage)
+ }
+
+ ForEach(imageUrls, id: \.absoluteString) { url in
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image.resizable().scaledToFit()
+ case .failure:
+ ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark")
+ .frame(height: 120)
+ default:
+ ProgressView()
+ .frame(height: 120)
+ }
+ }
+ .frame(maxWidth: 260)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+
+ if !text.isEmpty {
+ Text(text)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .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)
+ .padding(.horizontal, 16)
+ }
+ }
+
+ 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 = 18
+ let small: CGFloat = tail ? 4 : 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
+ )
+ 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/Chat/ScreenerCard.swift b/iosApp/iosApp/Views/Chat/ScreenerCard.swift
new file mode 100644
index 0000000..2d4c71e
--- /dev/null
+++ b/iosApp/iosApp/Views/Chat/ScreenerCard.swift
@@ -0,0 +1,96 @@
+import SwiftUI
+import Shared
+
+struct ScreenerCard: View {
+ @Environment(AppState.self) private var appState
+
+ let room: Room
+ let onAccept: () -> Void
+ let onReject: () -> Void
+
+ @State private var profile: Profile?
+ @State private var isContact: Bool?
+ @State private var mutualCount: Int?
+ @State private var lastActivity: Nostr_sdk_kmpTimestamp?
+ @State private var subscription: FlowSubscription?
+
+ private var member: Nostr_sdk_kmpPublicKey? {
+ let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
+ return room.members.first { $0.toHex() != selfHex } ?? room.members.first
+ }
+
+ var body: some View {
+ VStack(spacing: 16) {
+ AvatarView(
+ name: profile?.name ?? member?.short() ?? "?",
+ picture: profile?.picture,
+ size: 80
+ )
+
+ Text(profile?.name ?? member?.short() ?? "Unknown")
+ .font(.headline)
+
+ VStack(spacing: 8) {
+ indicator(
+ title: "In your contacts",
+ value: isContact.map { $0 ? "Yes" : "No" },
+ positive: isContact == true
+ )
+ indicator(
+ title: "Mutual contacts",
+ value: mutualCount.map { "\($0)" },
+ positive: (mutualCount ?? 0) > 0
+ )
+ indicator(
+ title: "Last public activity",
+ value: lastActivity?.humanReadable(),
+ positive: lastActivity != nil
+ )
+ }
+ .font(.subheadline)
+
+ HStack(spacing: 12) {
+ Button(role: .destructive, action: onReject) {
+ Text("Reject")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+
+ Button(action: onAccept) {
+ Text("Accept")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
+ .padding()
+ .task {
+ guard let member else { return }
+ subscription = appState.bootstrap.watchProfile(pubkey: member) { value in
+ Task { @MainActor in profile = value }
+ }
+ isContact = (try? await appState.bootstrap.verifyContact(pubkey: member))?.boolValue
+ mutualCount = (try? await appState.bootstrap.mutualContacts(pubkey: member))?.count
+ lastActivity = try? await appState.bootstrap.verifyActivity(pubkey: member)
+ }
+ .onDisappear {
+ subscription?.cancel()
+ }
+ }
+
+ private func indicator(title: String, value: String?, positive: Bool) -> some View {
+ HStack {
+ Image(systemName: positive ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(positive ? .green : .secondary)
+ Text(title)
+ Spacer()
+ if let value {
+ Text(value).foregroundStyle(.secondary)
+ } else {
+ ProgressView().controlSize(.mini)
+ }
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Components/AvatarView.swift b/iosApp/iosApp/Views/Components/AvatarView.swift
new file mode 100644
index 0000000..5ae43a9
--- /dev/null
+++ b/iosApp/iosApp/Views/Components/AvatarView.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct AvatarView: View {
+ let name: String
+ let picture: String?
+ var size: CGFloat = 44
+
+ var body: some View {
+ Group {
+ if let picture, let url = URL(string: picture), !picture.isEmpty {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image.resizable().scaledToFill()
+ default:
+ placeholder
+ }
+ }
+ } else {
+ placeholder
+ }
+ }
+ .frame(width: size, height: size)
+ .clipShape(Circle())
+ }
+
+ private var placeholder: some View {
+ ZStack {
+ Circle().fill(Color(.secondarySystemFill))
+ Text(name.prefix(1).uppercased())
+ .font(.system(size: size * 0.45, weight: .semibold))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Contacts/ContactListView.swift b/iosApp/iosApp/Views/Contacts/ContactListView.swift
new file mode 100644
index 0000000..7856b6d
--- /dev/null
+++ b/iosApp/iosApp/Views/Contacts/ContactListView.swift
@@ -0,0 +1,129 @@
+import SwiftUI
+import Shared
+
+struct ContactListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var showAddContact = false
+ @State private var showScanner = false
+ @State private var newContact = ""
+ @State private var validating = false
+ @State private var validationError: String?
+ @State private var removing: Nostr_sdk_kmpPublicKey?
+
+ private var contacts: [Nostr_sdk_kmpPublicKey] {
+ Array(appState.accountState?.contactList ?? [])
+ .sorted { $0.toHex() < $1.toHex() }
+ }
+
+ var body: some View {
+ List {
+ ForEach(contacts, id: \.self) { pubkey in
+ Button {
+ openChat(with: pubkey)
+ } label: {
+ ContactRow(pubkey: pubkey)
+ }
+ .tint(.primary)
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ removing = pubkey
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
+ }
+ .overlay {
+ if contacts.isEmpty {
+ ContentUnavailableView(
+ "No contacts",
+ systemImage: "person.2",
+ description: Text("Add contacts to start chatting")
+ )
+ }
+ }
+ .navigationTitle("Contacts")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ Button { showScanner = true } label: {
+ Image(systemName: "qrcode.viewfinder")
+ }
+ Button { showAddContact = true } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showScanner) {
+ ScanView { result in
+ showScanner = false
+ if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
+ openChat(with: pubkey)
+ } else {
+ appState.errorMessage = "Invalid public key"
+ }
+ }
+ }
+ .alert("Add Contact", isPresented: $showAddContact) {
+ TextField("npub or user@domain", text: $newContact)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Button("Cancel", role: .cancel) {
+ newContact = ""
+ validationError = nil
+ }
+ Button("Add") {
+ addContact()
+ }
+ .disabled(newContact.isEmpty || validating)
+ } message: {
+ if let validationError {
+ Text(validationError)
+ } else {
+ Text("Enter a nostr public key (npub) or NIP-05 address")
+ }
+ }
+ .alert("Remove Contact?", isPresented: Binding(
+ get: { removing != nil },
+ set: { if !$0 { removing = nil } }
+ )) {
+ Button("Cancel", role: .cancel) { removing = nil }
+ Button("Remove", role: .destructive) {
+ if let removing {
+ appState.bootstrap.removeContact(publicKey: removing)
+ }
+ removing = nil
+ }
+ } message: {
+ Text("This contact will be removed from your list.")
+ }
+ }
+
+ private func addContact() {
+ let value = newContact.trimmingCharacters(in: .whitespacesAndNewlines)
+ validating = true
+ Task {
+ defer { validating = false }
+ if appState.bootstrap.parsePublicKey(input: value) != nil {
+ appState.bootstrap.addContact(address: value)
+ newContact = ""
+ } else if value.contains("@"),
+ (try? await appState.bootstrap.searchByAddress(query: value)) != nil {
+ appState.bootstrap.addContact(address: value)
+ newContact = ""
+ } else {
+ validationError = "Could not find this user. Check the address and try again."
+ }
+ }
+ }
+
+ private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
+ do {
+ let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
+ appState.path.append(.chat(id: roomId, screening: false))
+ } catch {
+ appState.errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Home/HomeView.swift b/iosApp/iosApp/Views/Home/HomeView.swift
new file mode 100644
index 0000000..6e2ab22
--- /dev/null
+++ b/iosApp/iosApp/Views/Home/HomeView.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+import Shared
+
+struct HomeView: View {
+ @Environment(AppState.self) private var appState
+ @State private var showProfileSheet = false
+ @State private var showRelayWarning = false
+
+ private var ongoingRooms: [Room] {
+ appState.chatRooms.filter { $0.kind == RoomKind.ongoing }
+ }
+
+ private var requestRooms: [Room] {
+ appState.chatRooms.filter { $0.kind == RoomKind.request }
+ }
+
+ private var requestUnread: Int {
+ requestRooms.reduce(0) { $0 + Int($1.unreadCount) }
+ }
+
+ var body: some View {
+ List {
+ if !requestRooms.isEmpty {
+ Button {
+ appState.path.append(.requestList)
+ } label: {
+ HStack(spacing: 10) {
+ Circle()
+ .fill(requestUnread > 0 ? Color.accentColor : .clear)
+ .frame(width: 10, height: 10)
+
+ Image(systemName: "tray.full")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ .frame(width: 48, height: 48)
+ .background(Color(.secondarySystemFill), in: Circle())
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Message Requests")
+ .font(.body.weight(requestUnread > 0 ? .semibold : .regular))
+ Text("\(requestRooms.count)")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Color(.systemGray3))
+ }
+ .padding(.vertical, 4)
+ }
+ .tint(.primary)
+ }
+
+ ForEach(ongoingRooms, id: \.id) { room in
+ Button {
+ appState.path.append(.chat(id: room.id, screening: false))
+ } label: {
+ RoomRow(room: room)
+ }
+ .tint(.primary)
+ }
+ }
+ .listStyle(.plain)
+ .refreshable {
+ appState.bootstrap.refreshChatRooms()
+ }
+ .overlay {
+ if !appState.partialProcessed {
+ ProgressView()
+ } else if appState.chatRooms.isEmpty {
+ ContentUnavailableView(
+ "No Messages",
+ systemImage: "bubble.left.and.bubble.right",
+ description: Text("Start a new conversation")
+ )
+ }
+ }
+ .navigationTitle("Coop")
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button {
+ showProfileSheet = true
+ } label: {
+ AvatarView(
+ name: appState.currentUserProfile?.name ?? "?",
+ picture: appState.currentUserProfile?.picture,
+ size: 30
+ )
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack(spacing: 16) {
+ if appState.isSyncing {
+ ProgressView().controlSize(.small)
+ }
+ Button {
+ appState.path.append(.newChat)
+ } label: {
+ Image(systemName: "square.and.pencil")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showProfileSheet) {
+ ProfileSheetView()
+ .presentationDetents([.medium, .large])
+ }
+ .sheet(isPresented: $showRelayWarning) {
+ RelayWarningSheet()
+ }
+ .onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
+ showRelayWarning = isEmpty == true
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Home/ProfileSheetView.swift b/iosApp/iosApp/Views/Home/ProfileSheetView.swift
new file mode 100644
index 0000000..86a75e2
--- /dev/null
+++ b/iosApp/iosApp/Views/Home/ProfileSheetView.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+struct ProfileSheetView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var showLogoutConfirm = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ HStack(spacing: 16) {
+ AvatarView(
+ name: appState.currentUserProfile?.name ?? "?",
+ picture: appState.currentUserProfile?.picture,
+ size: 64
+ )
+ VStack(alignment: .leading, spacing: 4) {
+ Text(appState.currentUserProfile?.name ?? "Loading...")
+ .font(.headline)
+ if let npub = try? appState.bootstrap.currentPublicKey()?.toBech32() {
+ Text(npub)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+
+ Button {
+ dismiss()
+ appState.path.append(.myQr)
+ } label: {
+ Label("Show QR Code", systemImage: "qrcode")
+ }
+ }
+
+ Section {
+ Button {
+ dismiss()
+ appState.path.append(.updateProfile)
+ } label: {
+ Label("Update Profile", systemImage: "person.crop.circle")
+ }
+ Button {
+ dismiss()
+ appState.path.append(.contactList)
+ } label: {
+ Label("Contact List", systemImage: "person.2")
+ }
+ Button {
+ dismiss()
+ appState.path.append(.relay)
+ } label: {
+ Label("Relay Management", systemImage: "globe")
+ }
+ Button {
+ dismiss()
+ appState.path.append(.settings)
+ } label: {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+
+ Section {
+ Button(role: .destructive) {
+ showLogoutConfirm = true
+ } label: {
+ Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Profile")
+ .navigationBarTitleDisplayMode(.inline)
+ .alert("Logout?", isPresented: $showLogoutConfirm) {
+ Button("Cancel", role: .cancel) {}
+ Button("Logout", role: .destructive) {
+ dismiss()
+ appState.logout()
+ }
+ } message: {
+ Text("This will delete all local data. Make sure you have backed up your secret key.")
+ }
+ }
+ .tint(.primary)
+ }
+}
diff --git a/iosApp/iosApp/Views/Home/RelayWarningSheet.swift b/iosApp/iosApp/Views/Home/RelayWarningSheet.swift
new file mode 100644
index 0000000..f71ad74
--- /dev/null
+++ b/iosApp/iosApp/Views/Home/RelayWarningSheet.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct RelayWarningSheet: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 48))
+ .foregroundStyle(.orange)
+
+ Text("No Messaging Relays")
+ .font(.title2.bold())
+
+ Text("You don't have any messaging relays configured. You won't be able to receive messages until this is resolved.")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+
+ VStack(spacing: 12) {
+ Button {
+ appState.bootstrap.refetchMsgRelays()
+ dismiss()
+ } label: {
+ Text("Retry")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Button {
+ appState.bootstrap.useDefaultMsgRelayList()
+ dismiss()
+ } label: {
+ Text("Use Default")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ }
+ }
+ .padding(32)
+ .presentationDetents([.medium])
+ }
+}
diff --git a/iosApp/iosApp/Views/Home/RequestListView.swift b/iosApp/iosApp/Views/Home/RequestListView.swift
new file mode 100644
index 0000000..fe82dee
--- /dev/null
+++ b/iosApp/iosApp/Views/Home/RequestListView.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+import Shared
+
+struct RequestListView: View {
+ @Environment(AppState.self) private var appState
+
+ private var requestRooms: [Room] {
+ appState.chatRooms.filter { $0.kind == RoomKind.request }
+ }
+
+ var body: some View {
+ List {
+ ForEach(requestRooms, id: \.id) { room in
+ Button {
+ appState.path.append(.chat(id: room.id, screening: true))
+ } label: {
+ RoomRow(room: room)
+ }
+ .tint(.primary)
+ }
+ }
+ .refreshable {
+ appState.bootstrap.refreshChatRooms()
+ }
+ .overlay {
+ if requestRooms.isEmpty {
+ ContentUnavailableView(
+ "No requests",
+ systemImage: "tray",
+ description: Text("New message requests will appear here")
+ )
+ }
+ }
+ .navigationTitle("Requests")
+ }
+}
diff --git a/iosApp/iosApp/Views/Home/RoomRow.swift b/iosApp/iosApp/Views/Home/RoomRow.swift
new file mode 100644
index 0000000..93c41db
--- /dev/null
+++ b/iosApp/iosApp/Views/Home/RoomRow.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import Shared
+
+struct RoomRow: View {
+ @Environment(AppState.self) private var appState
+ let room: Room
+ @State private var ui: RoomUiState?
+ @State private var subscription: FlowSubscription?
+
+ private var unread: Bool { room.unreadCount > 0 }
+
+ 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...")
+ .font(.body.weight(unread ? .semibold : .regular))
+ .lineLimit(1)
+
+ 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)
+ .task {
+ subscription = appState.bootstrap.watchRoomUi(
+ room: room,
+ currentUser: appState.bootstrap.currentPublicKey()
+ ) { state in
+ Task { @MainActor in ui = state }
+ }
+ }
+ .onDisappear {
+ subscription?.cancel()
+ subscription = nil
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/NewChat/NewChatView.swift b/iosApp/iosApp/Views/NewChat/NewChatView.swift
new file mode 100644
index 0000000..f034b75
--- /dev/null
+++ b/iosApp/iosApp/Views/NewChat/NewChatView.swift
@@ -0,0 +1,190 @@
+import SwiftUI
+import Shared
+
+struct ContactRow: View {
+ @Environment(AppState.self) private var appState
+ let pubkey: Nostr_sdk_kmpPublicKey
+ @State private var profile: Profile?
+ @State private var subscription: FlowSubscription?
+
+ var body: some View {
+ HStack(spacing: 12) {
+ AvatarView(name: profile?.name ?? "?", picture: profile?.picture)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(profile?.name ?? "Loading...")
+ .font(.headline)
+ .lineLimit(1)
+ Text(pubkey.short())
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .padding(.vertical, 2)
+ .task {
+ subscription = appState.bootstrap.watchProfile(pubkey: pubkey) { value in
+ Task { @MainActor in profile = value }
+ }
+ }
+ .onDisappear {
+ subscription?.cancel()
+ subscription = nil
+ }
+ }
+}
+
+struct NewChatView: View {
+ @Environment(AppState.self) private var appState
+ @State private var query = ""
+ @State private var searchResults: [Nostr_sdk_kmpPublicKey] = []
+ @State private var searching = false
+ @State private var selected: [Nostr_sdk_kmpPublicKey] = []
+ @State private var showScanner = false
+ @State private var searchTask: Task?
+
+ private var contacts: [Nostr_sdk_kmpPublicKey] {
+ Array(appState.accountState?.contactList ?? [])
+ }
+
+ var body: some View {
+ List {
+ if !selected.isEmpty {
+ Section("To:") {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack {
+ ForEach(selected, id: \.self) { pubkey in
+ Button {
+ selected.removeAll { $0.toHex() == pubkey.toHex() }
+ } label: {
+ Label(pubkey.short(), systemImage: "xmark.circle.fill")
+ .font(.subheadline)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(Color(.secondarySystemBackground), in: Capsule())
+ }
+ .tint(.primary)
+ }
+ }
+ }
+ }
+ }
+
+ if searching {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ }
+
+ let results = query.isEmpty ? contacts : searchResults
+ Section(query.isEmpty ? "Contacts" : "Results") {
+ ForEach(results, id: \.self) { pubkey in
+ Button {
+ openChat(with: pubkey)
+ } label: {
+ ContactRow(pubkey: pubkey)
+ }
+ .tint(.primary)
+ .swipeActions(edge: .leading) {
+ Button {
+ toggleSelection(pubkey)
+ } label: {
+ Label("Select", systemImage: "checkmark.circle")
+ }
+ .tint(.accentColor)
+ }
+ }
+ }
+ }
+ .navigationTitle("New Chat")
+ .searchable(text: $query, prompt: "npub, user@domain, or name")
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .onChange(of: query) { _, newValue in
+ searchTask?.cancel()
+ searchTask = Task {
+ try? await Task.sleep(for: .milliseconds(500))
+ guard !Task.isCancelled else { return }
+ await performSearch(newValue)
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ Button {
+ showScanner = true
+ } label: {
+ Image(systemName: "qrcode.viewfinder")
+ }
+ if !selected.isEmpty {
+ Button("Next") {
+ createGroupChat()
+ }
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showScanner) {
+ ScanView { result in
+ showScanner = false
+ if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
+ openChat(with: pubkey)
+ } else {
+ appState.errorMessage = "Invalid public key"
+ }
+ }
+ }
+ }
+
+ private func performSearch(_ value: String) async {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.count >= 3 else {
+ searchResults = []
+ return
+ }
+
+ searching = true
+ defer { searching = false }
+
+ if trimmed.hasPrefix("npub1") {
+ if let pubkey = appState.bootstrap.parsePublicKey(input: trimmed) {
+ searchResults = [pubkey]
+ }
+ } else if trimmed.contains("@") {
+ if let pubkey = try? await appState.bootstrap.searchByAddress(query: trimmed) {
+ searchResults = [pubkey]
+ } else {
+ searchResults = []
+ }
+ } else {
+ searchResults = (try? await appState.bootstrap.searchByNostr(query: trimmed)) ?? []
+ }
+ }
+
+ private func toggleSelection(_ pubkey: Nostr_sdk_kmpPublicKey) {
+ if let index = selected.firstIndex(where: { $0.toHex() == pubkey.toHex() }) {
+ selected.remove(at: index)
+ } else {
+ selected.append(pubkey)
+ }
+ }
+
+ private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
+ do {
+ let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
+ appState.path.append(.chat(id: roomId, screening: false))
+ } catch {
+ appState.errorMessage = error.localizedDescription
+ }
+ }
+
+ private func createGroupChat() {
+ do {
+ let roomId = try appState.bootstrap.createChatRoom(recipients: selected)
+ appState.path.append(.chat(id: roomId, screening: false))
+ } catch {
+ appState.errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Onboarding/ImportView.swift b/iosApp/iosApp/Views/Onboarding/ImportView.swift
new file mode 100644
index 0000000..0b6fd96
--- /dev/null
+++ b/iosApp/iosApp/Views/Onboarding/ImportView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+
+struct ImportView: View {
+ @Environment(AppState.self) private var appState
+ @State private var secret = ""
+ @State private var password = ""
+ @State private var showScanner = false
+
+ private var needsPassword: Bool {
+ secret.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("ncryptsec1")
+ }
+
+ private var canImport: Bool {
+ !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ (!needsPassword || !password.isEmpty) &&
+ appState.accountState?.isImporting != true
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ HStack {
+ SecureField("nsec1... or bunker://", text: $secret)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Button {
+ showScanner = true
+ } label: {
+ Image(systemName: "qrcode.viewfinder")
+ }
+ }
+
+ if needsPassword {
+ SecureField("Decrypt Password", text: $password)
+ }
+ } header: {
+ Text("Secret Key")
+ } footer: {
+ Text("Enter your nsec, ncryptsec (with password), or bunker:// connection string.")
+ }
+ }
+ .navigationTitle("Import Identity")
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Import") {
+ appState.bootstrap.importIdentity(
+ secret: secret.trimmingCharacters(in: .whitespacesAndNewlines),
+ password: needsPassword ? password : nil
+ )
+ }
+ .disabled(!canImport)
+ }
+ }
+ .overlay {
+ if appState.accountState?.isImporting == true {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ .sheet(isPresented: $showScanner) {
+ ScanView { result in
+ secret = result
+ showScanner = false
+ }
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Onboarding/NewIdentityView.swift b/iosApp/iosApp/Views/Onboarding/NewIdentityView.swift
new file mode 100644
index 0000000..46eaaf3
--- /dev/null
+++ b/iosApp/iosApp/Views/Onboarding/NewIdentityView.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+struct NewIdentityView: View {
+ @Environment(AppState.self) private var appState
+
+ var body: some View {
+ ProfileEditorForm(
+ title: "Create a new identity",
+ confirmLabel: "Continue",
+ isSubmitting: appState.accountState?.isImporting == true
+ ) { name, bio, picture, contentType in
+ appState.bootstrap.createIdentity(
+ name: name,
+ bio: bio,
+ picture: picture,
+ contentType: contentType
+ )
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Onboarding/OnboardingView.swift b/iosApp/iosApp/Views/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000..83df8c0
--- /dev/null
+++ b/iosApp/iosApp/Views/Onboarding/OnboardingView.swift
@@ -0,0 +1,69 @@
+import SwiftUI
+
+private enum OnboardingRoute: Hashable {
+ case newIdentity
+ case importIdentity
+}
+
+struct OnboardingView: View {
+ @State private var path: [OnboardingRoute] = []
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ VStack(spacing: 32) {
+ Spacer()
+
+ Image(systemName: "bubble.left.and.bubble.right.fill")
+ .font(.system(size: 80))
+ .foregroundStyle(.tint)
+
+ VStack(spacing: 8) {
+ Text("Coop")
+ .font(.largeTitle.bold())
+ Text("Simple, fast, and reliable nostr messaging")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+
+ Spacer()
+
+ VStack(spacing: 12) {
+ Button {
+ path.append(.newIdentity)
+ } label: {
+ Text("Start Messaging")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Button {
+ path.append(.importIdentity)
+ } label: {
+ Text("Add an Existing Identity")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ }
+ .padding(.horizontal)
+
+ Text("By continuing, you agree to our Terms of Service and Privacy Policy")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+ .padding()
+ .navigationDestination(for: OnboardingRoute.self) { route in
+ switch route {
+ case .newIdentity:
+ NewIdentityView()
+ case .importIdentity:
+ ImportView()
+ }
+ }
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Onboarding/ProfileEditorForm.swift b/iosApp/iosApp/Views/Onboarding/ProfileEditorForm.swift
new file mode 100644
index 0000000..2a6fce5
--- /dev/null
+++ b/iosApp/iosApp/Views/Onboarding/ProfileEditorForm.swift
@@ -0,0 +1,103 @@
+import PhotosUI
+import SwiftUI
+import Shared
+
+struct ProfileEditorForm: View {
+ let title: String
+ let confirmLabel: String
+ let initialName: String
+ let initialBio: String
+ let initialPicture: String?
+ let isSubmitting: Bool
+ let onConfirm: (String, String?, KotlinByteArray?, String?) -> Void
+
+ @State private var name: String
+ @State private var bio: String
+ @State private var photoItem: PhotosPickerItem?
+ @State private var photoData: Data?
+ @State private var photoContentType: String?
+
+ init(
+ title: String,
+ confirmLabel: String,
+ initialName: String = "",
+ initialBio: String = "",
+ initialPicture: String? = nil,
+ isSubmitting: Bool = false,
+ onConfirm: @escaping (String, String?, KotlinByteArray?, String?) -> Void
+ ) {
+ self.title = title
+ self.confirmLabel = confirmLabel
+ self.initialName = initialName
+ self.initialBio = initialBio
+ self.initialPicture = initialPicture
+ self.isSubmitting = isSubmitting
+ self.onConfirm = onConfirm
+ _name = State(initialValue: initialName)
+ _bio = State(initialValue: initialBio)
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ HStack {
+ Spacer()
+ PhotosPicker(selection: $photoItem, matching: .images) {
+ if let photoData, let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 120, height: 120)
+ .clipShape(Circle())
+ } else {
+ AvatarView(name: name.isEmpty ? "?" : name, picture: initialPicture, size: 120)
+ .overlay(alignment: .bottomTrailing) {
+ Image(systemName: "plus.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.tint)
+ }
+ }
+ }
+ Spacer()
+ }
+ }
+ .listRowBackground(Color.clear)
+
+ Section {
+ TextField("What others should call you?", text: $name)
+ TextField("Tell others about yourself (optional)", text: $bio, axis: .vertical)
+ .lineLimit(3...6)
+ }
+
+ Section {
+ Button {
+ onConfirm(
+ name.trimmingCharacters(in: .whitespacesAndNewlines),
+ bio.isEmpty ? nil : bio,
+ photoData?.toKotlinByteArray(),
+ photoContentType
+ )
+ } label: {
+ HStack {
+ Spacer()
+ if isSubmitting {
+ ProgressView()
+ } else {
+ Text(confirmLabel)
+ }
+ Spacer()
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
+ }
+ }
+ .navigationTitle(title)
+ .task(id: photoItem) {
+ guard let photoItem else { return }
+ if let data = try? await photoItem.loadTransferable(type: Data.self) {
+ photoData = data
+ photoContentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
+ }
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Profile/MyQrView.swift b/iosApp/iosApp/Views/Profile/MyQrView.swift
new file mode 100644
index 0000000..1dd724a
--- /dev/null
+++ b/iosApp/iosApp/Views/Profile/MyQrView.swift
@@ -0,0 +1,55 @@
+import CoreImage.CIFilterBuiltins
+import SwiftUI
+
+struct MyQrView: View {
+ @Environment(AppState.self) private var appState
+
+ private var npub: String? {
+ try? appState.bootstrap.currentPublicKey()?.toBech32()
+ }
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Spacer()
+
+ if let npub, let qrImage = generateQrCode(from: npub) {
+ Image(uiImage: qrImage)
+ .interpolation(.none)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 280)
+ .padding()
+ .background(Color.white, in: RoundedRectangle(cornerRadius: 16))
+
+ Text(npub)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+
+ ShareLink(item: npub) {
+ Label("Share", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+ } else {
+ ContentUnavailableView("No identity", systemImage: "qrcode")
+ }
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("My QR Code")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private func generateQrCode(from string: String) -> UIImage? {
+ let context = CIContext()
+ let filter = CIFilter.qrCodeGenerator()
+ filter.message = Data(string.utf8)
+ filter.correctionLevel = "M"
+ guard let outputImage = filter.outputImage else { return nil }
+ let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
+ guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
+ return UIImage(cgImage: cgImage)
+ }
+}
diff --git a/iosApp/iosApp/Views/Profile/ProfileView.swift b/iosApp/iosApp/Views/Profile/ProfileView.swift
new file mode 100644
index 0000000..855acd9
--- /dev/null
+++ b/iosApp/iosApp/Views/Profile/ProfileView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+import Shared
+
+struct ProfileView: View {
+ @Environment(AppState.self) private var appState
+
+ let pubkey: String
+
+ @State private var profile: Profile?
+ @State private var subscription: FlowSubscription?
+ @State private var parsedKey: Nostr_sdk_kmpPublicKey?
+
+ private var record: Nostr_sdk_kmpMetadataRecord? {
+ profile?.metadata.asRecord()
+ }
+
+ var body: some View {
+ List {
+ Section {
+ VStack(spacing: 12) {
+ AvatarView(
+ name: profile?.name ?? "?",
+ picture: profile?.picture,
+ size: 120
+ )
+ Text(profile?.name ?? "No name")
+ .font(.title2.bold())
+ Text(record?.nip05 ?? parsedKey?.short() ?? "")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical)
+ }
+ .listRowBackground(Color.clear)
+
+ Section("Details") {
+ LabeledContent("Username", value: record?.name ?? "None")
+ LabeledContent("Website", value: record?.website ?? "None")
+ LabeledContent("Lightning Address", value: record?.lud16 ?? "None")
+ }
+
+ Section {
+ Button {
+ openChat()
+ } label: {
+ Label("Message", systemImage: "message")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+
+ if let npub = try? parsedKey?.toBech32() {
+ ShareLink(item: npub) {
+ Label("Share", systemImage: "square.and.arrow.up")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+ .listRowBackground(Color.clear)
+ }
+ .navigationTitle("Profile")
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ parsedKey = appState.bootstrap.parsePublicKey(input: pubkey)
+ guard let parsedKey else { return }
+ subscription = appState.bootstrap.watchProfile(pubkey: parsedKey) { value in
+ Task { @MainActor in profile = value }
+ }
+ }
+ .onDisappear {
+ subscription?.cancel()
+ }
+ }
+
+ private func openChat() {
+ guard let parsedKey else { return }
+ do {
+ let roomId = try appState.bootstrap.createChatRoom(recipients: [parsedKey])
+ appState.path.append(.chat(id: roomId, screening: false))
+ } catch {
+ appState.errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Profile/UpdateProfileView.swift b/iosApp/iosApp/Views/Profile/UpdateProfileView.swift
new file mode 100644
index 0000000..14a05c9
--- /dev/null
+++ b/iosApp/iosApp/Views/Profile/UpdateProfileView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import Shared
+
+struct UpdateProfileView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ private var record: Nostr_sdk_kmpMetadataRecord? {
+ appState.currentUserProfile?.metadata.asRecord()
+ }
+
+ var body: some View {
+ ProfileEditorForm(
+ title: "Update Profile",
+ confirmLabel: "Save changes",
+ initialName: record?.displayName ?? record?.name ?? "",
+ initialBio: record?.about ?? "",
+ initialPicture: appState.currentUserProfile?.picture,
+ isSubmitting: appState.isUpdatingProfile
+ ) { name, bio, picture, contentType in
+ appState.bootstrap.updateProfile(
+ name: name,
+ bio: bio,
+ picture: picture,
+ contentType: contentType
+ )
+ dismiss()
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Scan/ScanView.swift b/iosApp/iosApp/Views/Scan/ScanView.swift
new file mode 100644
index 0000000..d9158dc
--- /dev/null
+++ b/iosApp/iosApp/Views/Scan/ScanView.swift
@@ -0,0 +1,87 @@
+import SwiftUI
+import VisionKit
+
+struct ScanView: View {
+ @Environment(\.dismiss) private var dismiss
+ let onResult: (String) -> Void
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
+ ScannerRepresentable(onResult: onResult)
+ .ignoresSafeArea()
+ } else {
+ ContentUnavailableView(
+ "Scanner unavailable",
+ systemImage: "camera.fill",
+ description: Text("This device does not support the data scanner")
+ )
+ }
+
+ VStack {
+ Spacer()
+ RoundedRectangle(cornerRadius: 16)
+ .strokeBorder(Color.white, lineWidth: 3)
+ .frame(width: 250, height: 250)
+ .background(.clear)
+ Spacer()
+ }
+ .allowsHitTesting(false)
+ }
+ .overlay(alignment: .topLeading) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title)
+ .foregroundStyle(.white)
+ .padding()
+ }
+ }
+ }
+}
+
+private struct ScannerRepresentable: UIViewControllerRepresentable {
+ let onResult: (String) -> Void
+
+ func makeUIViewController(context: Context) -> DataScannerViewController {
+ let scanner = DataScannerViewController(
+ recognizedDataTypes: [.barcode(symbologies: [.qr])],
+ qualityLevel: .balanced,
+ recognizesMultipleItems: false,
+ isHighFrameRateTrackingEnabled: false,
+ isHighlightingEnabled: true
+ )
+ scanner.delegate = context.coordinator
+ try? scanner.startScanning()
+ return scanner
+ }
+
+ func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onResult: onResult)
+ }
+
+ final class Coordinator: NSObject, DataScannerViewControllerDelegate {
+ let onResult: (String) -> Void
+ private var handled = false
+
+ init(onResult: @escaping (String) -> Void) {
+ self.onResult = onResult
+ }
+
+ func dataScanner(
+ _ dataScanner: DataScannerViewController,
+ didAdd addedItems: [RecognizedItem],
+ allItems: [RecognizedItem]
+ ) {
+ guard !handled,
+ case .barcode(let barcode) = addedItems.first,
+ let payload = barcode.payloadStringValue
+ else { return }
+ handled = true
+ onResult(payload)
+ }
+ }
+}
diff --git a/iosApp/iosApp/Views/Settings/RelayView.swift b/iosApp/iosApp/Views/Settings/RelayView.swift
new file mode 100644
index 0000000..f087593
--- /dev/null
+++ b/iosApp/iosApp/Views/Settings/RelayView.swift
@@ -0,0 +1,169 @@
+import SwiftUI
+import Shared
+
+private enum RelayRole: String, CaseIterable, Identifiable {
+ case messaging = "Messaging"
+ case inbox = "Inbox"
+ case outbox = "Outbox"
+
+ var id: String { rawValue }
+}
+
+struct RelayView: View {
+ @Environment(AppState.self) private var appState
+ @State private var lists: RelayLists?
+ @State private var subscription: FlowSubscription?
+ @State private var showAddRelay = false
+ @State private var newRelay = ""
+ @State private var newRelayRole = RelayRole.messaging
+ @State private var addError: String?
+
+ var body: some View {
+ List {
+ if let lists {
+ if !lists.messaging.isEmpty {
+ Section("Messaging Relays") {
+ ForEach(lists.messaging, id: \.self) { relay in
+ relayRow(relay)
+ .swipeActions(edge: .trailing) {
+ if lists.messaging.count > 1 {
+ Button(role: .destructive) {
+ appState.bootstrap.removeMsgRelay(relay: relay.description())
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if !lists.inbox.isEmpty {
+ Section("Inbox Relays") {
+ ForEach(lists.inbox, id: \.self) { relay in
+ relayRow(relay)
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ appState.bootstrap.removeRelay(relay: relay.description())
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
+ }
+ }
+
+ if !lists.outbox.isEmpty {
+ Section("Outbox Relays") {
+ ForEach(lists.outbox, id: \.self) { relay in
+ relayRow(relay)
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ appState.bootstrap.removeRelay(relay: relay.description())
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
+ }
+ }
+ } else {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ }
+ }
+ .navigationTitle("Relays")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button { showAddRelay = true } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showAddRelay) {
+ NavigationStack {
+ Form {
+ Section("Relay URL") {
+ TextField("wss://relay.example.com", text: $newRelay)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ }
+ Section("Role") {
+ Picker("Role", selection: $newRelayRole) {
+ ForEach(RelayRole.allCases) { role in
+ Text(role.rawValue).tag(role)
+ }
+ }
+ .pickerStyle(.inline)
+ .labelsHidden()
+ }
+ if let addError {
+ Section {
+ Text(addError).foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Add Relay")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ showAddRelay = false
+ newRelay = ""
+ addError = nil
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Add") {
+ addRelay()
+ }
+ .disabled(!newRelay.hasPrefix("wss://"))
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+ .task {
+ appState.bootstrap.loadRelayLists()
+ subscription = appState.bootstrap.watchRelayLists { value in
+ Task { @MainActor in lists = value }
+ }
+ }
+ .onDisappear {
+ subscription?.cancel()
+ }
+ }
+
+ private func relayRow(_ relay: Nostr_sdk_kmpRelayUrl) -> some View {
+ HStack {
+ Image(systemName: "globe")
+ .foregroundStyle(.secondary)
+ Text(relay.description())
+ .font(.subheadline)
+ .lineLimit(1)
+ }
+ }
+
+ private func addRelay() {
+ let url = newRelay.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard url.hasPrefix("wss://") else {
+ addError = "Relay URL must start with wss://"
+ return
+ }
+ switch newRelayRole {
+ case .messaging:
+ appState.bootstrap.addMsgRelay(relay: url)
+ case .inbox:
+ appState.bootstrap.addInboxRelay(relay: url)
+ case .outbox:
+ appState.bootstrap.addOutboxRelay(relay: url)
+ }
+ showAddRelay = false
+ newRelay = ""
+ addError = nil
+ }
+}
diff --git a/iosApp/iosApp/Views/Settings/SettingsView.swift b/iosApp/iosApp/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..ff67f97
--- /dev/null
+++ b/iosApp/iosApp/Views/Settings/SettingsView.swift
@@ -0,0 +1,71 @@
+import SwiftUI
+import Shared
+
+struct SettingsView: View {
+ @Environment(AppState.self) private var appState
+ @State private var blossomServer = ""
+
+ private var settings: Settings? {
+ appState.settings
+ }
+
+ var body: some View {
+ Form {
+ Section("General") {
+ Toggle("Filter unknown contacts", isOn: Binding(
+ get: { settings?.screening == true },
+ set: { appState.bootstrap.setScreening(enabled: $0) }
+ ))
+
+ Picker("Media Preview", selection: Binding(
+ get: { settings?.media ?? MediaConfig.alwaysenabled },
+ set: { appState.bootstrap.setMediaConfig(media: $0) }
+ )) {
+ Text("Disabled").tag(MediaConfig.disabled)
+ Text("Disabled for Mobile Data").tag(MediaConfig.disabledformobiledata)
+ Text("Always Enabled").tag(MediaConfig.alwaysenabled)
+ }
+
+ HStack {
+ Text("Blossom Server")
+ Spacer()
+ TextField("https://blossom.band", text: $blossomServer)
+ .multilineTextAlignment(.trailing)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ .foregroundStyle(.secondary)
+ .onSubmit {
+ appState.bootstrap.setBlossomServer(
+ url: blossomServer.isEmpty ? nil : blossomServer
+ )
+ }
+ }
+
+ Button {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ } label: {
+ Label("Notifications", systemImage: "bell")
+ }
+ .tint(.primary)
+ }
+
+ Section("Appearance") {
+ Picker("Theme", selection: Binding(
+ get: { settings?.theme ?? Theme.system },
+ set: { appState.bootstrap.setTheme(theme: $0) }
+ )) {
+ Text("Light").tag(Theme.light)
+ Text("Dark").tag(Theme.dark)
+ Text("System").tag(Theme.system)
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ .onAppear {
+ blossomServer = settings?.blossomServer ?? ""
+ }
+ }
+}
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index d83dca6..d64ce46 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -2,9 +2,33 @@ import SwiftUI
@main
struct iOSApp: App {
+ @State private var appState = AppState()
+ @Environment(\.scenePhase) private var scenePhase
+
var body: some Scene {
WindowGroup {
ContentView()
+ .environment(appState)
+ .onAppear {
+ appState.start()
+ NotificationService.shared.requestPermissionIfNeeded()
+ NotificationService.shared.onOpenChat = { roomId in
+ appState.path.append(.chat(id: roomId, screening: false))
+ }
+ }
+ .onOpenURL { url in
+ appState.handle(url)
+ }
+ .onChange(of: scenePhase) { _, phase in
+ switch phase {
+ case .active:
+ appState.resume()
+ case .background:
+ appState.pause()
+ default:
+ break
+ }
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
index 9f1965a..d815c1f 100644
--- a/shared/build.gradle.kts
+++ b/shared/build.gradle.kts
@@ -22,6 +22,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
+ binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
}
}
diff --git a/shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt b/shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt
new file mode 100644
index 0000000..38eaa6b
--- /dev/null
+++ b/shared/src/iosMain/kotlin/su/reya/coop/Bootstrap.kt
@@ -0,0 +1,306 @@
+package su.reya.coop
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+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
+import su.reya.coop.nostr.NostrManager
+import su.reya.coop.repository.AccountRepository
+import su.reya.coop.repository.AccountState
+import su.reya.coop.repository.ChatRepository
+import su.reya.coop.repository.MediaRepository
+import su.reya.coop.repository.SettingsRepository
+import su.reya.coop.viewmodel.ProfileCache
+import kotlin.coroutines.resume
+
+class FlowSubscription(private val job: Job) {
+ fun cancel() {
+ job.cancel()
+ }
+}
+
+data class RelayLists(
+ val messaging: List,
+ val inbox: List,
+ val outbox: List,
+)
+
+class Bootstrap private constructor(
+ val scope: CoroutineScope,
+ val nostr: Nostr,
+ val settingsRepository: SettingsRepository,
+ val accountRepository: AccountRepository,
+ val chatRepository: ChatRepository,
+ val profileCache: ProfileCache,
+) {
+ companion object {
+ fun create(storage: AppStorage): Bootstrap {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val nostr = NostrManager.instance
+ val settingsRepository = SettingsRepository(storage, scope)
+ val mediaRepository = MediaRepository(settingsRepository)
+ val accountRepository = AccountRepository(
+ nostr = nostr,
+ storage = storage,
+ mediaRepository = mediaRepository,
+ settingsRepository = settingsRepository,
+ scope = scope,
+ externalSignerHandler = null,
+ )
+ val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
+ val profileCache = ProfileCache(nostr)
+ return Bootstrap(
+ scope = scope,
+ nostr = nostr,
+ settingsRepository = settingsRepository,
+ accountRepository = accountRepository,
+ chatRepository = chatRepository,
+ profileCache = profileCache,
+ )
+ }
+ }
+
+ 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(path)
+ nostr.connectBootstrapRelays()
+ nostr.handleNotifications(
+ onMetadataUpdate = { pubkey, metadata ->
+ scope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
+ },
+ onContactListUpdate = { contacts ->
+ scope.launch { nostr.profiles.emitContactListUpdate(contacts) }
+ },
+ onNewMessage = { event ->
+ nostr.emitNewEvent(event)
+ messageCallback(event)
+ },
+ )
+ }.onFailure {
+ accountRepository.showError("Failed to start Nostr: ${it.message}")
+ }
+ }
+ }
+
+ 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)
+ }
+
+ fun watchAccountState(onEach: (AccountState) -> Unit): FlowSubscription =
+ accountRepository.state.watch(onEach)
+
+ fun watchIsUpdatingProfile(onEach: (Boolean) -> Unit): FlowSubscription =
+ accountRepository.isUpdatingProfile.watch(onEach)
+
+ fun watchCurrentUserProfile(onEach: (Profile?) -> Unit): FlowSubscription =
+ accountRepository.currentUserProfile.watch(onEach)
+
+ fun watchChatRooms(onEach: (List) -> Unit): FlowSubscription =
+ chatRepository.chatRooms.watch(onEach)
+
+ fun watchIsSyncing(onEach: (Boolean) -> Unit): FlowSubscription =
+ chatRepository.isSyncing.watch(onEach)
+
+ fun watchPartialProcessed(onEach: (Boolean) -> Unit): FlowSubscription =
+ chatRepository.isPartialProcessedGiftWrap.watch(onEach)
+
+ fun watchSettings(onEach: (Settings) -> Unit): FlowSubscription =
+ settingsRepository.settings.watch(onEach)
+
+ fun watchNewEvents(onEach: (UnsignedEvent) -> Unit): FlowSubscription =
+ chatRepository.newEvents.watch(onEach)
+
+ fun watchErrors(onEach: (String) -> Unit): FlowSubscription =
+ merge(
+ accountRepository.errorEvents,
+ chatRepository.errorEvents,
+ profileCache.errorEvents,
+ ).watch(onEach)
+
+ fun watchProfile(pubkey: PublicKey, onEach: (Profile?) -> Unit): FlowSubscription =
+ profileCache.getMetadata(pubkey).watch(onEach)
+
+ fun watchRoomUi(
+ room: Room,
+ currentUser: PublicKey?,
+ onEach: (RoomUiState) -> Unit
+ ): FlowSubscription =
+ room.uiStateFlow(profileCache, currentUser).watch(onEach)
+
+ @Throws(IllegalArgumentException::class)
+ fun createChatRoom(recipients: List): Long =
+ chatRepository.createChatRoom(recipients)
+
+ fun getChatRoom(id: Long): Room? = chatRepository.getChatRoom(id)
+
+ fun markRoomRead(id: Long) = chatRepository.markAsRead(id)
+
+ fun refreshChatRooms() = chatRepository.refreshChatRooms()
+
+ fun connectRoom(id: Long) = chatRepository.chatRoomConnect(id)
+
+ fun sendTextMessage(roomId: Long, text: String) =
+ chatRepository.sendMessage(roomId, text, emptyList())
+
+ fun sendReplyMessage(roomId: Long, text: String, replyTo: EventId) =
+ chatRepository.sendMessage(roomId, text, listOf(replyTo))
+
+ fun sendImageMessage(roomId: Long, file: ByteArray, contentType: String) =
+ chatRepository.sendFileMessage(roomId, file, contentType, emptyList())
+
+ suspend fun loadRoomMessages(roomId: Long): List =
+ suspendCancellableCoroutine { cont ->
+ chatRepository.loadChatRoomMessages(roomId) { cont.resume(it) }
+ }
+
+ fun importIdentity(secret: String, password: String?) =
+ accountRepository.importIdentity(secret, password)
+
+ fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String?) =
+ accountRepository.createIdentity(name, bio, picture, contentType)
+
+ fun updateProfile(name: String?, bio: String?, picture: ByteArray?, contentType: String?) =
+ accountRepository.updateProfile(name, bio, picture, contentType)
+
+ fun logout() = accountRepository.logout {}
+
+ fun dismissNotificationBanner() = accountRepository.dismissNotificationBanner()
+
+ fun addContact(address: String) = accountRepository.addContact(address)
+
+ fun removeContact(publicKey: PublicKey) = accountRepository.removeContact(publicKey)
+
+ suspend fun searchByAddress(query: String): PublicKey? =
+ suspendCancellableCoroutine { cont ->
+ accountRepository.searchByAddress(query) { cont.resume(it) }
+ }
+
+ suspend fun searchByNostr(query: String): List =
+ suspendCancellableCoroutine { cont ->
+ accountRepository.searchByNostr(query) { cont.resume(it) }
+ }
+
+ suspend fun verifyActivity(pubkey: PublicKey): Timestamp? =
+ suspendCancellableCoroutine { cont ->
+ accountRepository.verifyActivity(pubkey) { cont.resume(it) }
+ }
+
+ suspend fun verifyContact(pubkey: PublicKey): Boolean =
+ suspendCancellableCoroutine { cont ->
+ accountRepository.verifyContact(pubkey) { cont.resume(it) }
+ }
+
+ suspend fun mutualContacts(pubkey: PublicKey): Set =
+ suspendCancellableCoroutine { cont ->
+ accountRepository.mutualContacts(pubkey) { cont.resume(it) }
+ }
+
+ fun refetchMsgRelays() = accountRepository.refetchMsgRelays()
+
+ fun useDefaultMsgRelayList() = accountRepository.useDefaultMsgRelayList()
+
+ fun loadRelayLists() {
+ accountRepository.loadCurrentUserRelayList()
+ accountRepository.loadCurrentUserMsgRelayList()
+ }
+
+ fun watchRelayLists(onEach: (RelayLists) -> Unit): FlowSubscription =
+ accountRepository.state.map { state ->
+ RelayLists(
+ messaging = state.userMsgRelayList,
+ inbox = state.userRelayList
+ .filter { it.value == RelayMetadata.READ || it.value == null }
+ .keys.toList(),
+ outbox = state.userRelayList
+ .filter { it.value == RelayMetadata.WRITE || it.value == null }
+ .keys.toList(),
+ )
+ }.watch(onEach)
+
+ fun addMsgRelay(relay: String) = accountRepository.addMsgRelay(relay)
+
+ fun removeMsgRelay(relay: String) = accountRepository.removeMsgRelay(relay)
+
+ fun addInboxRelay(relay: String) = accountRepository.addInboxRelay(relay)
+
+ fun addOutboxRelay(relay: String) = accountRepository.addOutboxRelay(relay)
+
+ fun removeRelay(relay: String) = accountRepository.removeRelay(relay)
+
+ fun setTheme(theme: Theme) = settingsRepository.update { it.copy(theme = theme) }
+
+ fun setMediaConfig(media: MediaConfig) = settingsRepository.update { it.copy(media = media) }
+
+ fun setScreening(enabled: Boolean) = settingsRepository.update { it.copy(screening = enabled) }
+
+ fun setBlossomServer(url: String?) = settingsRepository.update { it.copy(blossomServer = url) }
+
+ fun parsePublicKey(input: String): PublicKey? =
+ runCatching { PublicKey.parse(input.trim()) }.getOrNull()
+
+ fun currentPublicKey(): PublicKey? = nostr.signer.publicKeyFlow.value
+
+ fun resetState() {
+ accountRepository.resetInternalState()
+ chatRepository.resetInternalState()
+ }
+}