| 1 | import SwiftUI |
| 2 | |
| 3 | // The community directory (the web's showDiscover in shell.js): every open |
| 4 | // group in the space, joined or not. Join is the surface's one loud control — |
| 5 | // groups you're already in get the quiet variant. Shared by the iOS push |
| 6 | // (from New chat) and the Mac sheet so the two surfaces can't drift; the |
| 7 | // platform supplies navigation via onOpen. |
| 8 | public struct DiscoverGroupsView: View { |
| 9 | @ObservedObject var store: ThreadStore |
| 10 | let onOpen: (String) -> Void |
| 11 | |
| 12 | private enum Phase: Equatable { |
| 13 | case loading |
| 14 | case keyPending // instance key hasn't reached this device yet |
| 15 | case loaded([DiscoverGroup]) |
| 16 | } |
| 17 | @State private var phase: Phase = .loading |
| 18 | @State private var joining: String? |
| 19 | @State private var failedJoin: String? |
| 20 | |
| 21 | public init(store: ThreadStore, onOpen: @escaping (String) -> Void) { |
| 22 | self.store = store |
| 23 | self.onOpen = onOpen |
| 24 | } |
| 25 | |
| 26 | public var body: some View { |
| 27 | List { |
| 28 | switch phase { |
| 29 | case .loading: |
| 30 | HStack { Spacer(); ProgressView(); Spacer() } |
| 31 | .listRowBackground(Color.clear) |
| 32 | case .keyPending: |
| 33 | Text("Waiting for your key to arrive — try again in a moment.") |
| 34 | .foregroundStyle(.secondary) |
| 35 | case .loaded(let groups) where groups.isEmpty: |
| 36 | Text("No open groups here yet. Make a group open and it shows up for everyone in this space.") |
| 37 | .foregroundStyle(.secondary) |
| 38 | case .loaded(let groups): |
| 39 | Section { |
| 40 | ForEach(groups) { row($0) } |
| 41 | } footer: { |
| 42 | Text("Groups anyone in this space can join — no invite needed.") |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | .navigationTitle("Discover") |
| 47 | #if os(iOS) |
| 48 | .navigationBarTitleDisplayMode(.inline) |
| 49 | #endif |
| 50 | .task { await load() } |
| 51 | .refreshable { await load() } |
| 52 | } |
| 53 | |
| 54 | private func row(_ g: DiscoverGroup) -> some View { |
| 55 | HStack(spacing: 12) { |
| 56 | GroupMonogram(name: g.title) |
| 57 | VStack(alignment: .leading, spacing: 2) { |
| 58 | Text(g.title).font(.body.weight(.medium)).lineLimit(1) |
| 59 | Text(subtitle(g)) |
| 60 | .font(.footnote) |
| 61 | .foregroundStyle(failedJoin == g.id ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| 62 | } |
| 63 | Spacer(minLength: 8) |
| 64 | if g.joined { |
| 65 | Button("Open") { onOpen(g.id) } |
| 66 | .buttonStyle(.bordered) |
| 67 | .buttonBorderShape(.capsule) |
| 68 | .accessibilityLabel("Open \(g.title)") |
| 69 | } else if g.locked || g.keyPending { |
| 70 | // No dead Join buttons: a lock is information, not an error, |
| 71 | // and the state is carried by the row text too (never a |
| 72 | // glyph alone). |
| 73 | Image(systemName: g.locked ? "lock" : "hourglass") |
| 74 | .foregroundStyle(.secondary) |
| 75 | .accessibilityLabel(g.locked ? "Locked" : "Waiting for your key") |
| 76 | } else { |
| 77 | Button { join(g) } label: { |
| 78 | if joining == g.id { ProgressView().controlSize(.small) } else { Text("Join") } |
| 79 | } |
| 80 | .buttonStyle(.borderedProminent) |
| 81 | .buttonBorderShape(.capsule) |
| 82 | .disabled(joining != nil) |
| 83 | .accessibilityLabel("Join \(g.title)") |
| 84 | } |
| 85 | } |
| 86 | .padding(.vertical, 2) |
| 87 | } |
| 88 | |
| 89 | private func subtitle(_ g: DiscoverGroup) -> String { |
| 90 | if failedJoin == g.id { return "Couldn't join — try again." } |
| 91 | let count = g.memberCount == 1 ? "1 member" : "\(g.memberCount) members" |
| 92 | if g.locked { return g.tierName.isEmpty ? "\(count) · limited access" : "\(count) · for \(g.tierName) only" } |
| 93 | if g.keyPending { return "\(count) · your key is on its way" } |
| 94 | return g.defaultJoin ? "\(count) · everyone starts here" : count |
| 95 | } |
| 96 | |
| 97 | private func load() async { |
| 98 | guard let groups = await store.discoverGroups() else { |
| 99 | phase = .keyPending |
| 100 | return |
| 101 | } |
| 102 | phase = .loaded(groups) |
| 103 | } |
| 104 | |
| 105 | private func join(_ g: DiscoverGroup) { |
| 106 | joining = g.id |
| 107 | failedJoin = nil |
| 108 | Task { |
| 109 | let ok = await store.joinOpenGroup(g.id) |
| 110 | joining = nil |
| 111 | if ok { |
| 112 | onOpen(g.id) |
| 113 | } else { |
| 114 | failedJoin = g.id |
| 115 | await load() // it may have closed while the sheet was up |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // The same face the app draws everywhere for a photo-less name: first initial |
| 122 | // over a colour hashed from the name (MonogramImage's shared rules, so a |
| 123 | // group's Discover face matches its sidebar face once joined). |
| 124 | private struct GroupMonogram: View { |
| 125 | let name: String |
| 126 | private static let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red] |
| 127 | |
| 128 | var body: some View { |
| 129 | ZStack { |
| 130 | Circle().fill(Self.palette[MonogramImage.paletteIndex(for: name)]) |
| 131 | Text(MonogramImage.initial(for: name)) |
| 132 | .font(.system(size: 16, weight: .semibold)) |
| 133 | .foregroundStyle(.white) |
| 134 | } |
| 135 | .frame(width: 38, height: 38) |
| 136 | .accessibilityHidden(true) |
| 137 | } |
| 138 | } |
| 139 | |