eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
8public 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).
124private 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