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..d2acc7d --- /dev/null +++ b/iosApp/iosApp/App/AppState.swift @@ -0,0 +1,90 @@ +import Foundation +import Shared + +@MainActor +@Observable +final class AppState { + let bootstrap: IosBootstrap + + 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 = IosBootstrap.companion.create(storage: IosAppStorage()) + } + + 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 = [] + } + + 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/IosAppStorage.swift b/iosApp/iosApp/Services/IosAppStorage.swift new file mode 100644 index 0000000..96056e2 --- /dev/null +++ b/iosApp/iosApp/Services/IosAppStorage.swift @@ -0,0 +1,73 @@ +import Foundation +import Security +import Shared + +final class IosAppStorage: 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..c1468e5 --- /dev/null +++ b/iosApp/iosApp/Views/Chat/ChatView.swift @@ -0,0 +1,236 @@ +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 authorNames: [String: String] = [:] + + private var showScreener: Bool { + (viewModel?.requireScreening ?? false) && appState.settings?.screening == 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 + } + } + .navigationTitle(viewModel?.roomUi?.name ?? "Chat") + .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: 28 + ) + 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: 8) { + ForEach(groupedMessages, id: \.0) { group, messages in + Text(group) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + + 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 } + ) + } + } + } + .padding(.horizontal) + } + .defaultScrollAnchor(.bottom) + .overlay { + if viewModel?.loading == true { + ProgressView() + } + } + } + + private var inputBar: some View { + VStack(spacing: 8) { + 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) + .padding(.top, 8) + } + + HStack(spacing: 12) { + PhotosPicker(selection: $photoItem, matching: .images) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(.tint) + } + + TextField("Message", text: $input, axis: .vertical) + .lineLimit(1...5) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground), in: Capsule()) + + Button { + viewModel?.send(input, appState: appState) + input = "" + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(input.isEmpty ? Color(.systemGray3) : Color.accentColor) + } + .disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(.bar) + } + + 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 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 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 cached = authorNames[hex] { + return cached + } + let short = event.author().short() + loadAuthorName(pubkey: event.author(), hex: hex) + return short + } + + private func loadAuthorName(pubkey: Nostr_sdk_kmpPublicKey, hex: String) { + guard authorNames[hex] == nil else { return } + authorNames[hex] = pubkey.short() + let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in + Task { @MainActor in + if let profile { + authorNames[hex] = profile.name + } + } + } + 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..895c448 --- /dev/null +++ b/iosApp/iosApp/Views/Chat/MessageBubble.swift @@ -0,0 +1,125 @@ +import SwiftUI +import Shared + +struct MessageBubble: View { + let event: Nostr_sdk_kmpUnsignedEvent + let isMine: Bool + let showImages: Bool + 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 { + if isMine { Spacer(minLength: 40) } + + 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)) + } + + 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: 12)) + } + + if !text.isEmpty { + Text(text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + isMine ? Color.accentColor : Color(.secondarySystemBackground), + in: BubbleShape(isMine: isMine) + ) + .foregroundStyle(isMine ? .white : .primary) + } + + 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") + } + } + + if !isMine { Spacer(minLength: 40) } + } + } +} + +struct BubbleShape: Shape { + let isMine: 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 path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + _ = tail + return Path(path.cgPath) + } +} 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..6594594 --- /dev/null +++ b/iosApp/iosApp/Views/Home/HomeView.swift @@ -0,0 +1,151 @@ +import SwiftUI +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] { + 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: 12) { + Image(systemName: "tray.full") + .font(.title2) + .foregroundStyle(.tint) + .frame(width: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("New Requests") + .font(.headline) + Text("\(requestRooms.count) request\(requestRooms.count == 1 ? "" : "s")") + .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()) + } + } + } + .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 chats yet", + systemImage: "bubble.left.and.bubble.right", + description: Text("Start a new chat to begin messaging") + ) + } + } + .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 + ) + } + } + } + .safeAreaInset(edge: .bottom) { + Button { + appState.path.append(.newChat) + } label: { + Label("New Chat", systemImage: "square.and.pencil") + .font(.headline) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.trailing) + } + .sheet(isPresented: $showProfileSheet) { + 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() + } + .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..71fecee --- /dev/null +++ b/iosApp/iosApp/Views/Home/RoomRow.swift @@ -0,0 +1,54 @@ +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? + + var body: some View { + HStack(spacing: 12) { + AvatarView(name: ui?.name ?? "?", picture: ui?.picture) + + VStack(alignment: .leading, spacing: 2) { + Text(ui?.name ?? "Loading...") + .font(.headline) + .lineLimit(1) + Text(room.lastMessage ?? "") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + 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()) + } + } + } + .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..c715078 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -2,9 +2,22 @@ import SwiftUI @main struct iOSApp: App { + @State private var appState = AppState() + 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) + } } } -} \ 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/IosBootstrap.kt b/shared/src/iosMain/kotlin/su/reya/coop/IosBootstrap.kt new file mode 100644 index 0000000..182b108 --- /dev/null +++ b/shared/src/iosMain/kotlin/su/reya/coop/IosBootstrap.kt @@ -0,0 +1,263 @@ +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.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +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 IosBootstrap 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): IosBootstrap { + 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 IosBootstrap( + scope = scope, + nostr = nostr, + settingsRepository = settingsRepository, + accountRepository = accountRepository, + chatRepository = chatRepository, + profileCache = profileCache, + ) + } + } + + private var notificationsJob: Job? = null + fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) { + if (notificationsJob?.isActive == true) return + notificationsJob = scope.launch { + runCatching { + nostr.init(dbPath) + 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) + onNewMessage(event) + }, + ) + }.onFailure { + accountRepository.showError("Failed to start Nostr: ${it.message}") + } + } + } + + 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() + } +}