TeraSupportingViews.swift (11575B)
1 import RadrootsKit 2 import SwiftUI 3 4 struct TeraSearchSheet: View { 5 let snapshot: TeraRuntimeSnapshot 6 let context: TeraLocalNetwork? 7 @ObservedObject var store: TeraSearchStore 8 @ObservedObject var mediaStore: TeraMediaStore 9 let revise: (TeraTodayCard) -> Void 10 let retract: (TeraTodayCard) -> Void 11 @Environment(\.dismiss) private var dismiss 12 13 var body: some View { 14 NavigationStack { 15 Group { 16 switch store.state { 17 case .idle: 18 ContentUnavailableView( 19 "Search your local network", 20 systemImage: "magnifyingglass", 21 description: Text("Find current posts and adopted Nostr profiles.") 22 ) 23 case .loading: 24 ProgressView("Searching…") 25 case .empty: 26 ContentUnavailableView.search(text: store.query) 27 case let .failed(message): 28 ContentUnavailableView { 29 Label("Search unavailable", systemImage: "exclamationmark.triangle") 30 } description: { 31 Text(message) 32 } actions: { 33 Button("Try again") { Task { await store.search() } } 34 } 35 case .loaded: 36 resultList 37 } 38 } 39 .navigationTitle("Search") 40 .searchable(text: query, prompt: "Posts and profiles") 41 .onSubmit(of: .search) { Task { await store.search() } } 42 .toolbar { 43 ToolbarItem(placement: .confirmationAction) { 44 Button("Done") { dismiss() } 45 } 46 } 47 } 48 .task(id: context) { store.configure(context: context) } 49 .accessibilityIdentifier("radroots.support.search.sheet") 50 } 51 52 private var resultList: some View { 53 List(store.results) { result in 54 switch (result.card, result.profile) { 55 case let (card?, _): 56 NavigationLink { 57 TeraTodayDetailView( 58 card: card, 59 context: context, 60 mediaStore: mediaStore, 61 canRevise: card.authorPublicKey == snapshot.identity.publicKeyHex 62 && card.localOperationID != nil, 63 canRetract: card.authorPublicKey == snapshot.identity.publicKeyHex, 64 revise: revise, 65 retract: retract 66 ) 67 } label: { 68 TeraTodayCardView(card: card, context: context, mediaStore: mediaStore) 69 } 70 .accessibilityIdentifier("radroots.search.card.\(result.id)") 71 case let (_, profile?): 72 NavigationLink { 73 TeraProfileView(profile: profile, context: context, mediaStore: mediaStore) 74 } label: { 75 TeraProfileRow(profile: profile, context: context, mediaStore: mediaStore) 76 } 77 .accessibilityIdentifier("radroots.search.profile.\(result.id)") 78 default: 79 EmptyView() 80 } 81 } 82 .listStyle(.plain) 83 .accessibilityIdentifier("radroots.search.results") 84 } 85 86 private var query: Binding<String> { 87 Binding( 88 get: { store.query }, 89 set: { value in store.updateQuery(value) } 90 ) 91 } 92 } 93 94 struct TeraMeSheet: View { 95 let runtimeSnapshot: TeraRuntimeSnapshot 96 let context: TeraLocalNetwork? 97 @ObservedObject var store: TeraMeStore 98 @ObservedObject var todayStore: TeraTodayStore 99 @ObservedObject var addStore: TeraAddStore 100 @ObservedObject var settingsStore: TeraSettingsStore 101 @ObservedObject var mediaStore: TeraMediaStore 102 let revise: (TeraTodayCard) -> Void 103 let retract: (TeraTodayCard) -> Void 104 @Environment(\.dismiss) private var dismiss 105 @Environment(\.dynamicTypeSize) private var dynamicTypeSize 106 @State private var showsDrafts = false 107 108 var body: some View { 109 NavigationStack { 110 Group { 111 switch store.state { 112 case .idle, .loading: 113 ProgressView("Loading your profile…") 114 case let .failed(message) where store.snapshot == nil: 115 ContentUnavailableView { 116 Label("Profile unavailable", systemImage: "person.crop.circle.badge.exclamationmark") 117 } description: { 118 Text(message) 119 } actions: { 120 Button("Try again") { Task { await store.reload() } } 121 } 122 default: 123 content 124 } 125 } 126 .navigationTitle("Me") 127 .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic) 128 .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize) 129 .toolbar { 130 ToolbarItem(placement: .confirmationAction) { 131 Button("Done") { dismiss() } 132 } 133 } 134 } 135 .sheet(isPresented: $showsDrafts) { 136 TeraDraftsSheet(store: addStore) 137 } 138 .task(id: context) { 139 store.configure(context: context) 140 await store.start() 141 } 142 .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium, .large]) 143 .accessibilityIdentifier("radroots.support.me.sheet") 144 } 145 146 private var content: some View { 147 List { 148 Section { 149 if let profile = store.snapshot?.profile { 150 NavigationLink { 151 TeraProfileView(profile: profile, context: context, mediaStore: mediaStore) 152 } label: { 153 TeraProfileRow(profile: profile, context: context, mediaStore: mediaStore) 154 } 155 } else { 156 TeraProfileRow( 157 profile: TeraProfileSummary( 158 authorPublicKey: store.snapshot?.publicKey ?? runtimeSnapshot.identity.publicKeyHex, 159 name: nil, 160 displayName: nil, 161 about: nil, 162 picture: nil, 163 banner: nil, 164 nip05: nil, 165 website: nil, 166 lightningAddress: nil 167 ), 168 context: context, 169 mediaStore: mediaStore 170 ) 171 } 172 } 173 174 Section("Local work") { 175 Button { 176 showsDrafts = true 177 } label: { 178 LabeledContent("Drafts & outbox", value: "\(addStore.drafts.count)") 179 } 180 .foregroundStyle(.primary) 181 .accessibilityIdentifier("radroots.support.outbox") 182 } 183 184 Section("My posts") { 185 if store.snapshot?.cards.isEmpty != false { 186 Text("No current posts in this local network.") 187 .foregroundStyle(.secondary) 188 } 189 ForEach(store.snapshot?.cards ?? []) { card in 190 NavigationLink { 191 TeraTodayDetailView( 192 card: card, 193 context: context, 194 mediaStore: mediaStore, 195 canRevise: card.localOperationID != nil, 196 canRetract: card.authorPublicKey 197 == (store.snapshot?.publicKey ?? runtimeSnapshot.identity.publicKeyHex), 198 revise: revise, 199 retract: retract 200 ) 201 } label: { 202 TeraTodayCardView(card: card, context: context, mediaStore: mediaStore) 203 } 204 } 205 } 206 207 Section { 208 NavigationLink("Settings") { 209 TeraSettingsView( 210 snapshot: runtimeSnapshot, 211 todayStore: todayStore, 212 addStore: addStore, 213 meStore: store, 214 settingsStore: settingsStore 215 ) 216 } 217 .accessibilityIdentifier("radroots.support.settings") 218 } 219 } 220 .refreshable { await store.reload() } 221 } 222 } 223 224 struct TeraProfileRow: View { 225 let profile: TeraProfileSummary 226 let context: TeraLocalNetwork? 227 @ObservedObject var mediaStore: TeraMediaStore 228 229 var body: some View { 230 HStack(spacing: 12) { 231 TeraStableAvatarView( 232 profile: profile, 233 context: context, 234 mediaStore: mediaStore, 235 size: 48 236 ) 237 VStack(alignment: .leading, spacing: 3) { 238 Text(profile.preferredName) 239 .font(.headline) 240 if let nip05 = profile.nip05 { 241 Text(nip05) 242 .font(.caption) 243 .foregroundStyle(.secondary) 244 } else { 245 Text(Self.abbreviate(profile.authorPublicKey)) 246 .font(.caption.monospaced()) 247 .foregroundStyle(.secondary) 248 } 249 } 250 } 251 .accessibilityElement(children: .combine) 252 .accessibilityLabel("\(profile.preferredName), \(Self.abbreviate(profile.authorPublicKey))") 253 } 254 255 private static func abbreviate(_ key: String) -> String { 256 guard key.count > 16 else { return key } 257 return "\(key.prefix(8))…\(key.suffix(8))" 258 } 259 } 260 261 struct TeraProfileView: View { 262 let profile: TeraProfileSummary 263 let context: TeraLocalNetwork? 264 @ObservedObject var mediaStore: TeraMediaStore 265 266 var body: some View { 267 List { 268 Section { 269 VStack(spacing: 12) { 270 if let banner = profile.banner { 271 TeraLocalMediaContent(media: banner, context: context, store: mediaStore) 272 .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 160) 273 .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) 274 .clipShape(RoundedRectangle(cornerRadius: 12)) 275 } 276 TeraStableAvatarView( 277 profile: profile, 278 context: context, 279 mediaStore: mediaStore, 280 size: 88 281 ) 282 Text(profile.preferredName) 283 .font(.title2.weight(.semibold)) 284 Text(abbreviatedPublicKey) 285 .font(.caption.monospaced()) 286 .foregroundStyle(.secondary) 287 } 288 .frame(maxWidth: .infinity) 289 .accessibilityElement(children: .combine) 290 } 291 if let about = profile.about, !about.isEmpty { 292 Section("About") { Text(about) } 293 } 294 Section("Nostr profile") { 295 if let nip05 = profile.nip05 { 296 LabeledContent("NIP-05", value: nip05) 297 } 298 if let website = safeHTTPS(profile.website) { 299 Link(destination: website) { 300 LabeledContent("Website", value: website.host ?? website.absoluteString) 301 } 302 } 303 if let address = profile.lightningAddress { 304 LabeledContent("Lightning", value: address) 305 } 306 } 307 } 308 .navigationTitle("Profile") 309 .toolbar { TeraAuthorVisibilityMenu(author: profile.authorPublicKey) } 310 .navigationBarTitleDisplayMode(.inline) 311 .accessibilityIdentifier("radroots.profile.\(profile.authorPublicKey)") 312 } 313 314 private var abbreviatedPublicKey: String { 315 guard profile.authorPublicKey.count > 16 else { return profile.authorPublicKey } 316 return "\(profile.authorPublicKey.prefix(8))…\(profile.authorPublicKey.suffix(8))" 317 } 318 319 private func safeHTTPS(_ value: String?) -> URL? { 320 guard let value, 321 let url = URL(string: value), 322 url.scheme?.lowercased() == "https", 323 url.host != nil, 324 url.user == nil, 325 url.password == nil 326 else { 327 return nil 328 } 329 return url 330 } 331 } 332 333 struct TeraStableAvatarView: View { 334 let profile: TeraProfileSummary 335 let context: TeraLocalNetwork? 336 @ObservedObject var mediaStore: TeraMediaStore 337 let size: CGFloat 338 339 var body: some View { 340 Group { 341 if let picture = profile.picture { 342 TeraLocalMediaContent(media: picture, context: context, store: mediaStore) 343 } else { 344 fallback 345 } 346 } 347 .frame(width: size, height: size) 348 .background(.quaternary) 349 .clipShape(Circle()) 350 } 351 352 private var fallback: some View { 353 let identity = TeraStableVisualIdentity(publicKeyHex: profile.authorPublicKey) 354 return ZStack { 355 Circle().fill(Self.palette[identity.paletteIndex]) 356 Text(String(profile.preferredName.prefix(1)).uppercased()) 357 .font(.system(size: size * 0.4, weight: .semibold, design: .rounded)) 358 .foregroundStyle(.white) 359 } 360 .accessibilityHidden(true) 361 } 362 363 private static let palette: [Color] = [ 364 .indigo, .mint, .orange, .purple, .teal, .pink, 365 .blue, .green, .red, .cyan, .brown, .yellow, 366 ] 367 }