commit 6ed849e562b2ed7f6da36fc36c79e4daadb8b78d
parent 33fdd9f3010502e248502fe19a7368006fc1cc7f
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 02:28:17 -0700
ui: add document interchange flows
- add diagnostics and relay config export controls
- add relay config import presentation and validation feedback
- route post sharing through AppleKit presentation
- expose stable identifiers for document interchange tests
Diffstat:
4 files changed, 196 insertions(+), 2 deletions(-)
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -240,6 +240,14 @@ public final class AppState: ObservableObject {
try documentInterchange().publicPostShareRequest(content: content)
}
+ func documentFileAccess() throws -> RadrootsAppleFileAccess {
+ try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier())
+ }
+
+ func releasePreparedDocumentExport(_ preparedExport: RadrootsPreparedExportDocument) {
+ try? documentFileAccess().releasePreparedExport(preparedExport)
+ }
+
private func documentInterchange() throws -> FieldDocumentInterchange {
try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier())
}
diff --git a/Radroots/Views/PostDetailView.swift b/Radroots/Views/PostDetailView.swift
@@ -1,6 +1,8 @@
+import RadrootsKit
import SwiftUI
struct PostDetailView: View {
+ @EnvironmentObject private var app: AppState
let post: NostrPostEventMetadata
@State private var showCopied = false
@@ -30,8 +32,18 @@ struct PostDetailView: View {
.inlineNavigationTitle("Post")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
- ShareLink(item: post.post.content) {
- Image(systemName: "square.and.arrow.up")
+ if let shareRequest = try? app.publicPostShareRequest(content: post.post.content),
+ let shareLink = try? RadrootsSharePresentationLink(request: shareRequest, label: {
+ Image(systemName: "square.and.arrow.up")
+ }) {
+ shareLink
+ .accessibilityIdentifier("field_ios.post.share")
+ } else {
+ Button {} label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ .disabled(true)
+ .accessibilityIdentifier("field_ios.post.share_unavailable")
}
}
}
diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift
@@ -1,7 +1,15 @@
+import RadrootsKit
import SwiftUI
struct RelaysView: View {
@EnvironmentObject private var app: AppState
+ @State private var preparedExport: RadrootsPreparedExportDocument?
+ @State private var activeExport: RadrootsPreparedExportDocument?
+ @State private var importRequest: RadrootsDocumentImportRequest?
+ @State private var fileAccess: RadrootsAppleFileAccess?
+ @State private var importedRelays: [String] = []
+ @State private var documentMessage: String?
+ @State private var documentError: String?
private var configuredRelays: [String] {
(try? RelaySettings.relays()) ?? []
@@ -42,11 +50,122 @@ struct RelaysView: View {
}
}
}
+
+ Section("Document Interchange") {
+ Button {
+ prepareRelayExport()
+ } label: {
+ Label("Export Relay Config", systemImage: "square.and.arrow.up")
+ }
+ .accessibilityIdentifier("field_ios.relays.export")
+
+ Button {
+ prepareRelayImport()
+ } label: {
+ Label("Import Relay Config", systemImage: "square.and.arrow.down")
+ }
+ .accessibilityIdentifier("field_ios.relays.import")
+
+ if let documentMessage {
+ Text(documentMessage)
+ .foregroundStyle(.secondary)
+ .accessibilityIdentifier("field_ios.relays.document_status")
+ }
+ if let documentError {
+ Text(documentError)
+ .foregroundStyle(.red)
+ .font(.footnote)
+ .accessibilityIdentifier("field_ios.relays.document_error")
+ }
+ }
+
+ if !importedRelays.isEmpty {
+ Section("Imported Relays") {
+ ForEach(importedRelays, id: \.self) { url in
+ Text(url)
+ .font(.callout.monospaced())
+ .accessibilityIdentifier("field_ios.relays.imported_url")
+ }
+ }
+ }
}
.listStyle(.insetGrouped)
.inlineNavigationTitle("Relays")
+ .task {
+ fileAccess = try? app.documentFileAccess()
+ }
+ .radrootsDocumentExporter(preparedExport: $preparedExport) { result in
+ handleRelayExportCompletion(result)
+ }
+ .background {
+ if let fileAccess {
+ Color.clear.radrootsDocumentImporter(
+ request: $importRequest,
+ fileAccess: fileAccess
+ ) { result in
+ handleRelayImportCompletion(result)
+ }
+ }
+ }
.accessibilityIdentifier("field_ios.relays")
}
+
+ private func prepareRelayExport() {
+ documentMessage = nil
+ documentError = nil
+ do {
+ let export = try app.prepareRelayConfigDocumentExport()
+ activeExport = export
+ preparedExport = export
+ } catch {
+ documentError = error.localizedDescription
+ }
+ }
+
+ private func prepareRelayImport() {
+ documentMessage = nil
+ documentError = nil
+ do {
+ if fileAccess == nil {
+ fileAccess = try app.documentFileAccess()
+ }
+ importRequest = try RadrootsDocumentImportRequest(
+ allowedContentKinds: [.json],
+ allowsMultipleSelection: false,
+ destinationScope: .temporary
+ )
+ } catch {
+ documentError = error.localizedDescription
+ }
+ }
+
+ private func handleRelayExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) {
+ if let activeExport {
+ app.releasePreparedDocumentExport(activeExport)
+ }
+ activeExport = nil
+ switch result {
+ case .success(let exportResult):
+ documentMessage = "Exported \(exportResult.exportedFilename)"
+ documentError = nil
+ case .failure(let error):
+ documentError = error.localizedDescription
+ }
+ }
+
+ private func handleRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) {
+ do {
+ let importResult = try result.get()
+ guard let document = importResult.documents.first else {
+ throw FieldDocumentInterchangeError.invalidRelayConfigDocument
+ }
+ importedRelays = try app.importedRelayConfig(from: document)
+ documentMessage = "Imported \(importedRelays.count) relay config entries"
+ documentError = nil
+ } catch {
+ documentError = error.localizedDescription
+ }
+ }
}
private struct RelayMetricRow: View {
diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift
@@ -1,3 +1,4 @@
+import RadrootsKit
import SwiftUI
struct SettingsView: View {
@@ -132,9 +133,34 @@ private struct IdentityStateRow: View {
private struct RuntimeDiagnosticsView: View {
@EnvironmentObject private var app: AppState
+ @State private var preparedExport: RadrootsPreparedExportDocument?
+ @State private var activeExport: RadrootsPreparedExportDocument?
+ @State private var exportMessage: String?
+ @State private var exportError: String?
var body: some View {
List {
+ Section("Export") {
+ Button {
+ prepareExport()
+ } label: {
+ Label("Export Diagnostics", systemImage: "square.and.arrow.up")
+ }
+ .accessibilityIdentifier("field_ios.diagnostics.export")
+
+ if let exportMessage {
+ Text(exportMessage)
+ .foregroundStyle(.secondary)
+ .accessibilityIdentifier("field_ios.diagnostics.export_status")
+ }
+ if let exportError {
+ Text(exportError)
+ .foregroundStyle(.red)
+ .font(.footnote)
+ .accessibilityIdentifier("field_ios.diagnostics.export_error")
+ }
+ }
+
Section("Relay") {
LabeledContent("Connected", value: "\(app.relayConnectedCount)")
LabeledContent("Connecting", value: "\(app.relayConnectingCount)")
@@ -153,6 +179,35 @@ private struct RuntimeDiagnosticsView: View {
}
.listStyle(.insetGrouped)
.inlineNavigationTitle("Diagnostics")
+ .radrootsDocumentExporter(preparedExport: $preparedExport) { result in
+ handleExportCompletion(result)
+ }
.accessibilityIdentifier("field_ios.diagnostics")
}
+
+ private func prepareExport() {
+ exportMessage = nil
+ exportError = nil
+ do {
+ let export = try app.prepareDiagnosticsDocumentExport()
+ activeExport = export
+ preparedExport = export
+ } catch {
+ exportError = error.localizedDescription
+ }
+ }
+
+ private func handleExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) {
+ if let activeExport {
+ app.releasePreparedDocumentExport(activeExport)
+ }
+ activeExport = nil
+ switch result {
+ case .success(let exportResult):
+ exportMessage = "Exported \(exportResult.exportedFilename)"
+ exportError = nil
+ case .failure(let error):
+ exportError = error.localizedDescription
+ }
+ }
}