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 0e8ebef8087086f93d33e5ac6638c8e86f2033b3
parent eb6c1a71d1dceba2a9672d3b6612f3047821fc40
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 04:39:57 +0000

feat(ios): complete local social product surface

Diffstat:
MRadroots/App/AppEntry.swift | 67+++++++++++++++++++++++++++++++------------------------------------
MRadroots/App/RadrootsAppModel.swift | 119++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MRadroots/App/RadrootsRootShell.swift | 350++++++++++++++++++++++++++++++++++++++-----------------------------------------
MRadroots/Runtime/RadrootsRuntimeModels.swift | 193++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MRadroots/State/RadrootsAddStore.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MRadroots/Views/RadrootsAddView.swift | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MRadroots/Views/RadrootsSupportingViews.swift | 29++++++++++++++++++-----------
MRadroots/Views/RadrootsTodayView.swift | 32++++++++++++++++++++++++++++----
MRadrootsTests/RadrootsAddStoreTests.swift | 227++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
9 files changed, 884 insertions(+), 354 deletions(-)

diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -1,50 +1,45 @@ import SwiftUI public enum RadrootsAppRelease: Sendable { - public static let version = "0.1.0-alpha" + public static let version = "0.1.0-alpha" } public struct RadrootsAppView: View { - public init() {} + public init() {} - public var body: some View { - RadrootsProvider { - AppEntry() - } + public var body: some View { + RadrootsProvider { + AppEntry() } + } } struct AppEntry: View { - @EnvironmentObject private var appModel: RadrootsAppModel + @EnvironmentObject private var appModel: RadrootsAppModel - var body: some View { - Group { - if case .running(let snapshot) = appModel.phase { - RadrootsRootShell( - snapshot: snapshot, - todayStore: appModel.todayStore, - addStore: appModel.addStore, - searchStore: appModel.searchStore, - meStore: appModel.meStore, - settingsStore: appModel.settingsStore, - mediaStore: appModel.mediaStore - ) - } else { - RuntimeStatusView( - phase: appModel.phase, - retry: { Task { await appModel.retry() } }, - createIdentity: { Task { await appModel.createIdentity() } }, - importIdentity: { material in - Task { await appModel.importIdentity(material) } - }, - unlockIdentity: { Task { await appModel.unlockIdentity() } }, - recoverIdentity: { Task { await appModel.recoverIdentity() } }, - applyConfigurationReconfiguration: { - Task { await appModel.applyConfigurationReconfiguration() } - } - ) - } - } - .accessibilityIdentifier("radroots.app_entry") + var body: some View { + Group { + if case .running(let snapshot) = appModel.phase { + RadrootsRootShell( + snapshot: snapshot, + stores: appModel.productStores + ) + } else { + RuntimeStatusView( + phase: appModel.phase, + retry: { Task { await appModel.retry() } }, + createIdentity: { Task { await appModel.createIdentity() } }, + importIdentity: { material in + Task { await appModel.importIdentity(material) } + }, + unlockIdentity: { Task { await appModel.unlockIdentity() } }, + recoverIdentity: { Task { await appModel.recoverIdentity() } }, + applyConfigurationReconfiguration: { + Task { await appModel.applyConfigurationReconfiguration() } + } + ) + } } + .accessibilityIdentifier("radroots.app_entry") + } } diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -2,16 +2,61 @@ import Foundation import RadrootsKit @MainActor +struct RadrootsProductStores { + let today: RadrootsTodayStore + let add: RadrootsAddStore + let search: RadrootsSearchStore + let me: RadrootsMeStore + let settings: RadrootsSettingsStore + let media: RadrootsMediaStore + + init( + runtimeClient: RadrootsRuntimeClient, + addMedia: (any RadrootsAddMediaHandling)? = nil + ) { + today = RadrootsTodayStore(runtimeClient: runtimeClient) + add = RadrootsAddStore(runtimeClient: runtimeClient, media: addMedia) + search = RadrootsSearchStore(runtimeClient: runtimeClient) + me = RadrootsMeStore(runtimeClient: runtimeClient) + settings = RadrootsSettingsStore(runtimeClient: runtimeClient) + media = RadrootsMediaStore(runtimeClient: runtimeClient) + } + + func configure(snapshot: RadrootsRuntimeSnapshot) { + today.configure(snapshot: snapshot) + add.configure(snapshot: snapshot) + } + + func resume() async { + await today.start() + await add.start() + } + + func suspend() { + today.stop() + add.suspend() + search.stop() + me.stop() + settings.stop() + media.reset() + } + + func stop() { + today.stop() + add.stop() + search.stop() + me.stop() + settings.stop() + media.reset() + } +} + +@MainActor final class RadrootsAppModel: ObservableObject { typealias Phase = RadrootsSessionPhase @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(set) var settingsStore: RadrootsSettingsStore? - private(set) var mediaStore: RadrootsMediaStore? + let productStores: RadrootsProductStores? let diagnosticsStore: RadrootsDiagnosticsStore private let sessionStore: RadrootsSessionStore? @@ -33,22 +78,17 @@ final class RadrootsAppModel: ObservableObject { } let lifecycleCoordinator = requestedLifecycleCoordinator - ?? productionServices?.coordinator - ?? RadrootsLifecycleCoordinator.disabled() + ?? productionServices?.coordinator + ?? RadrootsLifecycleCoordinator.disabled() let backgroundTransfer = requestedLifecycleCoordinator == nil - ? productionServices?.backgroundTransfer : nil + ? productionServices?.backgroundTransfer : nil self.lifecycleCoordinator = lifecycleCoordinator diagnosticsStore = RadrootsDiagnosticsStore(coordinator: lifecycleCoordinator) #if DEBUG if ProcessInfo.processInfo.environment["RADROOTS_IOS_UI_TEST_SHELL"] == "1" { self.sessionStore = nil - todayStore = nil - addStore = nil - searchStore = nil - meStore = nil - settingsStore = nil - mediaStore = nil + productStores = nil bootstrapFailure = nil isShellUITest = true phase = .running(.shellUITest) @@ -58,12 +98,7 @@ final class RadrootsAppModel: ObservableObject { isShellUITest = false if let sessionStore { 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) } - settingsStore = runtimeClient.map { RadrootsSettingsStore(runtimeClient: $0) } - mediaStore = runtimeClient.map { RadrootsMediaStore(runtimeClient: $0) } + productStores = runtimeClient.map { RadrootsProductStores(runtimeClient: $0) } bootstrapFailure = nil } else { do { @@ -75,24 +110,14 @@ final class RadrootsAppModel: ObservableObject { transfer: backgroundTransfer ) self.sessionStore = sessionStore - todayStore = RadrootsTodayStore(runtimeClient: runtimeClient) - addStore = RadrootsAddStore( + productStores = RadrootsProductStores( runtimeClient: runtimeClient, - media: mediaCoordinator + addMedia: mediaCoordinator ) - searchStore = RadrootsSearchStore(runtimeClient: runtimeClient) - meStore = RadrootsMeStore(runtimeClient: runtimeClient) - settingsStore = RadrootsSettingsStore(runtimeClient: runtimeClient) - mediaStore = RadrootsMediaStore(runtimeClient: runtimeClient) bootstrapFailure = nil } catch let error as LocalizedError { self.sessionStore = nil - todayStore = nil - addStore = nil - searchStore = nil - meStore = nil - settingsStore = nil - mediaStore = nil + productStores = nil bootstrapFailure = .local( operation: "app.bootstrap", code: "ios.app.configuration_invalid", @@ -100,12 +125,7 @@ final class RadrootsAppModel: ObservableObject { ) } catch { self.sessionStore = nil - todayStore = nil - addStore = nil - searchStore = nil - meStore = nil - settingsStore = nil - mediaStore = nil + productStores = nil bootstrapFailure = .local( operation: "app.bootstrap", code: "ios.app.configuration_invalid", @@ -181,8 +201,7 @@ final class RadrootsAppModel: ObservableObject { } resumePending = false if case .running = phase { - await todayStore?.start() - await addStore?.start() + await productStores?.resume() await lifecycleCoordinator.record("ios.lifecycle.active") } else { await start() @@ -192,12 +211,7 @@ final class RadrootsAppModel: ObservableObject { func suspend() async { generation &+= 1 resumePending = false - todayStore?.stop() - addStore?.suspend() - searchStore?.stop() - meStore?.stop() - settingsStore?.stop() - mediaStore?.reset() + productStores?.suspend() await sessionStore?.suspend() await lifecycleCoordinator.record("ios.lifecycle.background") } @@ -258,8 +272,7 @@ final class RadrootsAppModel: ObservableObject { return } if case .running(let snapshot) = result { - todayStore?.configure(snapshot: snapshot) - addStore?.configure(snapshot: snapshot) + productStores?.configure(snapshot: snapshot) } phase = result await lifecycleCoordinator.record( @@ -287,11 +300,7 @@ final class RadrootsAppModel: ObservableObject { } private func stopPresentationWork() { - todayStore?.stop() - addStore?.stop() - searchStore?.stop() - meStore?.stop() - mediaStore?.reset() + productStores?.stop() } private static func isFailure(_ phase: Phase) -> Bool { diff --git a/Radroots/App/RadrootsRootShell.swift b/Radroots/App/RadrootsRootShell.swift @@ -1,213 +1,203 @@ import SwiftUI enum RadrootsRootTab: String, CaseIterable, Sendable { - case today - case add - - static func resolve(_ rawValue: String?) -> Self { - guard let rawValue, let tab = Self(rawValue: rawValue) else { return .today } - return tab - } - - static func resolve(url: URL) -> Self? { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme?.lowercased() == "radroots", - components.user == nil, - components.password == nil, - components.port == nil, - components.query == nil, - components.fragment == nil, - components.percentEncodedPath.isEmpty, - let encodedHost = components.percentEncodedHost, - !encodedHost.contains("%") - else { - return nil - } - return Self(rawValue: encodedHost.lowercased()) + case today + case add + + static func resolve(_ rawValue: String?) -> Self { + guard let rawValue, let tab = Self(rawValue: rawValue) else { return .today } + return tab + } + + static func resolve(url: URL) -> Self? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "radroots", + components.user == nil, + components.password == nil, + components.port == nil, + components.query == nil, + components.fragment == nil, + components.percentEncodedPath.isEmpty, + let encodedHost = components.percentEncodedHost, + !encodedHost.contains("%") + else { + return nil } + return Self(rawValue: encodedHost.lowercased()) + } } struct RadrootsRootShell: View { - let snapshot: RadrootsRuntimeSnapshot - let todayStore: RadrootsTodayStore? - let addStore: RadrootsAddStore? - let searchStore: RadrootsSearchStore? - let meStore: RadrootsMeStore? - let settingsStore: RadrootsSettingsStore? - let mediaStore: RadrootsMediaStore? - @SceneStorage("radroots.selected_root_tab") private var storedSelection = RadrootsRootTab.today.rawValue - - init( - snapshot: RadrootsRuntimeSnapshot, - todayStore: RadrootsTodayStore? = nil, - addStore: RadrootsAddStore? = nil, - searchStore: RadrootsSearchStore? = nil, - meStore: RadrootsMeStore? = nil, - settingsStore: RadrootsSettingsStore? = nil, - mediaStore: RadrootsMediaStore? = nil - ) { - self.snapshot = snapshot - self.todayStore = todayStore - self.addStore = addStore - self.searchStore = searchStore - self.meStore = meStore - self.settingsStore = settingsStore - self.mediaStore = mediaStore - } - - var body: some View { - TabView(selection: selection) { - NavigationStack { - if let todayStore, let addStore, let searchStore, let meStore, let settingsStore, - let mediaStore - { - RadrootsTodayView( - snapshot: snapshot, - store: todayStore, - searchStore: searchStore, - meStore: meStore, - addStore: addStore, - settingsStore: settingsStore, - mediaStore: mediaStore, - revise: { card in - Task { - await addStore.retractAndRevise(card) - storedSelection = RadrootsRootTab.add.rawValue - } - } - ) - } else { - RadrootsTodayLanding(snapshot: snapshot) - } - } - .tabItem { Label("Today", systemImage: "sun.max.fill") } - .tag(RadrootsRootTab.today) - .accessibilityIdentifier("radroots.tab.today") - - NavigationStack { - if let addStore { - RadrootsAddView(store: addStore) - } else { - RadrootsAddUnavailable() - } + let snapshot: RadrootsRuntimeSnapshot + let stores: RadrootsProductStores? + @SceneStorage("radroots.selected_root_tab") private var storedSelection = RadrootsRootTab.today + .rawValue + + init( + snapshot: RadrootsRuntimeSnapshot, + stores: RadrootsProductStores? = nil + ) { + self.snapshot = snapshot + self.stores = stores + } + + var body: some View { + TabView(selection: selection) { + NavigationStack { + if let stores { + RadrootsTodayView( + snapshot: snapshot, + store: stores.today, + searchStore: stores.search, + meStore: stores.me, + addStore: stores.add, + settingsStore: stores.settings, + mediaStore: stores.media, + revise: { card in + Task { + await stores.add.retractAndRevise(card) + storedSelection = RadrootsRootTab.add.rawValue + } + }, + retract: { card in + Task { + await stores.add.retract(card) + storedSelection = RadrootsRootTab.add.rawValue + } } - .tabItem { Label("Add", systemImage: "plus.circle.fill") } - .tag(RadrootsRootTab.add) - .accessibilityIdentifier("radroots.tab.add") + ) + } else { + RadrootsTodayLanding(snapshot: snapshot) } - .accessibilityIdentifier("radroots.root.tabs") - .onOpenURL { url in - guard let tab = RadrootsRootTab.resolve(url: url) else { return } - storedSelection = tab.rawValue + } + .tabItem { Label("Today", systemImage: "sun.max.fill") } + .tag(RadrootsRootTab.today) + .accessibilityIdentifier("radroots.tab.today") + + NavigationStack { + if let stores { + RadrootsAddView(store: stores.add) + } else { + RadrootsAddUnavailable() } + } + .tabItem { Label("Add", systemImage: "plus.circle.fill") } + .tag(RadrootsRootTab.add) + .accessibilityIdentifier("radroots.tab.add") } - - private var selection: Binding<RadrootsRootTab> { - Binding( - get: { RadrootsRootTab.resolve(storedSelection) }, - set: { storedSelection = $0.rawValue } - ) + .accessibilityIdentifier("radroots.root.tabs") + .onOpenURL { url in + guard let tab = RadrootsRootTab.resolve(url: url) else { return } + storedSelection = tab.rawValue } + } + + private var selection: Binding<RadrootsRootTab> { + Binding( + get: { RadrootsRootTab.resolve(storedSelection) }, + set: { storedSelection = $0.rawValue } + ) + } } private struct RadrootsTodayLanding: View { - let snapshot: RadrootsRuntimeSnapshot - @State private var showsAccount = false - @State private var showsSearch = false - - var body: some View { - ContentUnavailableView { - Label("Today", systemImage: "leaf.fill") - } description: { - Text("Your local food network is ready for its first refresh.") - } - .navigationTitle("Today") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showsSearch = true - } label: { - Label("Search", systemImage: "magnifyingglass") - } - .accessibilityIdentifier("radroots.support.search") - } - ToolbarItem(placement: .topBarTrailing) { - Button { - showsAccount = true - } label: { - Label("Account", systemImage: "person.crop.circle") - } - .accessibilityIdentifier("radroots.support.account") - } - } - .sheet(isPresented: $showsAccount) { - RadrootsAccountUnavailableSheet(snapshot: snapshot) + let snapshot: RadrootsRuntimeSnapshot + @State private var showsAccount = false + @State private var showsSearch = false + + var body: some View { + ContentUnavailableView { + Label("Today", systemImage: "leaf.fill") + } description: { + Text("Your local food network is ready for its first refresh.") + } + .navigationTitle("Today") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showsSearch = true + } label: { + Label("Search", systemImage: "magnifyingglass") } - .sheet(isPresented: $showsSearch) { - RadrootsSearchUnavailableSheet() + .accessibilityIdentifier("radroots.support.search") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showsAccount = true + } label: { + Label("Account", systemImage: "person.crop.circle") } - .accessibilityIdentifier("radroots.today.root") + .accessibilityIdentifier("radroots.support.account") + } + } + .sheet(isPresented: $showsAccount) { + RadrootsAccountUnavailableSheet(snapshot: snapshot) + } + .sheet(isPresented: $showsSearch) { + RadrootsSearchUnavailableSheet() } + .accessibilityIdentifier("radroots.today.root") + } } struct RadrootsSearchUnavailableSheet: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ContentUnavailableView.search - .navigationTitle("Search") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ContentUnavailableView.search + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } } - .accessibilityIdentifier("radroots.support.search.sheet") } + .accessibilityIdentifier("radroots.support.search.sheet") + } } struct RadrootsAccountUnavailableSheet: View { - let snapshot: RadrootsRuntimeSnapshot - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List { - Section("Me") { - LabeledContent("Public key", value: abbreviatedPublicKey) - } - Section("Connection") { - LabeledContent("Runtime", value: snapshot.crateVersion) - LabeledContent("Relay", value: snapshot.relay?.state ?? "Not configured") - } - } - .navigationTitle("Account") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } + let snapshot: RadrootsRuntimeSnapshot + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Me") { + LabeledContent("Public key", value: abbreviatedPublicKey) } - .presentationDetents([.medium, .large]) - .accessibilityIdentifier("radroots.support.account.sheet") - } - - private var abbreviatedPublicKey: String { - let key = snapshot.identity.publicKeyHex - guard key.count > 16 else { return key } - return "\(key.prefix(8))…\(key.suffix(8))" + Section("Connection") { + LabeledContent("Runtime", value: snapshot.crateVersion) + LabeledContent("Relay", value: snapshot.relay?.state ?? "Not configured") + } + } + .navigationTitle("Account") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } } + .presentationDetents([.medium, .large]) + .accessibilityIdentifier("radroots.support.account.sheet") + } + + private var abbreviatedPublicKey: String { + let key = snapshot.identity.publicKeyHex + guard key.count > 16 else { return key } + return "\(key.prefix(8))…\(key.suffix(8))" + } } private struct RadrootsAddUnavailable: View { - var body: some View { - ContentUnavailableView { - Label("Add", systemImage: "plus.circle.fill") - } description: { - Text("Choose what to share with your local food network.") - } - .navigationTitle("Add") - .accessibilityIdentifier("radroots.add.root") + var body: some View { + ContentUnavailableView { + Label("Add", systemImage: "plus.circle.fill") + } description: { + Text("Choose what to share with your local food network.") } + .navigationTitle("Add") + .accessibilityIdentifier("radroots.add.root") + } } diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -130,7 +130,7 @@ extension RadrootsRuntimeLaunchConfiguration: Equatable { && lhs.publicKeyHex == rhs.publicKeyHex && lhs.sourceGenerationHex == rhs.sourceGenerationHex && lhs.sourceGenerationCreatedAtUnixMilliseconds - == rhs.sourceGenerationCreatedAtUnixMilliseconds + == rhs.sourceGenerationCreatedAtUnixMilliseconds && lhs.protectedData == rhs.protectedData && lhs.networkProfile == rhs.networkProfile && lhs.writableRelays == rhs.writableRelays @@ -468,12 +468,12 @@ extension RadrootsRuntimeClientError: LocalizedError { case .superseded: "A newer runtime lifecycle request replaced this request." case .startup(let failure), - .subscription(let failure), - .status(let failure), - .today(let failure), - .add(let failure), - .support(let failure), - .shutdown(let failure): + .subscription(let failure), + .status(let failure), + .today(let failure), + .add(let failure), + .support(let failure), + .shutdown(let failure): failure.safeMessage } } @@ -517,6 +517,16 @@ enum RadrootsTodayCardType: String, CaseIterable, Sendable, Equatable, Hashable case .foodAvailability: "Food availability" } } + + var addCommandType: RadrootsAddCommandType { + switch self { + case .update: .createUpdate + case .photoUpdate: .createPhotoUpdate + case .ask: .createAsk + case .event: .createEvent + case .foodAvailability: .createFoodAvailability + } + } } enum RadrootsMediaVerificationState: String, Sendable, Equatable, Hashable { @@ -543,9 +553,9 @@ struct RadrootsMediaReference: Sendable, Equatable, Hashable, Identifiable { var verifiedArtifactID: String? { guard verification == .verified, - let sha256, - sha256.count == 64, - sha256.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) + let sha256, + sha256.count == 64, + sha256.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) else { return nil } return sha256 } @@ -568,12 +578,12 @@ struct RadrootsVerifiedMediaArtifact: Sendable, Equatable { height: UInt32 ) { guard artifactID.count == 64, - artifactID.allSatisfy({ $0.isHexDigit && !$0.isUppercase }), - !bytes.isEmpty, - UInt64(bytes.count) == byteSize, - ["image/gif", "image/jpeg", "image/png", "image/webp"].contains(mediaType), - width > 0, - height > 0 + artifactID.allSatisfy({ $0.isHexDigit && !$0.isUppercase }), + !bytes.isEmpty, + UInt64(bytes.count) == byteSize, + ["image/gif", "image/jpeg", "image/png", "image/webp"].contains(mediaType), + width > 0, + height > 0 else { return nil } self.artifactID = artifactID self.bytes = bytes @@ -700,6 +710,30 @@ struct RadrootsTodayCard: Sendable, Equatable, Hashable, Identifiable { } return parts.joined(separator: ", ") } + + var retractionTargetKind: UInt32? { + switch type { + case .update, .photoUpdate, .ask: + guard sourceAddress == nil else { return nil } + return 1 + case .event: + guard let kind = sourceAddressKind, kind == 31_922 || kind == 31_923 else { return nil } + return kind + case .foodAvailability: + guard sourceAddressKind == 30_402 else { return nil } + return 30_402 + } + } + + private var sourceAddressKind: UInt32? { + guard let sourceAddress, + let rawKind = sourceAddress.split(separator: ":", maxSplits: 1).first, + let kind = UInt32(rawKind) + else { + return nil + } + return kind + } } struct RadrootsTodayPageRequest: Sendable, Equatable { @@ -825,6 +859,133 @@ struct RadrootsAddSchema: Sendable, Equatable, Hashable, Identifiable { } } +enum RadrootsProductSurfaceContractError: LocalizedError, Sendable, Equatable { + case invalidAddSchemaInventory + + var errorDescription: String? { + "The Add product contract does not match this app." + } +} + +enum RadrootsProductSurfaceContract { + private struct MediaProfile: Equatable { + let required: Bool + let maximum: UInt16 + } + + static let supportingSurfaceIDs = ["context_picker", "search", "me", "settings"] + + static var snapshot: String { + let cards = RadrootsTodayCardType.allCases.map(\.rawValue).joined(separator: ",") + let commands = RadrootsAddCommandType.allCases.map(\.rawValue).joined(separator: ",") + let supporting = supportingSurfaceIDs.joined(separator: ",") + return "today=\(cards)|add=\(commands)|support=\(supporting)" + } + + static func validate(schemas: [RadrootsAddSchema]) throws -> [RadrootsAddSchema] { + let expectedCommands = RadrootsAddCommandType.allCases + guard schemas.map(\.commandType) == expectedCommands, + schemas.allSatisfy({ $0.schemaVersion == 1 && $0.label == $0.commandType.label }), + schemas.allSatisfy({ schema in + schema.fields.allSatisfy({ $0.schemaVersion == 1 }) + && Set(schema.fields.map(\.id)).count == schema.fields.count + }), + schemas.allSatisfy({ $0.fields.map(\.id) == expectedFieldIDs(for: $0.commandType) }), + schemas.allSatisfy({ $0.fields.map(\.kind) == expectedFieldKinds(for: $0.commandType) }), + schemas.allSatisfy({ + requiredFieldIDs(in: $0) == expectedRequiredFieldIDs(for: $0.commandType) + }), + schemas.allSatisfy({ mediaProfile(in: $0) == expectedMediaProfile(for: $0.commandType) }), + schemas.first(where: { $0.commandType == .createFoodAvailability })? + .fields.first(where: { $0.id == "unit" })?.choices + == ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"] + else { + throw RadrootsProductSurfaceContractError.invalidAddSchemaInventory + } + return schemas + } + + private static func expectedFieldIDs(for command: RadrootsAddCommandType) -> [String] { + switch command { + case .createUpdate: + ["content"] + case .createPhotoUpdate, .createAsk: + ["content", "media"] + case .createEvent: + ["identifier", "title", "content", "event_start", "event_end", "location", "media"] + case .createFoodAvailability: + [ + "identifier", "title", "summary", "content", "location", "price_amount", "currency", + "unit", "quantity", "media", + ] + } + } + + private static func expectedRequiredFieldIDs(for command: RadrootsAddCommandType) -> Set<String> { + switch command { + case .createUpdate: + ["content"] + case .createPhotoUpdate: + ["content", "media"] + case .createAsk: + ["content"] + case .createEvent: + ["identifier", "title", "event_start"] + case .createFoodAvailability: + ["identifier", "title", "summary", "content", "location", "price_amount", "currency", "unit"] + } + } + + private static func expectedFieldKinds( + for command: RadrootsAddCommandType + ) -> [RadrootsAddFieldKind] { + switch command { + case .createUpdate: + [.multilineText] + case .createPhotoUpdate, .createAsk: + [.multilineText, .media] + case .createEvent: + [.text, .text, .multilineText, .dateTime, .dateTime, .location, .media] + case .createFoodAvailability: + [ + .text, .text, .text, .multilineText, .location, .decimal, .choice, .choice, .decimal, + .media, + ] + } + } + + private static func requiredFieldIDs(in schema: RadrootsAddSchema) -> Set<String> { + Set(schema.fields.lazy.filter(\.required).map(\.id)) + } + + private static func expectedMediaProfile( + for command: RadrootsAddCommandType + ) -> MediaProfile? { + switch command { + case .createUpdate: + nil + case .createPhotoUpdate: + MediaProfile(required: true, maximum: 20) + case .createAsk: + MediaProfile(required: false, maximum: 20) + case .createEvent: + MediaProfile(required: false, maximum: 1) + case .createFoodAvailability: + MediaProfile(required: false, maximum: 20) + } + } + + private static func mediaProfile( + in schema: RadrootsAddSchema + ) -> MediaProfile? { + let fields = schema.fields.filter { $0.kind == .media } + guard fields.count == 1, let field = fields.first, let maximum = field.maxItems else { + return nil + } + return MediaProfile(required: field.required, maximum: maximum) + } +} + enum RadrootsEventTiming: String, Sendable, Equatable, Hashable, CaseIterable, Identifiable { case allDay case timed diff --git a/Radroots/State/RadrootsAddStore.swift b/Radroots/State/RadrootsAddStore.swift @@ -26,6 +26,9 @@ final class RadrootsAddStore: ObservableObject { private let runtimeClient: RadrootsRuntimeClient private let media: (any RadrootsAddMediaHandling)? private let observationDelay: @Sendable (UInt32) async throws -> Void + private let identifier: @Sendable () -> String + private let nowUnixSeconds: @Sendable () -> UInt64 + private let nowUnixMilliseconds: @Sendable () -> UInt64 private var generation: UInt64 = 0 private var operationGeneration: UInt64? private var operationTask: Task<Void, Never>? @@ -34,18 +37,35 @@ final class RadrootsAddStore: ObservableObject { private var observationGeneration: UInt64 = 0 private var revisionTarget: RadrootsRevisionTarget? private var revisionOperationID: String? + private var activePublicKey: String? init( runtimeClient: RadrootsRuntimeClient, media: (any RadrootsAddMediaHandling)? = nil, initialType: RadrootsAddCommandType = .createUpdate, + identifier: @escaping @Sendable () -> String = { + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + }, + nowUnixSeconds: @escaping @Sendable () -> UInt64 = { + UInt64(Date().timeIntervalSince1970) + }, + nowUnixMilliseconds: @escaping @Sendable () -> UInt64 = { + UInt64(Date().timeIntervalSince1970 * 1_000) + }, observationDelay: @escaping @Sendable (UInt32) async throws -> Void = RadrootsRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient self.media = media + self.identifier = identifier + self.nowUnixSeconds = nowUnixSeconds + self.nowUnixMilliseconds = nowUnixMilliseconds self.observationDelay = observationDelay - form = .empty(initialType) + form = Self.newForm( + type: initialType, + identifier: identifier, + nowUnixSeconds: nowUnixSeconds + ) } deinit { @@ -57,16 +77,20 @@ final class RadrootsAddStore: ObservableObject { } var isFormEditable: Bool { - guard activeDraft?.isRevision != true else { return false } + guard activeDraft?.isRevision != true, activeDraft?.kind != .retraction else { return false } return activeDraft?.state.isEditable ?? true } + var isProductReady: Bool { + state == .ready && selectedSchema != nil + } + var canSave: Bool { - isFormEditable && !isWorking + isProductReady && isFormEditable && !isWorking } var canSubmit: Bool { - !isWorking + isProductReady && !isWorking && (activeDraft?.isRevision == true || activeDraft?.state.canAdvance == true || isFormEditable) } @@ -88,6 +112,7 @@ final class RadrootsAddStore: ObservableObject { } func configure(snapshot: RadrootsRuntimeSnapshot) { + activePublicKey = snapshot.identity.publicKeyHex blossomConfiguration = snapshot.blossomConfiguration blossomEvidence = snapshot.blossomEvidence } @@ -114,7 +139,7 @@ final class RadrootsAddStore: ObservableObject { try await media?.reconcileBackgroundUploads(drafts: loadedDrafts) try Task.checkCancellation() guard requestedGeneration == generation else { return } - schemas = loadedSchemas + schemas = try RadrootsProductSurfaceContract.validate(schemas: loadedSchemas) drafts = Self.sorted(loadedDrafts) mediaSupport = loadedSupport state = .ready @@ -150,7 +175,11 @@ final class RadrootsAddStore: ObservableObject { activeDraft = nil revisionTarget = nil revisionOperationID = nil - form = .empty(type) + form = Self.newForm( + type: type, + identifier: identifier, + nowUnixSeconds: nowUnixSeconds + ) message = nil } @@ -165,7 +194,11 @@ final class RadrootsAddStore: ObservableObject { activeDraft = nil revisionTarget = nil revisionOperationID = nil - form = .empty(type ?? form.commandType) + form = Self.newForm( + type: type ?? form.commandType, + identifier: identifier, + nowUnixSeconds: nowUnixSeconds + ) message = nil } @@ -373,6 +406,13 @@ final class RadrootsAddStore: ObservableObject { func retractAndRevise(_ card: RadrootsTodayCard) async { await perform { + guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else { + throw RadrootsRuntimeFailure.local( + operation: "add.revise", + code: "ios.add.revision_not_authorized", + safeMessage: "Only your own post can be revised." + ) + } guard let operationID = card.localOperationID else { throw RadrootsRuntimeFailure.local( operation: "add.revise", @@ -402,6 +442,60 @@ final class RadrootsAddStore: ObservableObject { } } + func retract(_ card: RadrootsTodayCard) async { + await perform { + guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else { + throw RadrootsRuntimeFailure.local( + operation: "add.retract", + code: "ios.add.retraction_not_authorized", + safeMessage: "Only your own post can be retracted." + ) + } + guard let targetKind = card.retractionTargetKind else { + throw RadrootsRuntimeFailure.local( + operation: "add.retract", + code: "ios.add.retraction_target_invalid", + safeMessage: "This post cannot be retracted safely." + ) + } + let draftID = self.identifier() + guard Self.isValidIdentifier(draftID) else { + throw RadrootsRuntimeFailure.local( + operation: "add.retract", + code: "ios.add.identifier_invalid", + safeMessage: "The local operation identifier is invalid." + ) + } + var status = try await self.runtimeClient.saveRetractionDraft( + id: draftID, + input: RadrootsRetractionDraftInput( + commandType: card.type.addCommandType, + targetCardID: card.id, + targetEventID: card.sourceEventID, + targetKind: targetKind, + targetAddress: card.sourceAddress, + reason: "Removed by author." + ), + authoredAtUnixSeconds: self.nowUnixSeconds(), + persistedAtUnixMilliseconds: self.nowUnixMilliseconds() + ) + self.accept(status) + status = try await self.runtimeClient.queueAddIntent( + id: status.id, + expectedRevision: status.revision + ) + self.accept(status) + if status.state.canAdvance { + status = try await self.runtimeClient.advanceDraft( + id: status.id, + expectedRevision: status.revision + ) + self.accept(status) + } + self.message = status.honestSummary + } + } + private func saveCurrentForm() async throws -> RadrootsDraftStatus { guard isFormEditable else { throw RadrootsRuntimeFailure.local( @@ -663,6 +757,50 @@ final class RadrootsAddStore: ObservableObject { } } + private static func newForm( + type: RadrootsAddCommandType, + identifier: @Sendable () -> String, + nowUnixSeconds: @Sendable () -> UInt64 + ) -> RadrootsAddForm { + var form = RadrootsAddForm.empty(type) + if type == .createEvent || type == .createFoodAvailability { + let value = identifier() + if isValidIdentifier(value) { + form.identifier = value + } + } + if type == .createEvent { + let now = nowUnixSeconds() + let start = now.addingReportingOverflow(3_600).overflow ? now : now + 3_600 + let end = start.addingReportingOverflow(3_600).overflow ? start : start + 3_600 + form.eventStartUnixSeconds = start + form.eventEndUnixSeconds = end + form.eventStartDate = eventDateFormatter.string( + from: Date(timeIntervalSince1970: TimeInterval(start)) + ) + form.eventEndDate = eventDateFormatter.string( + from: Date(timeIntervalSince1970: TimeInterval(end)) + ) + } + return form + } + + private static func isValidIdentifier(_ value: String) -> Bool { + value.utf8.count == 32 + && value.utf8.allSatisfy { byte in + (byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66) + } + } + + private static let eventDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + private static func message(for error: Error) -> String { if let failure = failure(for: error) { return failure.safeMessage diff --git a/Radroots/Views/RadrootsAddView.swift b/Radroots/Views/RadrootsAddView.swift @@ -7,6 +7,22 @@ struct RadrootsAddView: View { var body: some View { Form { + switch store.state { + case .idle, .loading: + Section { + ProgressView("Loading Add…") + .accessibilityIdentifier("radroots.add.loading") + } + case .failed(let message): + Section { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityIdentifier("radroots.add.error") + } + case .ready: + EmptyView() + } + if let message = store.message { Section { Label(message, systemImage: statusSymbol) @@ -15,21 +31,31 @@ struct RadrootsAddView: View { } } - Section("What are you sharing?") { - Picker("Type", selection: typeBinding) { - ForEach(availableTypes) { type in - Text(type.label).tag(type) - } + if store.activeDraft?.kind == .retraction { + Section("Retraction") { + Text("This saved operation removes one of your published posts.") + .foregroundStyle(.secondary) } - .pickerStyle(.navigationLink) - .disabled(!store.isFormEditable) - .accessibilityIdentifier("radroots.add.type") - } + } else { + Group { + Section("What are you sharing?") { + Picker("Type", selection: typeBinding) { + ForEach(availableTypes) { type in + Text(type.label).tag(type) + } + } + .pickerStyle(.navigationLink) + .disabled(!store.isFormEditable) + .accessibilityIdentifier("radroots.add.type") + } - composerFields + composerFields - if store.acceptsMedia { - mediaSection + if store.acceptsMedia { + mediaSection + } + } + .disabled(!store.isProductReady) } Section { @@ -142,8 +168,8 @@ struct RadrootsAddView: View { Section("Food availability") { TextField("Food", text: optional(\.title)) .accessibilityIdentifier("radroots.add.title") - TextField("Short summary (optional)", text: optional(\.summary)) - contentEditor(prompt: "Details (optional)") + TextField("Short summary", text: optional(\.summary)) + contentEditor(prompt: "Details") TextField("Location", text: optional(\.location)) .accessibilityIdentifier("radroots.add.location") } @@ -153,7 +179,13 @@ struct RadrootsAddView: View { .accessibilityIdentifier("radroots.add.price") TextField("Currency", text: optional(\.currency)) .textInputAutocapitalization(.characters) - TextField("Unit", text: optional(\.unit)) + Picker("Unit", selection: optional(\.unit)) { + Text("Choose a unit").tag("") + ForEach(unitChoices, id: \.self) { unit in + Text(unit).tag(unit) + } + } + .accessibilityIdentifier("radroots.add.unit") TextField("Quantity available (optional)", text: optional(\.quantity)) .keyboardType(.decimalPad) } @@ -230,6 +262,10 @@ struct RadrootsAddView: View { return types.isEmpty ? RadrootsAddCommandType.allCases : types } + private var unitChoices: [String] { + store.selectedSchema?.fields.first(where: { $0.id == "unit" })?.choices ?? [] + } + private var submitAccessibilityValue: String { if store.isWorking { return "Working" @@ -312,6 +348,9 @@ struct RadrootsAddView: View { } private var submitLabel: String { + if store.activeDraft?.kind == .retraction { + return "Retry retraction" + } if let state = store.activeDraft?.state, state.canAdvance { return "Retry delivery" } diff --git a/Radroots/Views/RadrootsSupportingViews.swift b/Radroots/Views/RadrootsSupportingViews.swift @@ -7,6 +7,7 @@ struct RadrootsSearchSheet: View { @ObservedObject var store: RadrootsSearchStore @ObservedObject var mediaStore: RadrootsMediaStore let revise: (RadrootsTodayCard) -> Void + let retract: (RadrootsTodayCard) -> Void @Environment(\.dismiss) private var dismiss var body: some View { @@ -59,7 +60,9 @@ struct RadrootsSearchSheet: View { mediaStore: mediaStore, canRevise: card.authorPublicKey == snapshot.identity.publicKeyHex && card.localOperationID != nil, - revise: revise + canRetract: card.authorPublicKey == snapshot.identity.publicKeyHex, + revise: revise, + retract: retract ) } label: { RadrootsTodayCardView(card: card, context: context, mediaStore: mediaStore) @@ -97,6 +100,7 @@ struct RadrootsMeSheet: View { @ObservedObject var settingsStore: RadrootsSettingsStore @ObservedObject var mediaStore: RadrootsMediaStore let revise: (RadrootsTodayCard) -> Void + let retract: (RadrootsTodayCard) -> Void @Environment(\.dismiss) private var dismiss @State private var showsDrafts = false @@ -186,7 +190,10 @@ struct RadrootsMeSheet: View { context: context, mediaStore: mediaStore, canRevise: card.localOperationID != nil, - revise: revise + canRetract: card.authorPublicKey + == (store.snapshot?.publicKey ?? runtimeSnapshot.identity.publicKeyHex), + revise: revise, + retract: retract ) } label: { RadrootsTodayCardView(card: card, context: context, mediaStore: mediaStore) @@ -307,11 +314,11 @@ struct RadrootsProfileView: View { 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 + let url = URL(string: value), + url.scheme?.lowercased() == "https", + url.host != nil, + url.user == nil, + url.password == nil else { return nil } @@ -401,7 +408,7 @@ struct RadrootsSettingsView: View { TextField("Display name", text: $settingsStore.profileDisplayName) .accessibilityIdentifier("radroots.settings.profile.display_name") TextField("About", text: $settingsStore.profileAbout, axis: .vertical) - .lineLimit(3 ... 8) + .lineLimit(3...8) .accessibilityIdentifier("radroots.settings.profile.about") TextField("NIP-05 identifier", text: $settingsStore.profileNip05) .textInputAutocapitalization(.never) @@ -481,7 +488,7 @@ struct RadrootsSettingsView: View { text: $settingsStore.blossomFallbackOrigins, axis: .vertical ) - .lineLimit(2 ... 5) + .lineLimit(2...5) .textInputAutocapitalization(.never) .keyboardType(.URL) Toggle("Cellular downloads", isOn: $settingsStore.allowCellularDownloads) @@ -492,13 +499,13 @@ struct RadrootsSettingsView: View { Stepper( "Cache: \(settingsStore.mediaCacheMegabytes) MB", value: $settingsStore.mediaCacheMegabytes, - in: 16 ... 2048, + in: 16...2048, step: 16 ) Stepper( "Artifacts: \(settingsStore.mediaCacheArtifacts)", value: $settingsStore.mediaCacheArtifacts, - in: 1 ... 10000, + in: 1...10000, step: 100 ) Button("Save network and storage settings") { diff --git a/Radroots/Views/RadrootsTodayView.swift b/Radroots/Views/RadrootsTodayView.swift @@ -10,6 +10,7 @@ struct RadrootsTodayView: View { @ObservedObject var settingsStore: RadrootsSettingsStore @ObservedObject var mediaStore: RadrootsMediaStore let revise: (RadrootsTodayCard) -> Void + let retract: (RadrootsTodayCard) -> Void @State private var showsAccount = false @State private var showsContextPicker = false @State private var showsSearch = false @@ -18,7 +19,7 @@ struct RadrootsTodayView: View { Group { switch store.state { case .idle where store.cards.isEmpty, - .loading where store.cards.isEmpty: + .loading where store.cards.isEmpty: ProgressView("Loading Today…") .accessibilityIdentifier("radroots.today.loading") case .empty: @@ -62,7 +63,8 @@ struct RadrootsTodayView: View { addStore: addStore, settingsStore: settingsStore, mediaStore: mediaStore, - revise: revise + revise: revise, + retract: retract ) } .sheet(isPresented: $showsSearch, onDismiss: { searchStore.stop() }) { @@ -71,7 +73,8 @@ struct RadrootsTodayView: View { context: store.selectedContext, store: searchStore, mediaStore: mediaStore, - revise: revise + revise: revise, + retract: retract ) } .task { await store.start() } @@ -119,7 +122,9 @@ struct RadrootsTodayView: View { mediaStore: mediaStore, canRevise: card.authorPublicKey == snapshot.identity.publicKeyHex && card.localOperationID != nil, - revise: revise + canRetract: card.authorPublicKey == snapshot.identity.publicKeyHex, + revise: revise, + retract: retract ) } .accessibilityIdentifier("radroots.today.feed") @@ -397,7 +402,10 @@ struct RadrootsTodayDetailView: View { let context: RadrootsLocalNetwork? @ObservedObject var mediaStore: RadrootsMediaStore let canRevise: Bool + let canRetract: Bool let revise: (RadrootsTodayCard) -> Void + let retract: (RadrootsTodayCard) -> Void + @State private var showsRetractionConfirmation = false var body: some View { List { @@ -429,6 +437,22 @@ struct RadrootsTodayDetailView: View { .accessibilityIdentifier("radroots.today.revise") } } + if canRetract { + ToolbarItem(placement: .topBarTrailing) { + Button("Retract", role: .destructive) { showsRetractionConfirmation = true } + .accessibilityIdentifier("radroots.today.retract") + } + } + } + .confirmationDialog( + "Retract this post?", + isPresented: $showsRetractionConfirmation, + titleVisibility: .visible + ) { + Button("Retract post", role: .destructive) { retract(card) } + Button("Keep post", role: .cancel) {} + } message: { + Text("A signed retraction will be saved to the durable outbox before relay delivery.") } .accessibilityIdentifier("radroots.today.detail.\(card.id)") } diff --git a/RadrootsTests/RadrootsAddStoreTests.swift b/RadrootsTests/RadrootsAddStoreTests.swift @@ -264,6 +264,62 @@ final class RadrootsAddStoreTests: XCTestCase { } @MainActor + func testProductSurfaceSnapshotAndSchemaInventoryAreExact() async throws { + XCTAssertEqual( + RadrootsProductSurfaceContract.snapshot, + "today=update,photoUpdate,ask,event,foodAvailability|add=createUpdate,createPhotoUpdate,createAsk,createEvent,createFoodAvailability|support=context_picker,search,me,settings" + ) + + let validBackend = AddBackend() + let validClient = try await Self.startedClient(validBackend) + let validStore = RadrootsAddStore(runtimeClient: validClient) + await validStore.configure(snapshot: validBackend.snapshot()) + await validStore.start() + XCTAssertEqual(validStore.state, .ready) + XCTAssertEqual(validStore.schemas.map(\.commandType), RadrootsAddCommandType.allCases) + XCTAssertTrue(validStore.isProductReady) + + let unicodeIdentifierStore = RadrootsAddStore( + runtimeClient: validClient, + initialType: .createFoodAvailability, + identifier: { String(repeating: "٠", count: 16) } + ) + XCTAssertNil(unicodeIdentifierStore.form.identifier) + _ = try await validClient.stop() + + let exact = AddBackend.schemas() + var missingField = exact + let food = exact[4] + missingField[4] = RadrootsAddSchema( + schemaVersion: food.schemaVersion, + commandType: food.commandType, + label: food.label, + fields: Array(food.fields.dropLast()) + ) + let invalidInventories = [ + Array(exact.reversed()), + Array(exact.dropLast()), + exact + [exact[0]], + missingField, + ] + for inventory in invalidInventories { + let invalidBackend = AddBackend(schemas: inventory) + let invalidClient = try await Self.startedClient(invalidBackend) + let invalidStore = RadrootsAddStore(runtimeClient: invalidClient) + await invalidStore.configure(snapshot: invalidBackend.snapshot()) + await invalidStore.start() + XCTAssertEqual( + invalidStore.state, + .failed("The Add product contract does not match this app.") + ) + XCTAssertFalse(invalidStore.isProductReady) + XCTAssertFalse(invalidStore.canSave) + XCTAssertFalse(invalidStore.canSubmit) + _ = try await invalidClient.stop() + } + } + + @MainActor func testOfflineSubmitPersistsQueuedSnapshotForRetryAndReopen() async throws { let backend = AddBackend(advanceOffline: true) let client = try await Self.startedClient(backend) @@ -311,6 +367,37 @@ final class RadrootsAddStoreTests: XCTestCase { } @MainActor + func testRetractionUsesTypedTargetAndCompletesThroughTheDurableOutbox() async throws { + let backend = AddBackend() + let client = try await Self.startedClient(backend) + let store = RadrootsAddStore( + runtimeClient: client, + identifier: { String(repeating: "f", count: 32) }, + nowUnixSeconds: { 1_800_000_200 }, + nowUnixMilliseconds: { 1_800_000_200_000 } + ) + await store.configure(snapshot: backend.snapshot()) + await store.start() + + await store.retract(Self.card()) + + XCTAssertEqual(store.activeDraft?.id, String(repeating: "f", count: 32)) + XCTAssertEqual(store.activeDraft?.kind, .retraction) + XCTAssertEqual(store.activeDraft?.state, .complete) + XCTAssertFalse(store.isFormEditable) + XCTAssertFalse(store.canSave) + let recordedRetraction = await backend.lastRetraction() + let retraction = try XCTUnwrap(recordedRetraction) + XCTAssertEqual(retraction.commandType, .createFoodAvailability) + XCTAssertEqual(retraction.targetKind, 30_402) + XCTAssertEqual(retraction.targetCardID, String(repeating: "c", count: 64)) + XCTAssertEqual(retraction.targetEventID, String(repeating: "e", count: 64)) + XCTAssertEqual(retraction.targetAddress, Self.card().sourceAddress) + XCTAssertEqual(retraction.reason, "Removed by author.") + _ = try await client.stop() + } + + @MainActor func testStopFencesSaveQueueAndAdvanceAndPermitsCleanRestart() async throws { for phase in AddDelayPhase.allCases { let backend = AddBackend(delayedPhase: phase) @@ -554,14 +641,19 @@ final class RadrootsAddStoreTests: XCTestCase { case .createAsk: store.updateForm(\.content, "Who has seed potatoes?") case .createEvent: - store.updateForm(\.identifier, Optional("market-day")) + XCTAssertEqual(store.form.identifier?.count, 32) + XCTAssertNotNil(store.form.eventStartUnixSeconds) + XCTAssertNotNil(store.form.eventEndUnixSeconds) + XCTAssertNotNil(store.form.eventStartDate) + XCTAssertNotNil(store.form.eventEndDate) store.updateForm(\.title, Optional("Market day")) store.updateForm(\.eventTiming, Optional(RadrootsEventTiming.timed)) - store.updateForm(\.eventStartUnixSeconds, Optional(UInt64(1_800_003_600))) store.updateForm(\.location, Optional("Town square")) case .createFoodAvailability: - store.updateForm(\.identifier, Optional("carrots")) + XCTAssertEqual(store.form.identifier?.count, 32) store.updateForm(\.title, Optional("Carrots")) + store.updateForm(\.summary, Optional("Fresh carrots")) + store.updateForm(\.content, "Freshly picked") store.updateForm(\.location, Optional("Town square")) store.updateForm(\.priceAmount, Optional("3")) store.updateForm(\.currency, Optional("CAD")) @@ -619,8 +711,8 @@ final class RadrootsAddStoreTests: XCTestCase { id: String(repeating: "c", count: 64), type: .foodAvailability, sourceEventID: String(repeating: "e", count: 64), - sourceAddress: "30402:\(String(repeating: "a", count: 64)):carrots", - authorPublicKey: String(repeating: "a", count: 64), + sourceAddress: "30402:\(String(repeating: "ab", count: 32)):carrots", + authorPublicKey: String(repeating: "ab", count: 32), contractID: "radroots.food_availability.v1", title: "Carrots", content: "Freshly picked", @@ -758,11 +850,13 @@ private actor AddBackend: RadrootsRuntimeBackend { private let includeWritableRelay: Bool private let delayedPhase: AddDelayPhase? private let delayAfterBackgroundCompletion: Bool + private let schemaInventory: [RadrootsAddSchema] private var values: [String: RadrootsDraftStatus] = [:] private var uploadedMedia = false private var revisionPlans = 0 private var delayConsumed = false private var backgroundCompletionPersisted = false + private var recordedRetraction: RadrootsRetractionDraftInput? private var closed = false init( @@ -770,13 +864,15 @@ private actor AddBackend: RadrootsRuntimeBackend { saveFailure: RadrootsRuntimeFailure? = nil, includeWritableRelay: Bool = true, delayedPhase: AddDelayPhase? = nil, - delayAfterBackgroundCompletion: Bool = false + delayAfterBackgroundCompletion: Bool = false, + schemas: [RadrootsAddSchema] = AddBackend.schemas() ) { self.advanceOffline = advanceOffline self.saveFailure = saveFailure self.includeWritableRelay = includeWritableRelay self.delayedPhase = delayedPhase self.delayAfterBackgroundCompletion = delayAfterBackgroundCompletion + schemaInventory = schemas } func snapshot() -> RadrootsRuntimeSnapshot { @@ -832,33 +928,99 @@ private actor AddBackend: RadrootsRuntimeBackend { } func addSchemas() -> [RadrootsAddSchema] { - RadrootsAddCommandType.allCases.map { type in - let maximum: UInt16? = - switch type { - case .createUpdate: nil - case .createEvent: 1 - case .createPhotoUpdate, .createAsk, .createFoodAvailability: 20 - } - return RadrootsAddSchema( + schemaInventory + } + + nonisolated static func schemas() -> [RadrootsAddSchema] { + let field = { + ( + id: String, + label: String, + kind: RadrootsAddFieldKind, + required: Bool, + choices: [String], + maxBytes: UInt64?, + maxItems: UInt16? + ) in + RadrootsAddField( schemaVersion: 1, - commandType: type, - label: type.label, - fields: maximum.map { - [ - RadrootsAddField( - schemaVersion: 1, - id: "media", - label: "Photos", - kind: .media, - required: false, - choices: [], - maxBytes: 10 * 1024 * 1024, - maxItems: $0 - ) - ] - } ?? [] + id: id, + label: label, + kind: kind, + required: required, + choices: choices, + maxBytes: maxBytes, + maxItems: maxItems ) } + let text = { + (id: String, label: String, kind: RadrootsAddFieldKind, required: Bool, maximum: UInt64?) in + field(id, label, kind, required, [], maximum, nil) + } + let media = { (required: Bool, maximum: UInt16) in + field("media", "Photos", .media, required, [], 10 * 1024 * 1024, maximum) + } + return [ + RadrootsAddSchema( + schemaVersion: 1, + commandType: .createUpdate, + label: "Update", + fields: [text("content", "Update", .multilineText, true, 65_535)] + ), + RadrootsAddSchema( + schemaVersion: 1, + commandType: .createPhotoUpdate, + label: "Photo update", + fields: [ + text("content", "Update", .multilineText, true, 65_535), + media(true, 20), + ] + ), + RadrootsAddSchema( + schemaVersion: 1, + commandType: .createAsk, + label: "Ask", + fields: [ + text("content", "Question", .multilineText, true, 65_535), + media(false, 20), + ] + ), + RadrootsAddSchema( + schemaVersion: 1, + commandType: .createEvent, + label: "Event", + fields: [ + text("identifier", "Identifier", .text, true, 256), + text("title", "Title", .text, true, 256), + text("content", "Description", .multilineText, false, 65_535), + text("event_start", "Starts", .dateTime, true, nil), + text("event_end", "Ends", .dateTime, false, nil), + text("location", "Location", .location, false, 256), + media(false, 1), + ] + ), + RadrootsAddSchema( + schemaVersion: 1, + commandType: .createFoodAvailability, + label: "Food availability", + fields: [ + text("identifier", "Identifier", .text, true, 256), + text("title", "Food", .text, true, 256), + text("summary", "Summary", .text, true, 256), + text("content", "Details", .multilineText, true, 65_535), + text("location", "Pickup location", .location, true, 256), + text("price_amount", "Price", .decimal, true, 64), + field("currency", "Currency", .choice, true, [], 3, nil), + field( + "unit", "Unit", .choice, true, + ["g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket"], + nil, nil + ), + text("quantity", "Available quantity", .decimal, false, 64), + media(false, 20), + ] + ), + ] } func saveAddIntent( @@ -935,6 +1097,7 @@ private actor AddBackend: RadrootsRuntimeBackend { authoredAtUnixSeconds _: UInt64, persistedAtUnixMilliseconds: UInt64 ) -> RadrootsDraftStatus { + recordedRetraction = input let status = makeStatus( id: id, revision: 1, @@ -949,6 +1112,10 @@ private actor AddBackend: RadrootsRuntimeBackend { return status } + func lastRetraction() -> RadrootsRetractionDraftInput? { + recordedRetraction + } + func saveRevisionIntent( target: RadrootsRevisionTarget, replacement: RadrootsAddRuntimeInput