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