TeraSettingsView.swift (11140B)
1 import SwiftUI 2 3 struct TeraSettingsView: View { 4 let snapshot: TeraRuntimeSnapshot 5 @ObservedObject var todayStore: TeraTodayStore 6 @ObservedObject var addStore: TeraAddStore 7 @ObservedObject var meStore: TeraMeStore 8 @ObservedObject var settingsStore: TeraSettingsStore 9 @EnvironmentObject private var diagnosticsStore: TeraDiagnosticsStore 10 @EnvironmentObject private var appModel: TeraAppModel 11 @Environment(\.dynamicTypeSize) private var dynamicTypeSize 12 @State private var showsKeyRemoval = false 13 14 var body: some View { 15 List { 16 Section("Privacy-safe diagnostics") { 17 if let message = diagnosticsStore.message { 18 Text(message) 19 .foregroundStyle(.secondary) 20 } 21 Button("Prepare diagnostics export") { 22 Task { await diagnosticsStore.prepare(snapshot: snapshot) } 23 } 24 .disabled(diagnosticsStore.isPreparing) 25 .accessibilityIdentifier("radroots.settings.diagnostics") 26 Text("Exports contain bounded lifecycle codes and runtime status only.") 27 .fixedSize(horizontal: false, vertical: true) 28 .accessibilityIdentifier("tera.settings.diagnostics.contents") 29 Text("Posts, keys, credentials, endpoint URLs, and local paths are excluded.") 30 .fixedSize(horizontal: false, vertical: true) 31 .accessibilityIdentifier("tera.settings.diagnostics.exclusions") 32 } 33 TeraSupportSettingsSection() 34 Section("Identity") { 35 LabeledContent( 36 "Local signer", 37 value: snapshot.identity.hostSignerConfigured ? "Ready" : "Needs attention" 38 ) 39 LabeledContent("Public key", value: abbreviatedPublicKey) 40 .accessibilityIdentifier("radroots.settings.identity.public_key") 41 .accessibilityValue(snapshot.identity.publicKeyHex) 42 if let identity = settingsStore.settings?.identity { 43 LabeledContent( 44 "Custody state", 45 value: identity.lockState == .unlocked ? "Unlocked" : "Locked" 46 ) 47 ForEach(identity.identities) { record in 48 HStack { 49 Text(abbreviated(record.publicKeyHex)).font(.caption.monospaced()) 50 Spacer() 51 if record.id == identity.activeIdentityID { 52 Text("Selected").foregroundStyle(.secondary) 53 } 54 } 55 } 56 } 57 Button("Lock identity", role: .destructive) { 58 Task { await appModel.lockIdentity() } 59 } 60 .accessibilityIdentifier("radroots.settings.identity.lock") 61 Button("Remove signing key…", role: .destructive) { showsKeyRemoval = true } 62 .accessibilityIdentifier("tera.settings.identity.remove") 63 } 64 Section("Profile") { 65 TextField("Name", text: $settingsStore.profileName) 66 .textInputAutocapitalization(.never) 67 .accessibilityIdentifier("radroots.settings.profile.name") 68 TextField("Display name", text: $settingsStore.profileDisplayName) 69 .accessibilityIdentifier("radroots.settings.profile.display_name") 70 TextField("About", text: $settingsStore.profileAbout, axis: .vertical) 71 .lineLimit(3 ... 8) 72 .accessibilityIdentifier("radroots.settings.profile.about") 73 TextField("NIP-05 identifier", text: $settingsStore.profileNip05) 74 .textInputAutocapitalization(.never) 75 .keyboardType(.emailAddress) 76 .accessibilityIdentifier("radroots.settings.profile.nip05") 77 Toggle("Automated account", isOn: $settingsStore.profileBot) 78 Button("Save profile update") { Task { await settingsStore.saveProfile() } } 79 .disabled(settingsStore.isWorking) 80 .accessibilityIdentifier("radroots.settings.profile.save") 81 if let status = settingsStore.profileStatus { 82 LabeledContent("Publication", value: status.honestSummary) 83 if status.state.canAdvance { 84 Button("Retry profile publication") { 85 Task { await settingsStore.advanceProfile() } 86 } 87 } 88 if status.state.canCancel { 89 Button("Cancel profile publication", role: .destructive) { 90 Task { await settingsStore.cancelProfile() } 91 } 92 } 93 } 94 } 95 Section("Network environment") { 96 Picker("Environment", selection: $settingsStore.networkEnvironment) { 97 ForEach(TeraSettingsNetworkEnvironment.allCases) { environment in 98 Text(display(environment.rawValue)).tag(environment) 99 } 100 } 101 .accessibilityIdentifier("radroots.settings.network.environment") 102 } 103 Section("Nostr relay preferences") { 104 ForEach($settingsStore.relays) { $relay in 105 VStack(alignment: .leading) { 106 TextField("wss://relay.example", text: $relay.url) 107 .textInputAutocapitalization(.never) 108 .keyboardType(.URL) 109 Picker("Access", selection: $relay.access) { 110 ForEach(TeraRelayAccessPreference.allCases) { access in 111 Text(access.label).tag(access) 112 } 113 } 114 } 115 } 116 .onDelete(perform: settingsStore.removeRelays) 117 Button("Add relay") { settingsStore.addRelay() } 118 .accessibilityIdentifier("radroots.settings.relays.add") 119 } 120 Section("Live relay status") { 121 if snapshot.relay?.relays.isEmpty != false { 122 Text("No relay is configured for this profile.") 123 .foregroundStyle(.secondary) 124 } 125 ForEach(snapshot.relay?.relays ?? [], id: \.url) { relay in 126 VStack(alignment: .leading, spacing: 4) { 127 Text(relay.url).font(.caption.monospaced()) 128 Text("\(relay.access.label) · read \(relay.readState) · write \(relay.writeState)") 129 .font(.caption2) 130 .foregroundStyle(.secondary) 131 } 132 } 133 Button("Retry local network") { Task { await todayStore.reload() } } 134 .accessibilityIdentifier("radroots.settings.retry.network") 135 } 136 Section("Blossom preferences") { 137 Picker("Trust", selection: $settingsStore.blossomAuthority) { 138 ForEach(TeraBlossomAuthorityPreference.allCases) { authority in 139 Text(display(authority.rawValue)).tag(authority) 140 } 141 } 142 TextField("Primary HTTPS origin", text: $settingsStore.blossomPrimaryOrigin) 143 .textInputAutocapitalization(.never) 144 .keyboardType(.URL) 145 .accessibilityIdentifier("radroots.settings.blossom.primary") 146 TextField( 147 "Fallback HTTPS origins, one per line", 148 text: $settingsStore.blossomFallbackOrigins, 149 axis: .vertical 150 ) 151 .lineLimit(2 ... 5) 152 .textInputAutocapitalization(.never) 153 .keyboardType(.URL) 154 Toggle("Cellular downloads", isOn: $settingsStore.allowCellularDownloads) 155 Toggle("Cellular uploads", isOn: $settingsStore.allowCellularUploads) 156 Toggle("Background transfers", isOn: $settingsStore.allowBackgroundTransfers) 157 } 158 TeraStorageSettingsSection(settingsStore: settingsStore, todayStore: todayStore) 159 TeraVisibilitySettingsSection() 160 Section("Blossom service status") { 161 if let configuration = addStore.blossomConfiguration { 162 LabeledContent("Origin", value: configuration.primaryOrigin) 163 .accessibilityIdentifier("radroots.settings.blossom.origin") 164 LabeledContent("Configuration", value: abbreviated(configuration.configFingerprint)) 165 .accessibilityIdentifier("radroots.settings.blossom.fingerprint") 166 } else { 167 Text("No photo service is configured for this network profile.") 168 .foregroundStyle(.secondary) 169 } 170 if let evidence = addStore.blossomEvidence { 171 LabeledContent("Service state", value: display(evidence.state)) 172 .accessibilityIdentifier("radroots.settings.blossom.state") 173 LabeledContent("Connection", value: display(evidence.transportSecurity)) 174 if evidence.lastSuccessfulState != "none" { 175 LabeledContent("Last success", value: display(evidence.lastSuccessfulState)) 176 } 177 if let status = evidence.httpStatus { 178 LabeledContent("HTTP status", value: String(status)) 179 .accessibilityIdentifier("radroots.settings.blossom.http_status") 180 } 181 if let errorCode = evidence.errorCode { 182 LabeledContent("Last error", value: display(errorCode)) 183 .accessibilityIdentifier("radroots.settings.blossom.error_code") 184 } 185 if let serverErrorCode = evidence.serverErrorCode { 186 LabeledContent("Server error", value: display(serverErrorCode)) 187 .accessibilityIdentifier("radroots.settings.blossom.server_error_code") 188 } 189 if evidence.attempts > 0 { 190 LabeledContent("Attempts", value: String(evidence.attempts)) 191 } 192 if evidence.possibleOrphan { 193 Text("The server may contain an upload whose verification did not complete.") 194 .foregroundStyle(.orange) 195 } 196 } 197 LabeledContent( 198 "Photo library", 199 value: addStore.mediaSupport.library ? "Ready" : "Unavailable" 200 ) 201 LabeledContent("Camera", value: addStore.mediaSupport.cameraAccess.label) 202 Button { 203 Task { await addStore.checkPhotoService() } 204 } label: { 205 if addStore.isCheckingBlossom { 206 ProgressView() 207 } else { 208 Text("Check photo service") 209 } 210 } 211 .disabled(addStore.isCheckingBlossom || addStore.blossomConfiguration == nil) 212 .accessibilityIdentifier("radroots.settings.retry.blossom") 213 } 214 Section("Runtime") { 215 LabeledContent("Crate", value: snapshot.crateName) 216 LabeledContent("Version", value: snapshot.crateVersion) 217 LabeledContent("State", value: snapshot.isClosed ? "Closed" : "Running") 218 } 219 } 220 // Native audits and system typography changes invalidate lazy row heights. 221 // Rebuild those measurements while retaining the existing settings store. 222 .id(dynamicTypeSize) 223 .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize) 224 .navigationTitle("Settings") 225 .sheet(isPresented: $showsKeyRemoval) { 226 TeraKeyRemovalView(author: snapshot.identity.publicKeyHex) 227 } 228 .task { await settingsStore.load(profile: meStore.snapshot?.profile) } 229 .radrootsDocumentExporter(preparedExport: $diagnosticsStore.preparedExport) { result in 230 diagnosticsStore.completeExport(result) 231 } 232 .accessibilityIdentifier("radroots.support.settings.view") 233 } 234 235 private var abbreviatedPublicKey: String { 236 let key = snapshot.identity.publicKeyHex 237 guard key.count > 16 else { return key } 238 return "\(key.prefix(8))…\(key.suffix(8))" 239 } 240 241 private func abbreviated(_ value: String) -> String { 242 guard value.count > 16 else { return value } 243 return "\(value.prefix(8))…\(value.suffix(8))" 244 } 245 246 private func display(_ value: String) -> String { 247 value.replacingOccurrences(of: "_", with: " ") 248 } 249 }