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:
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