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