field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit af31b0dbb33e7d03a052ca3d4318ab4ba8746972
parent 424feea8bce5fcb4c4f68bc43ac37e97d878624b
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 02:44:38 +0000

feat(ios): complete supporting product surfaces

- back Search and Me with the shared current projection
- reuse trusted card profile thread and media presentation rules
- expose local identity relay Blossom and outbox recovery routes
- remove dormant generic post profile relay and setup screens

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 12++++++++++++
MRadroots/App/AppEntry.swift | 4+++-
MRadroots/App/RadrootsAppModel.swift | 12++++++++++++
MRadroots/App/RadrootsRootShell.swift | 22+++++++++++++++-------
DRadroots/Runtime/Nostr.swift | 58----------------------------------------------------------
MRadroots/Runtime/RadrootsGeneratedRuntimeBackend.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/RadrootsRuntimeClient.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/RadrootsRuntimeModels.swift | 20++++++++++++++++++++
MRadroots/State/RadrootsAddStore.swift | 12++++++++++++
ARadroots/State/RadrootsSupportingStores.swift | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DRadroots/Views/PostCreateView.swift | 103-------------------------------------------------------------------------------
DRadroots/Views/PostDetailView.swift | 69---------------------------------------------------------------------
DRadroots/Views/PostFeedView.swift | 231-------------------------------------------------------------------------------
DRadroots/Views/PostFeedViewModel.swift | 119-------------------------------------------------------------------------------
DRadroots/Views/ProfileView.swift | 193-------------------------------------------------------------------------------
MRadroots/Views/RadrootsAddView.swift | 2+-
ARadroots/Views/RadrootsSupportingViews.swift | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/RadrootsTodayView.swift | 29++++++++++++++++++++++-------
DRadroots/Views/RelaysView.swift | 197-------------------------------------------------------------------------------
DRadroots/Views/SettingsView.swift | 234-------------------------------------------------------------------------------
DRadroots/Views/SetupView.swift | 208-------------------------------------------------------------------------------
ARadrootsTests/RadrootsSupportingStoreTests.swift | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 3+++
23 files changed, 1067 insertions(+), 1428 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 0486BFE6223D0FF0ACFA4249 /* RadrootsAddStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E3E5B1D1A0E1A32A83100 /* RadrootsAddStore.swift */; }; 27B7FB991398E2BE7034C72E /* RadrootsAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */; }; 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; }; + 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */; }; + 3C16EC0B109F76EEE8D3031A /* RadrootsSupportingStores.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478127DF070D6065A282C44A /* RadrootsSupportingStores.swift */; }; 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */; }; 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */; }; 499BCC8FC845FDD77E74FD99 /* RadrootsTodayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A6A6D1850A21E5516060E5E /* RadrootsTodayStore.swift */; }; @@ -19,6 +21,7 @@ 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; }; 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F432D5450B21DFADF99FACD1 /* Localizable.strings */; }; + 75275CDCE16FB99248793AB4 /* RadrootsSupportingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DD502141AA86FD5C948618 /* RadrootsSupportingViews.swift */; }; 75C044403AF08F7230DE9485 /* RadrootsRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF484F988B3D1D3D81880388 /* RadrootsRootShellUITests.swift */; }; 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */; }; 86DF51D41F92E1A695D83205 /* RadrootsTodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78F57CD1A43D46EE8CB35591 /* RadrootsTodayView.swift */; }; @@ -79,6 +82,7 @@ /* Begin PBXFileReference section */ 298DA81B0A000E307098C840 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + 29DD502141AA86FD5C948618 /* RadrootsSupportingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSupportingViews.swift; sourceTree = "<group>"; }; 2A6A6D1850A21E5516060E5E /* RadrootsTodayStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsTodayStore.swift; sourceTree = "<group>"; }; 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsIdentityStore.swift; sourceTree = "<group>"; }; 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; @@ -86,8 +90,10 @@ 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppModel.swift; sourceTree = "<group>"; }; 397ED29DF8E9BF73B4BBFDEC /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeModels.swift; sourceTree = "<group>"; }; + 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSupportingStoreTests.swift; sourceTree = "<group>"; }; 42C296DF1E35100FE59C04D3 /* RadrootsAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAddStoreTests.swift; sourceTree = "<group>"; }; 43B0EA447E213CD1B96846D6 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; + 478127DF070D6065A282C44A /* RadrootsSupportingStores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSupportingStores.swift; sourceTree = "<group>"; }; 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsProvider.swift; sourceTree = "<group>"; }; 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClient.swift; sourceTree = "<group>"; }; @@ -141,6 +147,7 @@ B8F89E4B3A775E88F661B891 /* RadrootsRootShellTests.swift */, D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */, C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */, + 41B94CEAFD958CE92D6B9265 /* RadrootsSupportingStoreTests.swift */, D91770F919A06DB39186281B /* RadrootsTodayStoreTests.swift */, ); path = RadrootsTests; @@ -204,6 +211,7 @@ DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */, 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */, 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */, + 478127DF070D6065A282C44A /* RadrootsSupportingStores.swift */, 2A6A6D1850A21E5516060E5E /* RadrootsTodayStore.swift */, ); name = State; @@ -261,6 +269,7 @@ isa = PBXGroup; children = ( CE0C073F20D8716C60FCCF2C /* RadrootsAddView.swift */, + 29DD502141AA86FD5C948618 /* RadrootsSupportingViews.swift */, 78F57CD1A43D46EE8CB35591 /* RadrootsTodayView.swift */, 9469790A906080D90F8735CB /* RuntimeStatusView.swift */, ); @@ -454,6 +463,7 @@ 7CF6D05AC3F7C8CAD56E3A91 /* RadrootsRootShellTests.swift in Sources */, CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */, 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */, + 3BCAFE2B1A96BBE5D741D8FC /* RadrootsSupportingStoreTests.swift in Sources */, FE9376121B0780485673EC06 /* RadrootsTodayStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -476,6 +486,8 @@ F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */, B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */, 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */, + 3C16EC0B109F76EEE8D3031A /* RadrootsSupportingStores.swift in Sources */, + 75275CDCE16FB99248793AB4 /* RadrootsSupportingViews.swift in Sources */, 499BCC8FC845FDD77E74FD99 /* RadrootsTodayStore.swift in Sources */, 86DF51D41F92E1A695D83205 /* RadrootsTodayView.swift in Sources */, CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */, diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -9,7 +9,9 @@ struct AppEntry: View { RadrootsRootShell( snapshot: snapshot, todayStore: appModel.todayStore, - addStore: appModel.addStore + addStore: appModel.addStore, + searchStore: appModel.searchStore, + meStore: appModel.meStore ) } else { RuntimeStatusView( diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -7,6 +7,8 @@ final class RadrootsAppModel: ObservableObject { @Published private(set) var phase: Phase = .starting private(set) var todayStore: RadrootsTodayStore? private(set) var addStore: RadrootsAddStore? + private(set) var searchStore: RadrootsSearchStore? + private(set) var meStore: RadrootsMeStore? private let sessionStore: RadrootsSessionStore? private let bootstrapFailure: RadrootsRuntimeFailure? @@ -22,6 +24,8 @@ final class RadrootsAppModel: ObservableObject { self.sessionStore = nil todayStore = nil addStore = nil + searchStore = nil + meStore = nil bootstrapFailure = nil isShellUITest = true phase = .running(.shellUITest) @@ -33,6 +37,8 @@ final class RadrootsAppModel: ObservableObject { self.sessionStore = sessionStore todayStore = runtimeClient.map { RadrootsTodayStore(runtimeClient: $0) } addStore = runtimeClient.map { RadrootsAddStore(runtimeClient: $0) } + searchStore = runtimeClient.map { RadrootsSearchStore(runtimeClient: $0) } + meStore = runtimeClient.map { RadrootsMeStore(runtimeClient: $0) } bootstrapFailure = nil } else { do { @@ -43,11 +49,15 @@ final class RadrootsAppModel: ObservableObject { runtimeClient: runtimeClient, media: Self.productionMediaCoordinator() ) + searchStore = RadrootsSearchStore(runtimeClient: runtimeClient) + meStore = RadrootsMeStore(runtimeClient: runtimeClient) bootstrapFailure = nil } catch let error as LocalizedError { self.sessionStore = nil todayStore = nil addStore = nil + searchStore = nil + meStore = nil bootstrapFailure = .local( operation: "app.bootstrap", code: "ios.app.configuration_invalid", @@ -57,6 +67,8 @@ final class RadrootsAppModel: ObservableObject { self.sessionStore = nil todayStore = nil addStore = nil + searchStore = nil + meStore = nil bootstrapFailure = .local( operation: "app.bootstrap", code: "ios.app.configuration_invalid", diff --git a/Radroots/App/RadrootsRootShell.swift b/Radroots/App/RadrootsRootShell.swift @@ -27,27 +27,35 @@ struct RadrootsRootShell: View { let snapshot: RadrootsRuntimeSnapshot let todayStore: RadrootsTodayStore? let addStore: RadrootsAddStore? + let searchStore: RadrootsSearchStore? + let meStore: RadrootsMeStore? @SceneStorage("radroots.selected_root_tab") private var storedSelection = RadrootsRootTab.today.rawValue init( snapshot: RadrootsRuntimeSnapshot, todayStore: RadrootsTodayStore? = nil, - addStore: RadrootsAddStore? = nil + addStore: RadrootsAddStore? = nil, + searchStore: RadrootsSearchStore? = nil, + meStore: RadrootsMeStore? = nil ) { self.snapshot = snapshot self.todayStore = todayStore self.addStore = addStore + self.searchStore = searchStore + self.meStore = meStore } var body: some View { TabView(selection: selection) { NavigationStack { - if let todayStore { + if let todayStore, let addStore, let searchStore, let meStore { RadrootsTodayView( snapshot: snapshot, store: todayStore, + searchStore: searchStore, + meStore: meStore, + addStore: addStore, revise: { card in - guard let addStore else { return } Task { await addStore.retractAndRevise(card) storedSelection = RadrootsRootTab.add.rawValue @@ -119,16 +127,16 @@ private struct RadrootsTodayLanding: View { } } .sheet(isPresented: $showsAccount) { - RadrootsAccountSheet(snapshot: snapshot) + RadrootsAccountUnavailableSheet(snapshot: snapshot) } .sheet(isPresented: $showsSearch) { - RadrootsSearchSheet() + RadrootsSearchUnavailableSheet() } .accessibilityIdentifier("radroots.today.root") } } -struct RadrootsSearchSheet: View { +struct RadrootsSearchUnavailableSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { @@ -145,7 +153,7 @@ struct RadrootsSearchSheet: View { } } -struct RadrootsAccountSheet: View { +struct RadrootsAccountUnavailableSheet: View { let snapshot: RadrootsRuntimeSnapshot @Environment(\.dismiss) private var dismiss diff --git a/Radroots/Runtime/Nostr.swift b/Radroots/Runtime/Nostr.swift @@ -1,58 +0,0 @@ -import Foundation - -public struct NostrEventId: Hashable, Sendable { - public let rawValue: String - - public init(_ rawValue: String) { - self.rawValue = rawValue - } -} - -public extension NostrPostEventMetadata { - var eventId: NostrEventId { - NostrEventId(id) - } -} - -public extension NostrProfileEventMetadata { - var eventId: NostrEventId { - NostrEventId(id) - } -} - -public extension FieldRuntimeService { - func nostrPostProfile( - name: String? = nil, - displayName: String? = nil, - nip05: String? = nil, - about: String? = nil - ) async throws -> NostrEventId { - let id = try await runtime.nostrPostProfile( - name: name, - displayName: displayName, - nip05: nip05, - about: about - ) - return NostrEventId(id) - } - - func nostrPostTextNote(content: String) async throws -> NostrEventId { - let id = try await runtime.nostrPostTextNote(content: content) - return NostrEventId(id) - } - - func nostrPostReply( - parentEventIdHex: String, - parentAuthorHex: String, - content: String, - rootEventIdHex: String? = nil - ) async throws -> NostrEventId { - let id = try await runtime.nostrPostReply( - parentEventIdHex: parentEventIdHex, - parentAuthorHex: parentAuthorHex, - content: content, - rootEventIdHex: rootEventIdHex - ) - return NostrEventId(id) - } -} diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -192,6 +192,38 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un } } + func search( + context: RadrootsLocalNetwork, + query: String, + limit: UInt16, + asOfUnixSeconds: UInt64 + ) async throws -> [RadrootsSearchResult] { + do { + return try await runtime.phase1Search( + context: context.generatedValue, + query: query, + limit: limit, + asOfUnixS: asOfUnixSeconds + ).map(\.appValue) + } catch { + throw Self.failure(from: error) + } + } + + func me( + context: RadrootsLocalNetwork, + asOfUnixSeconds: UInt64 + ) async throws -> RadrootsMeSnapshot { + do { + return try await runtime.phase1Me( + context: context.generatedValue, + asOfUnixS: asOfUnixSeconds + ).appValue + } catch { + throw Self.failure(from: error) + } + } + func addSchemas() async throws -> [RadrootsAddSchema] { runtime.phase1AddSchemas().map(\.appValue) } @@ -650,6 +682,36 @@ private extension FfiProfileRecord { } } +private extension FfiSearchResultRecord { + var appValue: RadrootsSearchResult { + RadrootsSearchResult( + type: resultType.appValue, + id: stableId, + card: card?.appValue, + profile: profile?.appValue + ) + } +} + +private extension FfiSearchResultType { + var appValue: RadrootsSearchResultType { + switch self { + case .card: .card + case .profile: .profile + } + } +} + +private extension FfiMeRecord { + var appValue: RadrootsMeSnapshot { + RadrootsMeSnapshot( + publicKey: publicKey, + profile: profile?.appValue, + cards: cards.map(\.appValue) + ) + } +} + private extension FfiThreadEntryRecord { var appValue: RadrootsThreadEntry { RadrootsThreadEntry( diff --git a/Radroots/Runtime/RadrootsRuntimeClient.swift b/Radroots/Runtime/RadrootsRuntimeClient.swift @@ -12,6 +12,16 @@ protocol RadrootsRuntimeBackend: Sendable { nowUnixSeconds: UInt64, update: RadrootsTodayProjectionUpdate ) async throws -> RadrootsTodayRefreshReceipt + func search( + context: RadrootsLocalNetwork, + query: String, + limit: UInt16, + asOfUnixSeconds: UInt64 + ) async throws -> [RadrootsSearchResult] + func me( + context: RadrootsLocalNetwork, + asOfUnixSeconds: UInt64 + ) async throws -> RadrootsMeSnapshot func addSchemas() async throws -> [RadrootsAddSchema] func saveDraft( id: String, @@ -50,6 +60,14 @@ protocol RadrootsRuntimeBackend: Sendable { } extension RadrootsRuntimeBackend { + private func supportUnsupported() -> RadrootsRuntimeFailure { + .local( + operation: "runtime.support", + code: "ios.support.unsupported", + safeMessage: "This supporting surface is unavailable in the current runtime." + ) + } + private func addUnsupported() -> RadrootsRuntimeFailure { .local( operation: "runtime.add", @@ -120,6 +138,22 @@ extension RadrootsRuntimeBackend { ) async throws -> RadrootsDraftStatus { throw addUnsupported() } + + func search( + context _: RadrootsLocalNetwork, + query _: String, + limit _: UInt16, + asOfUnixSeconds _: UInt64 + ) async throws -> [RadrootsSearchResult] { + throw supportUnsupported() + } + + func me( + context _: RadrootsLocalNetwork, + asOfUnixSeconds _: UInt64 + ) async throws -> RadrootsMeSnapshot { + throw supportUnsupported() + } } struct RadrootsRuntimeBackendStart: Sendable { @@ -260,6 +294,31 @@ actor RadrootsRuntimeClient { } } + func search( + context: RadrootsLocalNetwork, + query: String, + limit: UInt16 = 50, + asOfUnixSeconds: UInt64 + ) async throws -> [RadrootsSearchResult] { + try await supportOperation("runtime.support.search") { backend in + try await backend.search( + context: context, + query: query, + limit: limit, + asOfUnixSeconds: asOfUnixSeconds + ) + } + } + + func me( + context: RadrootsLocalNetwork, + asOfUnixSeconds: UInt64 + ) async throws -> RadrootsMeSnapshot { + try await supportOperation("runtime.support.me") { backend in + try await backend.me(context: context, asOfUnixSeconds: asOfUnixSeconds) + } + } + func addSchemas() async throws -> [RadrootsAddSchema] { try await addOperation("runtime.add.schemas") { backend in try await backend.addSchemas() @@ -558,6 +617,22 @@ actor RadrootsRuntimeClient { } } + private func supportOperation<T: Sendable>( + _ operation: String, + _ body: @Sendable (any RadrootsRuntimeBackend) async throws -> T + ) async throws -> T { + guard let backend, case .running = lifecycleState else { + throw RadrootsRuntimeClientError.notRunning + } + do { + return try await body(backend) + } catch { + throw RadrootsRuntimeClientError.support( + Self.failure(from: error, operation: operation) + ) + } + } + private func receive( _ change: RadrootsRuntimeChange, subscriptionID: UUID, @@ -602,6 +677,9 @@ actor RadrootsRuntimeClient { if case let RadrootsRuntimeClientError.add(failure) = error { return failure } + if case let RadrootsRuntimeClientError.support(failure) = error { + return failure + } if case let RadrootsRuntimeClientError.shutdown(failure) = error { return failure } diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -180,6 +180,7 @@ enum RadrootsRuntimeClientError: Error, Sendable, Equatable { case status(RadrootsRuntimeFailure) case today(RadrootsRuntimeFailure) case add(RadrootsRuntimeFailure) + case support(RadrootsRuntimeFailure) case shutdown(RadrootsRuntimeFailure) } @@ -197,6 +198,7 @@ extension RadrootsRuntimeClientError: LocalizedError { let .status(failure), let .today(failure), let .add(failure), + let .support(failure), let .shutdown(failure): failure.safeMessage } @@ -429,6 +431,24 @@ struct RadrootsTodayRefreshReceipt: Sendable, Equatable { let changed: Bool } +enum RadrootsSearchResultType: Sendable, Equatable, Hashable { + case card + case profile +} + +struct RadrootsSearchResult: Sendable, Equatable, Hashable, Identifiable { + let type: RadrootsSearchResultType + let id: String + let card: RadrootsTodayCard? + let profile: RadrootsProfileSummary? +} + +struct RadrootsMeSnapshot: Sendable, Equatable { + let publicKey: String + let profile: RadrootsProfileSummary? + let cards: [RadrootsTodayCard] +} + enum RadrootsAddCommandType: String, CaseIterable, Sendable, Equatable, Hashable, Identifiable { case createUpdate case createPhotoUpdate diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -159,6 +159,18 @@ final class RadrootsAddStore: ObservableObject { } } + func refreshMediaSupport() async { + do { + mediaSupport = try await loadMediaSupport() + message = mediaSupport == .unavailable + ? "Photo intake is unavailable for the current configuration." + : "Photo service is ready." + } catch { + mediaSupport = .unavailable + message = Self.message(for: error) + } + } + func capturePhoto() async { guard isFormEditable, form.commandType.acceptsMedia, let media else { message = "Camera intake is unavailable." diff --git a/Radroots/State/RadrootsSupportingStores.swift b/Radroots/State/RadrootsSupportingStores.swift @@ -0,0 +1,200 @@ +import Foundation + +enum RadrootsSupportingLoadState: Sendable, Equatable { + case idle + case loading + case loaded + case empty + case failed(String) +} + +@MainActor +final class RadrootsSearchStore: ObservableObject { + @Published private(set) var query = "" + @Published private(set) var results: [RadrootsSearchResult] = [] + @Published private(set) var state: RadrootsSupportingLoadState = .idle + + private let runtimeClient: RadrootsRuntimeClient + private let now: @Sendable () -> UInt64 + private var context: RadrootsLocalNetwork? + private var generation: UInt64 = 0 + + init( + runtimeClient: RadrootsRuntimeClient, + now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) } + ) { + self.runtimeClient = runtimeClient + self.now = now + } + + func configure(context: RadrootsLocalNetwork?) { + guard self.context != context else { return } + generation &+= 1 + self.context = context + results = [] + state = .idle + } + + func updateQuery(_ value: String) { + query = String(value.prefix(256)) + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + generation &+= 1 + results = [] + state = .idle + } + } + + func search() async { + guard let context else { + state = .failed("Choose a local network before searching.") + return + } + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, + normalized.utf8.count <= 256, + !normalized.contains(where: \.isNewline), + !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { + results = [] + state = .idle + return + } + + generation &+= 1 + let requestedGeneration = generation + state = .loading + do { + let loaded = try await runtimeClient.search( + context: context, + query: normalized, + limit: 50, + asOfUnixSeconds: now() + ) + guard requestedGeneration == generation, !Task.isCancelled else { return } + results = Self.unique(loaded) + state = results.isEmpty ? .empty : .loaded + } catch { + guard requestedGeneration == generation, !Task.isCancelled else { return } + results = [] + state = .failed(Self.message(for: error)) + } + } + + func stop() { + generation &+= 1 + state = .idle + } + + private static func unique(_ values: [RadrootsSearchResult]) -> [RadrootsSearchResult] { + var identifiers = Set<String>() + return values.filter { identifiers.insert("\($0.type):\($0.id)").inserted } + } + + private static func message(for error: Error) -> String { + if case let RadrootsRuntimeClientError.support(failure) = error { + return failure.safeMessage + } + return (error as? LocalizedError)?.errorDescription ?? "Search is unavailable." + } +} + +@MainActor +final class RadrootsMeStore: ObservableObject { + @Published private(set) var snapshot: RadrootsMeSnapshot? + @Published private(set) var state: RadrootsSupportingLoadState = .idle + + private let runtimeClient: RadrootsRuntimeClient + private let now: @Sendable () -> UInt64 + private var context: RadrootsLocalNetwork? + private var generation: UInt64 = 0 + private var observationTask: Task<Void, Never>? + private var isStarted = false + + init( + runtimeClient: RadrootsRuntimeClient, + now: @escaping @Sendable () -> UInt64 = { UInt64(Date().timeIntervalSince1970) } + ) { + self.runtimeClient = runtimeClient + self.now = now + } + + deinit { + observationTask?.cancel() + } + + func configure(context: RadrootsLocalNetwork?) { + guard self.context != context else { return } + generation &+= 1 + self.context = context + snapshot = nil + state = .idle + } + + func start() async { + if !isStarted { + isStarted = true + observationTask = Task { [weak self, runtimeClient] in + do { + let changes = try await runtimeClient.changes(bufferCapacity: 8) + for await change in changes { + guard !Task.isCancelled else { break } + switch change.kind { + case .today, .identity, .media, .drafts: + await self?.reload() + case .initial, .relay, .lifecycle: + continue + } + } + } catch { + // Manual loading remains available from the durable projection. + } + self?.observationDidFinish() + } + } + await reload() + } + + func reload() async { + guard let context else { + snapshot = nil + state = .failed("Choose a local network before loading your profile.") + return + } + generation &+= 1 + let requestedGeneration = generation + if snapshot == nil { + state = .loading + } + do { + let loaded = try await runtimeClient.me( + context: context, + asOfUnixSeconds: now() + ) + guard requestedGeneration == generation, !Task.isCancelled else { return } + snapshot = loaded + state = loaded.cards.isEmpty && loaded.profile == nil ? .empty : .loaded + } catch { + guard requestedGeneration == generation, !Task.isCancelled else { return } + state = .failed(Self.message(for: error)) + } + } + + func stop() { + generation &+= 1 + isStarted = false + observationTask?.cancel() + observationTask = nil + } + + private func observationDidFinish() { + observationTask = nil + isStarted = false + } + + private static func message(for error: Error) -> String { + if case let RadrootsRuntimeClientError.support(failure) = error { + return failure.safeMessage + } + return (error as? LocalizedError)?.errorDescription ?? "Your profile is unavailable." + } +} diff --git a/Radroots/Views/PostCreateView.swift b/Radroots/Views/PostCreateView.swift @@ -1,103 +0,0 @@ -import SwiftUI - -struct PostCreateView: View { - @EnvironmentObject private var app: AppState - @State private var text: String = "" - @State private var isPosting = false - @State private var resultMessage: String? - @State private var showResult = false - @FocusState private var focused: Bool - - private var isConnected: Bool { app.relaySinkAvailable } - private var canPost: Bool { - isConnected && !isPosting && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - var body: some View { - Form { - Section { - ZStack(alignment: .topLeading) { - TextEditor(text: $text) - .frame(minHeight: 160) - .focused($focused) - .submitLabel(.send) - if text.isEmpty { - Text("What's happening?") - .foregroundStyle(.secondary) - .padding(.top, 8) - .padding(.leading, 5) - .allowsHitTesting(false) - } - } - HStack { - Text("\(text.count)") - .font(.footnote) - .foregroundStyle(.secondary) - Spacer() - Button { - post() - } label: { - if isPosting { - ProgressView() - } else { - Text("Post") - .fontWeight(.semibold) - } - } - .buttonStyle(.borderedProminent) - .disabled(!canPost) - } - } footer: { - VStack(alignment: .leading, spacing: 4) { - if !isConnected { - Text("No relays connected. Configure and connect to post.") - .foregroundStyle(.red) - } - if let e = app.relayLastError { - Text(e).foregroundStyle(.secondary) - } - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Compose") - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { focused = false } - } - } - .alert("Post Result", isPresented: $showResult) { - Button("OK", role: .cancel) { } - } message: { - Text(resultMessage ?? "") - } - .onAppear { focused = true } - } - - private func post() { - guard let service = app.runtimeService else { return } - let content = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !content.isEmpty else { return } - isPosting = true - resultMessage = nil - Task { - do { - let id = try await service.nostrPostTextNote(content: content) - await MainActor.run { - resultMessage = "Posted kind:1 event: \(id.rawValue)" - showResult = true - text = "" - isPosting = false - app.refresh() - } - } catch { - await MainActor.run { - resultMessage = "Failed to post: \(error.fieldRuntimeMessage)" - showResult = true - isPosting = false - } - } - } - } -} diff --git a/Radroots/Views/PostDetailView.swift b/Radroots/Views/PostDetailView.swift @@ -1,69 +0,0 @@ -import RadrootsKit -import SwiftUI - -struct PostDetailView: View { - @EnvironmentObject private var app: AppState - let post: NostrPostEventMetadata - @State private var showCopied = false - - var body: some View { - List { - Section("Content") { - Text(post.post.content) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - - Section("Event") { - CopyRow(title: "Author", value: post.author) { showCopied = true } - CopyRow(title: "Event ID", value: post.id) { showCopied = true } - - LabeledContent("Published") { - VStack(alignment: .trailing, spacing: 2) { - Text(absoluteDate) - Text(relativeDate) - .foregroundStyle(.secondary) - .font(.footnote) - } - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Post") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if let shareRequest = try? app.publicPostShareRequest(content: post.post.content), - let shareLink = try? RadrootsSharePresentationLink(request: shareRequest, label: { - Image(systemName: "square.and.arrow.up") - }) { - shareLink - .accessibilityIdentifier("field_ios.post.share") - } else { - Button {} label: { - Image(systemName: "square.and.arrow.up") - } - .disabled(true) - .accessibilityIdentifier("field_ios.post.share_unavailable") - } - } - } - .toast(isPresented: $showCopied) { - Text("Copied") - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.thinMaterial, in: Capsule()) - } - } - - private var absoluteDate: String { - let d = Date(timeIntervalSince1970: TimeInterval(post.publishedAt)) - return d.formatted(date: .abbreviated, time: .shortened) - } - - private var relativeDate: String { - let d = Date(timeIntervalSince1970: TimeInterval(post.publishedAt)) - let f = RelativeDateTimeFormatter() - f.unitsStyle = .full - return f.localizedString(for: d, relativeTo: Date()) - } -} diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -1,231 +0,0 @@ -import SwiftUI - -struct PostFeedView: View { - @EnvironmentObject private var app: AppState - @StateObject private var vm = PostFeedViewModel() - @State private var resultTitle: String = "" - @State private var resultMessage: String = "" - @State private var showResult: Bool = false - - var body: some View { - List { - if let e = vm.errorMessage { - Section { - Text(e) - .foregroundStyle(.red) - .font(.footnote) - } - } - - if vm.posts.isEmpty && vm.errorMessage == nil && !vm.isLoading { - ContentUnavailableView( - "No Posts Yet", - systemImage: "text.bubble", - description: Text("Connect to relays to load the public feed.") - ) - .listRowBackground(Color.clear) - } else { - Section { - ForEach(vm.posts, id: \.id) { item in - FeedPostRow( - post: item, - isExpanded: vm.expandedReplyFor == item.id, - onToggleReply: { vm.toggleReply(for: item.id) } - ) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - - if vm.expandedReplyFor == item.id { - InlineReplyComposer( - text: vm.bindingForReply(item.id), - isSending: vm.sendingReplyFor.contains(item.id), - onCancel: { vm.expandedReplyFor = nil }, - onSend: { - vm.sendReply(app: app, to: item) { title, message in - resultTitle = title - resultMessage = message - showResult = true - } - } - ) - .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) - .listRowSeparator(.hidden) - } - } - } footer: { - if !app.relaySourceAvailable { - Text("No relay source is available. Configure a relay to load posts.") - } - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Feed") - .accessibilityIdentifier("field_ios.feed") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if vm.isLoading { ProgressView() } - } - ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - PostCreateView() - } label: { - Image(systemName: "square.and.pencil") - } - } - ToolbarItem(placement: .topBarTrailing) { - Button { Task { await vm.refresh(app: app) } } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(vm.isLoading) - } - } - .task { vm.onAppear(app: app) } - .onDisappear { vm.onDisappear(app: app) } - .refreshable { await vm.refresh(app: app) } - .alert(resultTitle, isPresented: $showResult) { - Button("OK", role: .cancel) { } - } message: { - Text(resultMessage) - } - } -} - -fileprivate struct FeedPostRow: View { - let post: NostrPostEventMetadata - let isExpanded: Bool - let onToggleReply: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 12) { - AvatarView(seed: post.author) - .frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Text(shortAuthor(post.author)) - .font(.callout.monospaced()) - .foregroundStyle(.primary) - .lineLimit(1) - Text("·") - .foregroundStyle(.secondary) - Text(relativeTime(post.publishedAt)) - .font(.footnote) - .foregroundStyle(.secondary) - } - Text(post.post.content) - .font(.body) - .multilineTextAlignment(.leading) - HStack(spacing: 24) { - Button(action: onToggleReply) { - HStack(spacing: 6) { - Image(systemName: isExpanded ? "bubble.left.fill" : "bubble.left") - Text("Reply") - } - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - .font(.subheadline.weight(.semibold)) - .padding(.top, 2) - } - } - .padding(.vertical, 4) - } - - private func relativeTime(_ unix: UInt64) -> String { - let d = Date(timeIntervalSince1970: TimeInterval(unix)) - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - return f.localizedString(for: d, relativeTo: Date()) - } - - private func shortAuthor(_ s: String) -> String { - let n = s.count - if n <= 12 { return s } - let prefix = s.prefix(6) - let suffix = s.suffix(6) - return "\(prefix)…\(suffix)" - } -} - - -fileprivate struct InlineReplyComposer: View { - @Binding var text: String - let isSending: Bool - let onCancel: () -> Void - let onSend: () -> Void - @FocusState private var focused: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - ZStack(alignment: .topLeading) { - TextEditor(text: $text) - .font(.body) - .padding(10) - .frame(minHeight: 160, maxHeight: 220, alignment: .topLeading) - .scrollContentBackground(.hidden) - .background(Color.secondary.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .focused($focused) - - if text.isEmpty { - Text("Write a reply") - .foregroundStyle(.secondary) - .padding(.top, 16) - .padding(.leading, 16) - .allowsHitTesting(false) - } - } - - HStack { - Spacer() - Button("Cancel", role: .cancel) { onCancel() } - .buttonStyle(.bordered) - Button { - onSend() - } label: { - if isSending { - ProgressView() - } else { - Text("Send").fontWeight(.semibold) - } - } - .buttonStyle(.borderedProminent) - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) - } - } - .onAppear { focused = true } - } -} - -fileprivate struct AvatarView: View { - let seed: String - - var body: some View { - ZStack { - Circle().fill(gradient) - Text(initials) - .font(.caption.weight(.bold)) - .foregroundStyle(.white.opacity(0.9)) - } - .accessibilityHidden(true) - } - - private var initials: String { - let trimmed = seed.replacingOccurrences(of: "npub1", with: "") - return trimmed.first.map { String($0).uppercased() } ?? "?" - } - - private var gradient: LinearGradient { - let hash = abs(seed.hashValue) - let hue = Double(hash % 360) / 360.0 - let c1 = Color(hue: hue, saturation: 0.65, brightness: 0.85) - let c2 = Color(hue: (hue + 0.08).truncatingRemainder(dividingBy: 1), - saturation: 0.55, - brightness: 0.75) - return LinearGradient( - gradient: Gradient(colors: [c1, c2]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } -} diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -1,119 +0,0 @@ -import SwiftUI - -@MainActor -final class PostFeedViewModel: ObservableObject { - @Published var posts: [NostrPostEventMetadata] = [] - @Published var isLoading = false - @Published var errorMessage: String? - @Published var expandedReplyFor: String? - @Published var draftReplies: [String: String] = [:] - @Published var sendingReplyFor: Set<String> = [] - - private var liveTask: Task<Void, Never>? - - func onAppear(app: AppState) { - if posts.isEmpty { - Task { await load(app: app) } - } - startPolling(app: app) - } - - func onDisappear(app: AppState) { - liveTask?.cancel() - liveTask = nil - } - - func load(app: AppState) async { - guard let service = app.runtimeService else { return } - isLoading = true - errorMessage = nil - do { - let fetched = try await service.nostrFetchTextNotes(limit: 50, sinceUnix: nil) - posts = fetched.sorted { $0.publishedAt > $1.publishedAt } - isLoading = false - } catch { - errorMessage = error.fieldRuntimeMessage - isLoading = false - } - } - - func refresh(app: AppState) async { - await load(app: app) - } - - func toggleReply(for id: String) { - expandedReplyFor = expandedReplyFor == id ? nil : id - } - - func bindingForReply(_ id: String) -> Binding<String> { - Binding( - get: { self.draftReplies[id, default: ""] }, - set: { self.draftReplies[id] = $0 } - ) - } - - func sendReply( - app: AppState, - to post: NostrPostEventMetadata, - setResult: @escaping @MainActor @Sendable (_ title: String, _ message: String) -> Void - ) { - guard let service = app.runtimeService else { return } - let raw = draftReplies[post.id, default: ""] - let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !reply.isEmpty else { return } - sendingReplyFor.insert(post.id) - - Task { @MainActor in - let parentId = post.id - let parentAuthor = post.author - let text = reply - - do { - let id = try await service.nostrPostReply( - parentEventIdHex: parentId, - parentAuthorHex: parentAuthor, - content: text, - rootEventIdHex: nil as String? - ) - draftReplies[parentId] = "" - expandedReplyFor = nil - sendingReplyFor.remove(parentId) - setResult("Reply Posted", "Event \(id.rawValue)") - } catch { - sendingReplyFor.remove(parentId) - setResult("Failed to Post Reply", error.fieldRuntimeMessage) - } - } - } - - - private func startPolling(app: AppState) { - guard liveTask == nil else { return } - liveTask = Task { @MainActor [weak self] in - guard let self else { return } - guard let service = app.runtimeService else { return } - while !Task.isCancelled { - if !app.relaySourceAvailable { - try? await Task.sleep(for: .seconds(2)) - continue - } - - do { - let since = posts.map(\.publishedAt).max() - let fetched = try await service.nostrFetchTextNotes(limit: 50, sinceUnix: since) - let knownIds = Set(posts.map(\.id)) - let newPosts = fetched.filter { !knownIds.contains($0.id) } - if !newPosts.isEmpty { - posts.append(contentsOf: newPosts) - posts.sort { $0.publishedAt > $1.publishedAt } - posts = Array(posts.prefix(200)) - } - errorMessage = nil - } catch { - errorMessage = error.fieldRuntimeMessage - } - try? await Task.sleep(for: .seconds(5)) - } - } - } -} diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift @@ -1,193 +0,0 @@ -import SwiftUI - -public struct ProfileView: View { - @EnvironmentObject private var app: AppState - - @State private var name: String = "" - @State private var displayName: String = "" - @State private var nip05: String = "" - @State private var about: String = "" - - @State private var original: OriginalProfile = .empty - @State private var isLoading: Bool = false - @State private var isPosting: Bool = false - @State private var profileReadMessage: String? - @State private var postMessage: String? - @State private var showMessage: Bool = false - @FocusState private var focusedField: Field? - - enum Field: Hashable { case name, displayName, nip05, about } - - public init() {} - - public var body: some View { - Form { - Section(header: Text("Profile")) { - VStack(alignment: .leading, spacing: 8) { - Text("name").font(.footnote).foregroundStyle(.secondary) - TextField("name", text: $name) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.next) - .focused($focusedField, equals: .name) - .onSubmit { focusedField = .displayName } - } - - VStack(alignment: .leading, spacing: 8) { - Text("display_name").font(.footnote).foregroundStyle(.secondary) - TextField("display name", text: $displayName) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .submitLabel(.next) - .focused($focusedField, equals: .displayName) - .onSubmit { focusedField = .nip05 } - } - - VStack(alignment: .leading, spacing: 8) { - Text("nip05").font(.footnote).foregroundStyle(.secondary) - TextField("user@example.com", text: $nip05) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.next) - .focused($focusedField, equals: .nip05) - .onSubmit { focusedField = .about } - } - - VStack(alignment: .leading, spacing: 8) { - Text("about").font(.footnote).foregroundStyle(.secondary) - TextEditor(text: $about) - .frame(minHeight: 120) - .focused($focusedField, equals: .about) - } - } - - Section { - SectionWideButton("Post Kind 0", enabled: isPostEnabled, isProminent: hasChanges) { - post() - } - .animation(.easeInOut(duration: 0.15), value: hasChanges) - } footer: { - VStack(alignment: .leading, spacing: 6) { - if let profileReadMessage { - Text(profileReadMessage) - } - if !isConnected { - Text("No relays connected. Connect to at least one relay to post.") - } - if let msg = postMessage { - Text(msg) - } - } - } - } - .listStyle(.insetGrouped) - .scrollDismissesKeyboard(.interactively) - .inlineNavigationTitle("Profile") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if isLoading || isPosting { ProgressView() } - } - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { focusedField = nil } - } - } - .onAppear { loadProfile() } - .refreshable { loadProfile() } - .alert("Post Result", isPresented: $showMessage) { - Button("OK", role: .cancel) { } - } message: { - Text(postMessage ?? "") - } - } - - private var isConnected: Bool { app.relaySinkAvailable } - private var isPostEnabled: Bool { isConnected && !isPosting } - private var hasChanges: Bool { - name != original.name || - displayName != original.displayName || - nip05 != original.nip05 || - about != original.about - } - - private func loadProfile() { - guard let service = app.runtimeService else { return } - isLoading = true - Task { - do { - let meta = try await service.nostrProfileForSelf() - let loaded = OriginalProfile.from(meta) - await MainActor.run { - self.original = loaded - self.name = loaded.name - self.displayName = loaded.displayName - self.nip05 = loaded.nip05 - self.about = loaded.about - self.profileReadMessage = meta == nil ? "No profile event found yet." : nil - self.isLoading = false - } - } catch { - await MainActor.run { - self.profileReadMessage = error.fieldRuntimeMessage - self.isLoading = false - } - } - } - } - - private func post() { - guard let service = app.runtimeService, isPostEnabled else { return } - isPosting = true - postMessage = nil - let payload = PostPayload(name: name, displayName: displayName, nip05: nip05, about: about) - Task { - do { - let id = try await service.nostrPostProfile( - name: payload.name, - displayName: payload.displayName, - nip05: payload.nip05, - about: payload.about - ) - await MainActor.run { - self.original = OriginalProfile(name: name, displayName: displayName, nip05: nip05, about: about) - self.isPosting = false - self.postMessage = "Posted kind:0 event: \(id.rawValue)" - self.showMessage = true - self.app.refresh() - } - } catch { - await MainActor.run { - self.isPosting = false - self.postMessage = "Failed to post profile: \(error.fieldRuntimeMessage)" - self.showMessage = true - } - } - } - } -} - -private struct OriginalProfile: Equatable { - var name: String - var displayName: String - var nip05: String - var about: String - - static let empty = OriginalProfile(name: "", displayName: "", nip05: "", about: "") - - static func from(_ p: NostrProfileEventMetadata?) -> OriginalProfile { - OriginalProfile( - name: p?.profile.name ?? "", - displayName: p?.profile.displayName ?? "", - nip05: p?.profile.nip05 ?? "", - about: p?.profile.about ?? "" - ) - } -} - -private struct PostPayload { - var name: String - var displayName: String - var nip05: String - var about: String -} diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift @@ -300,7 +300,7 @@ struct RadrootsAddView: View { }() } -private struct RadrootsDraftsSheet: View { +struct RadrootsDraftsSheet: View { @ObservedObject var store: RadrootsAddStore @Environment(\.dismiss) private var dismiss diff --git a/Radroots/Views/RadrootsSupportingViews.swift b/Radroots/Views/RadrootsSupportingViews.swift @@ -0,0 +1,355 @@ +import SwiftUI + +struct RadrootsSearchSheet: View { + let snapshot: RadrootsRuntimeSnapshot + let context: RadrootsLocalNetwork? + @ObservedObject var store: RadrootsSearchStore + let revise: (RadrootsTodayCard) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + switch store.state { + case .idle: + ContentUnavailableView( + "Search your local network", + systemImage: "magnifyingglass", + description: Text("Find current posts and adopted Nostr profiles.") + ) + case .loading: + ProgressView("Searching…") + case .empty: + ContentUnavailableView.search(text: store.query) + case let .failed(message): + ContentUnavailableView { + Label("Search unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try again") { Task { await store.search() } } + } + case .loaded: + resultList + } + } + .navigationTitle("Search") + .searchable(text: query, prompt: "Posts and profiles") + .onSubmit(of: .search) { Task { await store.search() } } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .task { store.configure(context: context) } + .accessibilityIdentifier("radroots.support.search.sheet") + } + + private var resultList: some View { + List(store.results) { result in + switch (result.card, result.profile) { + case let (card?, _): + NavigationLink { + RadrootsTodayDetailView( + card: card, + canRevise: card.authorPublicKey == snapshot.identity.publicKeyHex, + revise: revise + ) + } label: { + RadrootsTodayCardView(card: card) + } + .accessibilityIdentifier("radroots.search.card.\(result.id)") + case let (_, profile?): + NavigationLink { + RadrootsProfileView(profile: profile) + } label: { + RadrootsProfileRow(profile: profile) + } + .accessibilityIdentifier("radroots.search.profile.\(result.id)") + default: + EmptyView() + } + } + .listStyle(.plain) + .accessibilityIdentifier("radroots.search.results") + } + + private var query: Binding<String> { + Binding( + get: { store.query }, + set: { value in store.updateQuery(value) } + ) + } +} + +struct RadrootsMeSheet: View { + let runtimeSnapshot: RadrootsRuntimeSnapshot + let context: RadrootsLocalNetwork? + @ObservedObject var store: RadrootsMeStore + @ObservedObject var todayStore: RadrootsTodayStore + @ObservedObject var addStore: RadrootsAddStore + let revise: (RadrootsTodayCard) -> Void + @Environment(\.dismiss) private var dismiss + @State private var showsDrafts = false + + var body: some View { + NavigationStack { + Group { + switch store.state { + case .idle, .loading: + ProgressView("Loading your profile…") + case let .failed(message) where store.snapshot == nil: + ContentUnavailableView { + Label("Profile unavailable", systemImage: "person.crop.circle.badge.exclamationmark") + } description: { + Text(message) + } actions: { + Button("Try again") { Task { await store.reload() } } + } + default: + content + } + } + .navigationTitle("Me") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .sheet(isPresented: $showsDrafts) { + RadrootsDraftsSheet(store: addStore) + } + .task { + store.configure(context: context) + await store.start() + } + .presentationDetents([.medium, .large]) + .accessibilityIdentifier("radroots.support.me.sheet") + } + + private var content: some View { + List { + Section { + if let profile = store.snapshot?.profile { + NavigationLink { + RadrootsProfileView(profile: profile) + } label: { + RadrootsProfileRow(profile: profile) + } + } else { + RadrootsProfileRow( + profile: RadrootsProfileSummary( + authorPublicKey: store.snapshot?.publicKey ?? runtimeSnapshot.identity.publicKeyHex, + name: nil, + displayName: nil, + about: nil, + picture: nil, + banner: nil, + nip05: nil, + website: nil, + lightningAddress: nil + ) + ) + } + } + + Section("Local work") { + Button { + showsDrafts = true + } label: { + LabeledContent("Drafts & outbox", value: "\(addStore.drafts.count)") + } + .foregroundStyle(.primary) + .accessibilityIdentifier("radroots.support.outbox") + } + + Section("My posts") { + if store.snapshot?.cards.isEmpty != false { + Text("No current posts in this local network.") + .foregroundStyle(.secondary) + } + ForEach(store.snapshot?.cards ?? []) { card in + NavigationLink { + RadrootsTodayDetailView(card: card, canRevise: true, revise: revise) + } label: { + RadrootsTodayCardView(card: card) + } + } + } + + Section { + NavigationLink("Settings") { + RadrootsSettingsView( + snapshot: runtimeSnapshot, + todayStore: todayStore, + addStore: addStore + ) + } + .accessibilityIdentifier("radroots.support.settings") + } + } + .refreshable { await store.reload() } + } +} + +struct RadrootsProfileRow: View { + let profile: RadrootsProfileSummary + + var body: some View { + HStack(spacing: 12) { + RadrootsStableAvatarView(profile: profile, size: 48) + VStack(alignment: .leading, spacing: 3) { + Text(profile.preferredName) + .font(.headline) + if let nip05 = profile.nip05 { + Text(nip05) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(Self.abbreviate(profile.authorPublicKey)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(profile.preferredName), \(Self.abbreviate(profile.authorPublicKey))") + } + + private static func abbreviate(_ key: String) -> String { + guard key.count > 16 else { return key } + return "\(key.prefix(8))…\(key.suffix(8))" + } +} + +struct RadrootsProfileView: View { + let profile: RadrootsProfileSummary + + var body: some View { + List { + Section { + VStack(spacing: 12) { + RadrootsStableAvatarView(profile: profile, size: 88) + Text(profile.preferredName) + .font(.title2.weight(.semibold)) + Text(abbreviatedPublicKey) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + } + if let about = profile.about, !about.isEmpty { + Section("About") { Text(about) } + } + Section("Nostr profile") { + if let nip05 = profile.nip05 { + LabeledContent("NIP-05", value: nip05) + } + if let website = safeHTTPS(profile.website) { + Link(destination: website) { + LabeledContent("Website", value: website.host ?? website.absoluteString) + } + } + if let address = profile.lightningAddress { + LabeledContent("Lightning", value: address) + } + } + } + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("radroots.profile.\(profile.authorPublicKey)") + } + + private var abbreviatedPublicKey: String { + guard profile.authorPublicKey.count > 16 else { return profile.authorPublicKey } + return "\(profile.authorPublicKey.prefix(8))…\(profile.authorPublicKey.suffix(8))" + } + + private func safeHTTPS(_ value: String?) -> URL? { + guard let value, + let url = URL(string: value), + url.scheme?.lowercased() == "https", + url.host != nil, + url.user == nil, + url.password == nil + else { + return nil + } + return url + } +} + +struct RadrootsStableAvatarView: View { + let profile: RadrootsProfileSummary + let size: CGFloat + + var body: some View { + let identity = RadrootsStableVisualIdentity(publicKeyHex: profile.authorPublicKey) + ZStack { + Circle().fill(Self.palette[identity.paletteIndex]) + Text(String(profile.preferredName.prefix(1)).uppercased()) + .font(.system(size: size * 0.4, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private static let palette: [Color] = [ + .indigo, .mint, .orange, .purple, .teal, .pink, + .blue, .green, .red, .cyan, .brown, .yellow, + ] +} + +struct RadrootsSettingsView: View { + let snapshot: RadrootsRuntimeSnapshot + @ObservedObject var todayStore: RadrootsTodayStore + @ObservedObject var addStore: RadrootsAddStore + + var body: some View { + List { + Section("Identity") { + LabeledContent("Local signer", value: snapshot.identity.hostSignerConfigured ? "Ready" : "Needs attention") + LabeledContent("Public key", value: abbreviatedPublicKey) + } + Section("Nostr relays") { + if snapshot.relay?.relays.isEmpty != false { + Text("No relay is configured for this profile.") + .foregroundStyle(.secondary) + } + ForEach(snapshot.relay?.relays ?? [], id: \.url) { relay in + VStack(alignment: .leading, spacing: 4) { + Text(relay.url).font(.caption.monospaced()) + Text("\(relay.access.replacingOccurrences(of: "_", with: " ")) · read \(relay.readState) · write \(relay.writeState)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Button("Retry local network") { Task { await todayStore.reload() } } + .accessibilityIdentifier("radroots.settings.retry.network") + } + Section("Blossom photos") { + LabeledContent("Photo library", value: addStore.mediaSupport.library ? "Ready" : "Unavailable") + LabeledContent("Camera", value: addStore.mediaSupport.camera ? "Ready" : "Unavailable") + Button("Check photo service") { Task { await addStore.refreshMediaSupport() } } + .accessibilityIdentifier("radroots.settings.retry.blossom") + } + Section("Runtime") { + LabeledContent("Crate", value: snapshot.crateName) + LabeledContent("Version", value: snapshot.crateVersion) + LabeledContent("State", value: snapshot.isClosed ? "Closed" : "Running") + } + } + .navigationTitle("Settings") + .accessibilityIdentifier("radroots.support.settings.view") + } + + private var abbreviatedPublicKey: String { + let key = snapshot.identity.publicKeyHex + guard key.count > 16 else { return key } + return "\(key.prefix(8))…\(key.suffix(8))" + } +} diff --git a/Radroots/Views/RadrootsTodayView.swift b/Radroots/Views/RadrootsTodayView.swift @@ -3,6 +3,9 @@ import SwiftUI struct RadrootsTodayView: View { let snapshot: RadrootsRuntimeSnapshot @ObservedObject var store: RadrootsTodayStore + @ObservedObject var searchStore: RadrootsSearchStore + @ObservedObject var meStore: RadrootsMeStore + @ObservedObject var addStore: RadrootsAddStore let revise: (RadrootsTodayCard) -> Void @State private var showsAccount = false @State private var showsContextPicker = false @@ -47,11 +50,23 @@ struct RadrootsTodayView: View { .sheet(isPresented: $showsContextPicker) { RadrootsContextPicker(store: store) } - .sheet(isPresented: $showsAccount) { - RadrootsAccountSheet(snapshot: snapshot) + .sheet(isPresented: $showsAccount, onDismiss: { meStore.stop() }) { + RadrootsMeSheet( + runtimeSnapshot: snapshot, + context: store.selectedContext, + store: meStore, + todayStore: store, + addStore: addStore, + revise: revise + ) } - .sheet(isPresented: $showsSearch) { - RadrootsSearchSheet() + .sheet(isPresented: $showsSearch, onDismiss: { searchStore.stop() }) { + RadrootsSearchSheet( + snapshot: snapshot, + context: store.selectedContext, + store: searchStore, + revise: revise + ) } .task { await store.start() } .accessibilityIdentifier("radroots.today.root") @@ -193,7 +208,7 @@ private struct RadrootsContextPicker: View { } } -private struct RadrootsTodayCardView: View { +struct RadrootsTodayCardView: View { let card: RadrootsTodayCard var body: some View { @@ -277,7 +292,7 @@ private struct RadrootsTodayCardView: View { } } -private struct RadrootsTrustedMediaView: View { +struct RadrootsTrustedMediaView: View { let media: RadrootsMediaReference var body: some View { @@ -320,7 +335,7 @@ private struct RadrootsTrustedMediaView: View { } } -private struct RadrootsTodayDetailView: View { +struct RadrootsTodayDetailView: View { let card: RadrootsTodayCard let canRevise: Bool let revise: (RadrootsTodayCard) -> Void diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -1,197 +0,0 @@ -import RadrootsKit -import SwiftUI - -struct RelaysView: View { - @EnvironmentObject private var app: AppState - @State private var preparedExport: RadrootsPreparedExportDocument? - @State private var activeExport: RadrootsPreparedExportDocument? - @State private var importRequest: RadrootsDocumentImportRequest? - @State private var fileAccess: RadrootsAppleFileAccess? - @State private var importedRelays: [String] = [] - @State private var documentMessage: String? - @State private var documentError: String? - - private var configuredRelays: [String] { - app.configuredRelayURLs - } - - var body: some View { - List { - Section("Relays") { - RelayMetricRow( - label: "Read operations", - systemImage: "arrow.down.circle", - value: app.relaySourceAvailable ? "Available" : "Unavailable", - accessibilityID: "field_ios.relays.source_available" - ) - RelayMetricRow( - label: "Write operations", - systemImage: "arrow.up.circle", - value: app.relaySinkAvailable ? "Available" : "Unavailable", - accessibilityID: "field_ios.relays.sink_available" - ) - if let last = app.relayLastError { - Text(last) - .foregroundStyle(.red) - .font(.footnote) - .accessibilityIdentifier("field_ios.relays.last_error") - } - } - - Section("Configured Relays") { - LabeledContent("Source", value: app.relaySettingsSourceLabel) - .accessibilityIdentifier("field_ios.relays.settings_source") - if configuredRelays.isEmpty { - Text("No relays configured") - .foregroundStyle(.secondary) - } else { - ForEach(configuredRelays, id: \.self) { url in - Text(url) - .font(.callout.monospaced()) - .accessibilityIdentifier("field_ios.relays.configured_url") - } - } - } - - Section("Document Interchange") { - Button { - prepareRelayExport() - } label: { - Label("Export Relay Config", systemImage: "square.and.arrow.up") - } - .accessibilityIdentifier("field_ios.relays.export") - - Button { - prepareRelayImport() - } label: { - Label("Import Relay Config", systemImage: "square.and.arrow.down") - } - .accessibilityIdentifier("field_ios.relays.import") - - if let documentMessage { - Text(documentMessage) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.relays.document_status") - } - if let documentError { - Text(documentError) - .foregroundStyle(.red) - .font(.footnote) - .accessibilityIdentifier("field_ios.relays.document_error") - } - } - - if !importedRelays.isEmpty { - Section("Imported Relays") { - ForEach(importedRelays, id: \.self) { url in - Text(url) - .font(.callout.monospaced()) - .accessibilityIdentifier("field_ios.relays.imported_url") - } - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Relays") - .task { - fileAccess = try? app.documentFileAccess() - } - .radrootsDocumentExporter(preparedExport: $preparedExport) { result in - handleRelayExportCompletion(result) - } - .background { - if let fileAccess { - Color.clear.radrootsDocumentImporter( - request: $importRequest, - fileAccess: fileAccess - ) { result in - handleRelayImportCompletion(result) - } - } - } - .accessibilityIdentifier("field_ios.relays") - } - - private func prepareRelayExport() { - documentMessage = nil - documentError = nil - do { - let export = try app.prepareRelayConfigDocumentExport() - activeExport = export - preparedExport = export - } catch { - documentError = error.fieldRuntimeMessage - } - } - - private func prepareRelayImport() { - documentMessage = nil - documentError = nil - do { - if fileAccess == nil { - fileAccess = try app.documentFileAccess() - } - importRequest = try RadrootsDocumentImportRequest( - allowedContentKinds: [.json], - allowsMultipleSelection: false, - destinationScope: .temporary - ) - } catch { - documentError = error.fieldRuntimeMessage - } - } - - private func handleRelayExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) { - if let activeExport { - app.releasePreparedDocumentExport(activeExport) - } - activeExport = nil - switch result { - case .success(let exportResult): - documentMessage = "Exported \(exportResult.exportedFilename)" - documentError = nil - case .failure(let error): - documentError = error.fieldRuntimeMessage - } - } - - private func handleRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) { - Task { - await applyRelayImportCompletion(result) - } - } - - @MainActor - private func applyRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) async { - do { - let importResult = try result.get() - guard let document = importResult.documents.first else { - throw FieldDocumentInterchangeError.invalidRelayConfigDocument - } - importedRelays = try await app.applyImportedRelayConfig(from: document) - documentMessage = "Imported and applied \(importedRelays.count) relay config entries" - documentError = nil - } catch { - documentError = error.fieldRuntimeMessage - } - } -} - -private struct RelayMetricRow: View { - let label: String - let systemImage: String - let value: String - let accessibilityID: String - - var body: some View { - HStack { - Label(label, systemImage: systemImage) - Spacer() - Text(value) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(label) - .accessibilityValue(value) - .accessibilityIdentifier(accessibilityID) - } -} diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -1,234 +0,0 @@ -import RadrootsKit -import SwiftUI - -struct SettingsView: View { - @EnvironmentObject private var app: AppState - @State private var showResetConfirmation = false - @State private var resetError: String? - - var body: some View { - List { - Section("Identity") { - Text(app.identityDisplayName) - .font(.headline) - if let npub = app.npub { - CopyRow(title: "npub", value: npub) - if app.canOpenNostrProfile { - Button { - Task { - await app.openCurrentNostrProfile() - } - } label: { - Label("Open Nostr Profile", systemImage: "person.crop.circle.badge.arrow.forward") - } - .accessibilityIdentifier("field_ios.settings.open_nostr_profile") - } else { - Text("No Nostr client is available.") - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.settings.nostr_profile_unavailable") - } - if let status = app.externalActionStatus { - Text(status) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.external_actions.status") - } - } else { - Text("No local Nostr identity is selected.") - .foregroundStyle(.secondary) - } - IdentityStateRow( - title: "Saved identity", - value: app.storedIdentityAvailable ? "Available" : "Missing", - identifier: "field_ios.settings.saved_identity" - ) - IdentityStateRow( - title: "Runtime identity", - value: app.runtimeIdentityReady ? "Unlocked" : "Locked", - identifier: "field_ios.settings.runtime_identity" - ) - if let userPresenceStatus = app.userPresenceStatus { - Text(userPresenceStatus) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.user_presence.status") - } - - NavigationLink { - ProfileView() - } label: { - Label("Profile", systemImage: "person.crop.circle") - } - } - - Section("Network") { - NavigationLink { - RelaysView() - } label: { - Label("Relays", systemImage: "dot.radiowaves.left.and.right") - } - } - - if diagnosticsAvailable { - Section("Operator") { - NavigationLink { - RuntimeDiagnosticsView() - } label: { - Label("Diagnostics", systemImage: "stethoscope") - } - .accessibilityIdentifier("field_ios.settings.diagnostics") - } - } - - Section { - Button { - app.signOut() - } label: { - Label("Lock Identity", systemImage: "lock.fill") - } - .accessibilityIdentifier("field_ios.settings.sign_out") - - Button(role: .destructive) { - showResetConfirmation = true - } label: { - Label("Delete Identity", systemImage: "trash") - } - .accessibilityIdentifier("field_ios.settings.reset_identity") - } footer: { - if let resetError { - Text(resetError) - .foregroundStyle(.red) - .accessibilityIdentifier("field_ios.settings.reset_error") - } - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Settings") - .task { - await app.refreshNostrProfileExternalActionCapability() - } - .confirmationDialog( - "Delete saved Nostr identity?", - isPresented: $showResetConfirmation, - titleVisibility: .visible - ) { - Button("Delete Identity", role: .destructive) { - resetIdentity() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes the identity saved on this iPhone. Lock keeps it available.") - } - .accessibilityIdentifier("field_ios.settings") - } - - private var diagnosticsAvailable: Bool { - BuildConfig.string(.runtimeMode) != "production" - } - - private func resetIdentity() { - resetError = nil - Task { - do { - try await app.resetLocalIdentity() - } catch { - resetError = error.fieldRuntimeMessage - } - } - } -} - -private struct IdentityStateRow: View { - let title: String - let value: String - let identifier: String - - var body: some View { - LabeledContent(title, value: value) - .accessibilityElement(children: .ignore) - .accessibilityLabel(title) - .accessibilityValue(value) - .accessibilityIdentifier(identifier) - } -} - -private struct RuntimeDiagnosticsView: View { - @EnvironmentObject private var app: AppState - @State private var preparedExport: RadrootsPreparedExportDocument? - @State private var activeExport: RadrootsPreparedExportDocument? - @State private var exportMessage: String? - @State private var exportError: String? - - var body: some View { - List { - Section("Export") { - Button { - prepareExport() - } label: { - Label("Export Diagnostics", systemImage: "square.and.arrow.up") - } - .accessibilityIdentifier("field_ios.diagnostics.export") - - if let exportMessage { - Text(exportMessage) - .foregroundStyle(.secondary) - .accessibilityIdentifier("field_ios.diagnostics.export_status") - } - if let exportError { - Text(exportError) - .foregroundStyle(.red) - .font(.footnote) - .accessibilityIdentifier("field_ios.diagnostics.export_error") - } - } - - Section("Relay") { - LabeledContent("Read operations", value: app.relaySourceAvailable ? "Available" : "Unavailable") - LabeledContent("Write operations", value: app.relaySinkAvailable ? "Available" : "Unavailable") - if let relayLastError = app.relayLastError { - Text(relayLastError) - .foregroundStyle(.red) - .font(.footnote) - } - } - - Section("Runtime Metadata") { - Text(app.infoJSONString.isEmpty ? "No runtime metadata available." : app.infoJSONString) - .font(.footnote.monospaced()) - .textSelection(.enabled) - } - } - .listStyle(.insetGrouped) - .inlineNavigationTitle("Diagnostics") - .radrootsDocumentExporter(preparedExport: $preparedExport) { result in - handleExportCompletion(result) - } - .accessibilityIdentifier("field_ios.diagnostics") - } - - private func prepareExport() { - exportMessage = nil - exportError = nil - do { - let export = try app.prepareDiagnosticsDocumentExport() - activeExport = export - preparedExport = export - } catch { - exportError = error.fieldRuntimeMessage - } - } - - private func handleExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) { - if let activeExport { - app.releasePreparedDocumentExport(activeExport) - } - activeExport = nil - switch result { - case .success(let exportResult): - exportMessage = "Exported \(exportResult.exportedFilename)" - exportError = nil - case .failure(let error): - exportError = error.fieldRuntimeMessage - } - } -} diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -1,208 +0,0 @@ -import SwiftUI - -struct SetupView: View { - @EnvironmentObject private var app: AppState - - var onSuccess: (() -> Void)? = nil - - @State private var secretKey = "" - @State private var isWorking = false - @State private var errorMessage: String? - @State private var showImport = false - @FocusState private var secretFocused: Bool - - var body: some View { - ScrollView { - VStack(spacing: 20) { - Spacer(minLength: 40) - - Image(systemName: app.hasKey ? "lock.open.fill" : "key.radiowaves.forward.fill") - .font(.system(size: 64, weight: .semibold)) - .foregroundStyle(.green) - .frame(width: 112, height: 112) - - VStack(spacing: 8) { - Text(app.hasKey ? "Identity saved on this iPhone" : "Create a Nostr identity") - .font(.title.weight(.semibold)) - .multilineTextAlignment(.center) - - if let npub = app.npub { - Text(npub) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .textSelection(.enabled) - } else { - Text("Radroots uses a local Nostr identity to publish and read field events.") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - } - - SetupErrorText(errorMessage) - UserPresenceStatusText(app.userPresenceStatus) - - if isWorking { - ProgressView() - .controlSize(.large) - } - - if app.hasKey { - Button { - continueWithIdentity() - } label: { - Label("Unlock Identity", systemImage: "lock.open.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(isWorking) - .accessibilityIdentifier("field_ios.setup.continue") - } else { - Button { - createIdentity() - } label: { - Label("Create Identity", systemImage: "plus.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(isWorking) - .accessibilityIdentifier("field_ios.setup.create_identity") - - Button { - withAnimation(.easeInOut(duration: 0.2)) { - showImport.toggle() - secretFocused = showImport - } - } label: { - Label("Import Secret Key", systemImage: "square.and.arrow.down") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(isWorking) - .accessibilityIdentifier("field_ios.setup.import_identity") - - if showImport { - VStack(spacing: 12) { - SecureField("nsec or hex secret key", text: $secretKey) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($secretFocused) - .padding(14) - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .accessibilityIdentifier("field_ios.setup.secret_key") - - Button { - importIdentity() - } label: { - Label("Import Identity", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled( - isWorking || - secretKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ) - .accessibilityIdentifier("field_ios.setup.use_secret_key") - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - Spacer(minLength: 24) - } - .padding() - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) - } - .background(Color(.systemGroupedBackground)) - .toolbar(.hidden, for: .navigationBar) - .accessibilityIdentifier("field_ios.setup") - } - - private func continueWithIdentity() { - errorMessage = nil - isWorking = true - Task { - do { - try await app.continueWithLocalIdentity() - onSuccess?() - } catch { - errorMessage = error.fieldRuntimeMessage - } - isWorking = false - } - } - - private func createIdentity() { - errorMessage = nil - isWorking = true - Task { - do { - try await app.createLocalIdentity() - onSuccess?() - } catch { - errorMessage = error.fieldRuntimeMessage - } - isWorking = false - } - } - - private func importIdentity() { - errorMessage = nil - isWorking = true - let submittedSecret = secretKey - secretKey = "" - Task { - do { - try await app.importNostrSecret(submittedSecret) - onSuccess?() - } catch { - errorMessage = error.fieldRuntimeMessage - } - isWorking = false - } - } -} - -private struct SetupErrorText: View { - let message: String? - - init(_ message: String?) { - self.message = message - } - - var body: some View { - if let message { - Text(message) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal) - .accessibilityIdentifier("field_ios.setup.error") - } - } -} - -private struct UserPresenceStatusText: View { - let message: String? - - init(_ message: String?) { - self.message = message - } - - var body: some View { - if let message { - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - .accessibilityIdentifier("field_ios.user_presence.status") - } - } -} diff --git a/RadrootsTests/RadrootsSupportingStoreTests.swift b/RadrootsTests/RadrootsSupportingStoreTests.swift @@ -0,0 +1,272 @@ +@testable import Radroots +import XCTest + +final class RadrootsSupportingStoreTests: XCTestCase { + @MainActor + func testSearchUsesCurrentContextDeduplicatesAndClearsEmptyQueries() async throws { + let backend = SupportingBackend() + let client = try await Self.startedClient(backend) + let store = RadrootsSearchStore(runtimeClient: client, now: { 1_800_000_000 }) + store.configure(context: Self.context(id: "farm")) + store.updateQuery(" carrots ") + + await store.search() + + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.results.map(\.id), ["card", "profile"]) + let request = await backend.lastSearchRequest() + XCTAssertEqual(request?.contextID, "farm") + XCTAssertEqual(request?.query, "carrots") + store.updateQuery(" ") + XCTAssertEqual(store.state, .idle) + XCTAssertTrue(store.results.isEmpty) + _ = try await client.stop() + } + + @MainActor + func testSearchContextChangeFencesLateResults() async throws { + let backend = SupportingBackend(searchDelayNanoseconds: 40_000_000) + let client = try await Self.startedClient(backend) + let store = RadrootsSearchStore(runtimeClient: client, now: { 1_800_000_000 }) + store.configure(context: Self.context(id: "first")) + store.updateQuery("carrots") + + let search = Task { await store.search() } + try await Task.sleep(nanoseconds: 2_000_000) + store.configure(context: Self.context(id: "second")) + await search.value + + XCTAssertTrue(store.results.isEmpty) + XCTAssertEqual(store.state, .idle) + _ = try await client.stop() + } + + @MainActor + func testMePreservesAdoptedProfileFieldsAndCurrentCards() async throws { + let backend = SupportingBackend() + let client = try await Self.startedClient(backend) + let store = RadrootsMeStore(runtimeClient: client, now: { 1_800_000_000 }) + store.configure(context: Self.context(id: "farm")) + + await store.start() + + let snapshot = try XCTUnwrap(store.snapshot) + XCTAssertEqual(snapshot.profile?.displayName, "Moss Farm") + XCTAssertEqual(snapshot.profile?.about, "Local roots") + XCTAssertEqual(snapshot.profile?.nip05, "moss@example.com") + XCTAssertEqual(snapshot.profile?.website, "https://moss.example") + XCTAssertEqual(snapshot.profile?.lightningAddress, "moss@example.com") + XCTAssertEqual(snapshot.cards.map(\.id), ["card"]) + store.stop() + _ = try await client.stop() + } + + func testVisualIdentityIsStableAndKeyBound() { + let first = RadrootsStableVisualIdentity(publicKeyHex: String(repeating: "a", count: 64)) + let repeated = RadrootsStableVisualIdentity(publicKeyHex: String(repeating: "a", count: 64)) + let second = RadrootsStableVisualIdentity(publicKeyHex: String(repeating: "b", count: 64)) + + XCTAssertEqual(first, repeated) + XCTAssertNotEqual(first.digestHex, second.digestHex) + XCTAssertTrue((0 ..< 12).contains(first.paletteIndex)) + } + + private static func startedClient(_ backend: SupportingBackend) async throws -> RadrootsRuntimeClient { + let client = RadrootsRuntimeClient { _ in + await RadrootsRuntimeBackendStart(backend: backend, snapshot: backend.snapshotValue()) + } + _ = try await client.start(configuration: configuration()) + return client + } + + private static func configuration() -> RadrootsRuntimeLaunchConfiguration { + RadrootsRuntimeLaunchConfiguration( + applicationSupportDirectory: "/tmp/radroots-supporting-tests", + publicKeyHex: String(repeating: "a", count: 64), + sourceGenerationHex: String(repeating: "c", count: 64), + sourceGenerationCreatedAtUnixMilliseconds: 1, + protectedData: .available, + networkProfile: .simulator, + writableRelays: ["ws://127.0.0.1:7447"], + blossomOrigins: ["http://127.0.0.1:3000"], + app: RadrootsRuntimeAppMetadata( + bundleIdentifier: "org.radroots.supporting-tests", + version: "0.1.0-alpha", + buildNumber: "1", + buildSHA: nil + ), + signerGeneration: "supporting-tests", + signer: SupportingSigner() + ) + } + + private static func context(id: String) -> RadrootsLocalNetwork { + RadrootsLocalNetwork( + schemaVersion: 1, + id: id, + label: "Farm", + relayURLs: ["ws://127.0.0.1:7447"], + locality: "Metchosin", + followedAuthors: [], + generation: 1 + ) + } +} + +private struct SupportingSigner: RadrootsRuntimeSigner { + func availability() -> RadrootsRuntimeSignerAvailability { + .ready + } + + func sign(_: RadrootsRuntimeSigningRequest) -> RadrootsRuntimeSigningOutcome { + .failed + } +} + +private actor SupportingBackend: RadrootsRuntimeBackend { + struct SearchRequest: Sendable { + let contextID: String + let query: String + } + + private let searchDelayNanoseconds: UInt64 + private var request: SearchRequest? + private var closed = false + + init(searchDelayNanoseconds: UInt64 = 0) { + self.searchDelayNanoseconds = searchDelayNanoseconds + } + + func snapshotValue() -> RadrootsRuntimeSnapshot { + RadrootsRuntimeSnapshot( + identity: RadrootsRuntimeIdentity( + publicKeyHex: String(repeating: "a", count: 64), + hostSignerConfigured: true + ), + relay: nil, + crateName: "radroots_mobile_ffi", + crateVersion: "0.1.0-alpha", + isClosed: closed + ) + } + + func snapshot() -> RadrootsRuntimeSnapshot { + snapshotValue() + } + + func todayPage(request _: RadrootsTodayPageRequest) throws -> RadrootsTodayPage { + throw unsupported() + } + + func refreshToday( + context _: RadrootsLocalNetwork, + nowUnixSeconds _: UInt64, + update _: RadrootsTodayProjectionUpdate + ) throws -> RadrootsTodayRefreshReceipt { + throw unsupported() + } + + func search( + context: RadrootsLocalNetwork, + query: String, + limit _: UInt16, + asOfUnixSeconds _: UInt64 + ) async throws -> [RadrootsSearchResult] { + request = SearchRequest(contextID: context.id, query: query) + if searchDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: searchDelayNanoseconds) + } + let card = Self.card() + let profile = Self.profile() + return [ + RadrootsSearchResult(type: .card, id: "card", card: card, profile: nil), + RadrootsSearchResult(type: .card, id: "card", card: card, profile: nil), + RadrootsSearchResult(type: .profile, id: "profile", card: nil, profile: profile), + ] + } + + func me( + context _: RadrootsLocalNetwork, + asOfUnixSeconds _: UInt64 + ) -> RadrootsMeSnapshot { + RadrootsMeSnapshot( + publicKey: String(repeating: "a", count: 64), + profile: Self.profile(), + cards: [Self.card()] + ) + } + + func subscribe( + bufferCapacity _: Int, + receive _: @escaping @Sendable (RadrootsRuntimeChange) async -> Void + ) -> any RadrootsRuntimeSubscriptionToken { + SupportingSubscriptionToken() + } + + func shutdown() -> RadrootsRuntimeShutdownReceipt { + let wasClosed = closed + closed = true + return RadrootsRuntimeShutdownReceipt(state: "closed", alreadyClosed: wasClosed) + } + + func lastSearchRequest() -> SearchRequest? { + request + } + + private func unsupported() -> RadrootsRuntimeFailure { + .local( + operation: "test.supporting", + code: "test.unsupported", + safeMessage: "Unsupported test operation." + ) + } + + private static func profile() -> RadrootsProfileSummary { + RadrootsProfileSummary( + authorPublicKey: String(repeating: "a", count: 64), + name: "moss", + displayName: "Moss Farm", + about: "Local roots", + picture: nil, + banner: nil, + nip05: "moss@example.com", + website: "https://moss.example", + lightningAddress: "moss@example.com" + ) + } + + private static func card() -> RadrootsTodayCard { + RadrootsTodayCard( + id: "card", + type: .foodAvailability, + sourceEventID: String(repeating: "e", count: 64), + sourceAddress: nil, + authorPublicKey: String(repeating: "a", count: 64), + contractID: "radroots.food_availability.v1", + title: "Carrots", + content: "Freshly picked", + authoredAtUnixSeconds: 1_800_000_000, + effectiveAtUnixSeconds: 1_800_000_000, + eventStartUnixSeconds: nil, + eventEndUnixSeconds: nil, + location: "Metchosin", + priceAmount: "3", + priceCurrency: "CAD", + priceUnit: "lb", + quantity: "12", + contextRank: 1, + inclusionReason: "local", + media: [], + lifecycle: .active, + rankDigest: nil, + authorProfile: profile(), + thread: [], + localOperationID: nil, + localOperationState: nil + ) + } +} + +private actor SupportingSubscriptionToken: RadrootsRuntimeSubscriptionToken { + func cancel() {} +} diff --git a/project.yml b/project.yml @@ -48,9 +48,11 @@ targets: - path: Radroots/State/RadrootsAddStore.swift - path: Radroots/State/RadrootsIdentityStore.swift - path: Radroots/State/RadrootsSessionStore.swift + - path: Radroots/State/RadrootsSupportingStores.swift - path: Radroots/State/RadrootsTodayStore.swift - path: Radroots/Views/RadrootsTodayView.swift - path: Radroots/Views/RadrootsAddView.swift + - path: Radroots/Views/RadrootsSupportingViews.swift - path: Radroots/Views/RuntimeStatusView.swift - path: Radroots/Resources settings: @@ -81,6 +83,7 @@ targets: - path: RadrootsTests/RadrootsStateMigrationTests.swift - path: RadrootsTests/RadrootsTodayStoreTests.swift - path: RadrootsTests/RadrootsAddStoreTests.swift + - path: RadrootsTests/RadrootsSupportingStoreTests.swift settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64