field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }