eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// 🤖 The Threads list — the native twin of the web's ▤ Threads panel
4// (apps/chat.js paintThreadList): every thread in this chat, freshest first,
5// one row per thread ROOT. The gap the thread pane left, in Paul's words:
6// "there's no way to see a list of threads — it's hard to find a thread if I
7// miss clicking the notification." A thread only exists where someone chose
8// one, so a chat has a handful and they're findable — if there is somewhere to
9// look. This is that somewhere.
10//
11// Split of responsibilities, exactly as ReplyThreadView's: the CONTAINER owns
12// presentation (the Mac's right-hand column, iOS's sheet) and what a row does
13// (open that thread, and dismiss this list on the way — one right dock); this
14// view owns the panel. It renders LIVE from the model's published rows through
15// the pure `threadListItems` selector, so a thread someone else starts while
16// you're looking arrives without a reload, and the freshest thread re-sorts to
17// the top as replies land.
18//
19// NO UNREAD DOT, deliberately — the same call the web made, for the same
20// reason. Read state in this product is per CHAT and nothing else: one
21// `read_marks(user_id, thread_id, last_read_at)` watermark server-side,
22// `ReadState.lastSeen` natively. Opening the chat advances it past every
23// thread reply in it (thread replies are ordinary messages, so they move the
24// chat's last_at even though the transcript never shows them), so a dot
25// derived from that would read "all caught up" the instant you glanced at the
26// chat — exactly the lie this list exists to fix. Per-thread read state is a
27// real feature (a watermark per root, synced like the chat one); half-inventing
28// it here would put a wrong dot in front of members. Rows carry activity order
29// instead — the freshest thread is simply top.
30public struct ReplyThreadListView: View {
31 @ObservedObject private var model: ChatFeedModel
32 private let theme: ChatTheme
33 /// Mac draws its own inline header (title + ✕) because the column has no
34 /// other chrome; iOS passes false — the sheet's navigation bar carries the
35 /// title and its Done button.
36 private let showsHeader: Bool
37 /// Open this thread. The container dismisses the list first (see the
38 /// one-dock note above) and then opens the pane/overlay, exactly as a
39 /// tally tap does.
40 private let onOpen: (Int64) -> Void
41 private let onClose: () -> Void
42
43 @Environment(\.colorScheme) private var scheme
44 @Environment(\.dynamicTypeSize) private var typeSize
45 /// Hover IS the highlight on Mac (the web row's :hover), so a pointer user
46 /// can see which row they're about to open.
47 @State private var hovered: Int64?
48
49 public init(model: ChatFeedModel, theme: ChatTheme = .classic,
50 showsHeader: Bool = true,
51 onOpen: @escaping (Int64) -> Void,
52 onClose: @escaping () -> Void) {
53 self.model = model
54 self.theme = theme
55 self.showsHeader = showsHeader
56 self.onOpen = onOpen
57 self.onClose = onClose
58 }
59
60 public var body: some View {
61 VStack(spacing: 0) {
62 if showsHeader {
63 header
64 Divider()
65 }
66 if model.threadListRows.isEmpty {
67 // Only reachable with the list already open — the containers
68 // hide the menu entry at zero — so this is what a member sees
69 // when the last thread's replies are deleted under them.
70 Text(Self.emptyNote)
71 .font(.footnote)
72 .foregroundStyle(.secondary)
73 .frame(maxWidth: .infinity, alignment: .leading)
74 .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
75 Spacer(minLength: 0)
76 } else {
77 ScrollView {
78 LazyVStack(spacing: 2) {
79 // Rows are keyed by their root, so a re-sort MOVES a row
80 // rather than rebuilding it — the keyboard stays where it
81 // was when a live thread jumps to the top (the web guards
82 // the same thing with threadListSignature + a focus
83 // restore).
84 ForEach(model.threadListRows) { row($0) }
85 }
86 .padding(EdgeInsets(top: 6, leading: 8, bottom: 10, trailing: 8))
87 }
88 }
89 }
90 }
91
92 static let emptyNote: String = {
93 #if os(macOS)
94 "No threads in this chat. Choose “Reply in Thread” on a message to start one."
95 #else
96 "No threads in this chat. Choose “Reply in thread” on a message to start one."
97 #endif
98 }()
99
100 private var header: some View {
101 HStack(alignment: .firstTextBaseline, spacing: 8) {
102 Text("Threads")
103 .font(.system(size: 15, weight: .bold))
104 Spacer()
105 Button(action: onClose) {
106 Image(systemName: "xmark")
107 .font(.system(size: 13, weight: .semibold))
108 .foregroundStyle(.secondary)
109 .frame(width: 28, height: 28)
110 .contentShape(Circle())
111 }
112 .buttonStyle(.plain)
113 .accessibilityLabel("Close the threads list")
114 }
115 .padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
116 }
117
118 // One row: who started the thread, the line you recognise it by, and how
119 // many replies it holds — the web's .thread-list-row, tally in its own
120 // trailing column so the numbers line up down the list instead of trailing
121 // ragged after snippets of every length.
122 private func row(_ r: ChatFeedModel.ThreadListRow) -> some View {
123 Button { onOpen(r.rootID) } label: {
124 rowBody(r)
125 .padding(EdgeInsets(top: 7, leading: 10, bottom: 7, trailing: 10))
126 .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading)
127 }
128 .buttonStyle(RowStyle(fill: theme.bubbleThem.resolved(scheme),
129 hovered: hovered == r.rootID))
130 .onHover { inside in
131 if inside { hovered = r.rootID } else if hovered == r.rootID { hovered = nil }
132 }
133 // The accessible name says what the row DOES before the size and the
134 // line, so a screen reader announces a destination rather than a
135 // fragment of someone's sentence. Both visible strings are carried
136 // through verbatim, so voice control still matches on the words a
137 // sighted user can see (WCAG 2.5.3).
138 .accessibilityLabel(Self.rowLabel(r))
139 .accessibilityAddTraits(.isButton)
140 }
141
142 @ViewBuilder private func rowBody(_ r: ChatFeedModel.ThreadListRow) -> some View {
143 // At the accessibility type sizes a name, a snippet and a tally cannot
144 // share one line without the tally being squeezed off it, so the tally
145 // moves under the line rather than truncating — the count is the thing
146 // a reader is scanning for.
147 if typeSize.isAccessibilitySize {
148 VStack(alignment: .leading, spacing: 2) {
149 line(r)
150 count(r)
151 }
152 } else {
153 HStack(alignment: .firstTextBaseline, spacing: 10) {
154 // The tally gets its width first and the line takes what's
155 // left, so a long snippet truncates rather than squeezing the
156 // number — and the numbers stay in one column down the list.
157 line(r)
158 count(r).layoutPriority(1)
159 }
160 }
161 }
162
163 private func line(_ r: ChatFeedModel.ThreadListRow) -> some View {
164 (Text(Self.who(r)).font(whoFont) + Text(Self.snippet(r)).font(lineFont).foregroundColor(.secondary))
165 .lineLimit(1)
166 .truncationMode(.tail)
167 .frame(maxWidth: .infinity, alignment: .leading)
168 }
169
170 private func count(_ r: ChatFeedModel.ThreadListRow) -> some View {
171 Text(ChatFeedModel.replyCountLabel(r.count)) // the same string the root's tally wears
172 .font(countFont)
173 .foregroundStyle(.secondary)
174 .monospacedDigit() // the tallies line up down the column
175 .fixedSize(horizontal: true, vertical: false)
176 }
177
178 /// The author prefix, with its separator — "" when the row has no name to
179 /// show (a converted chain whose root paged out).
180 static func who(_ r: ChatFeedModel.ThreadListRow) -> String {
181 r.name.isEmpty ? "" : r.name + ": "
182 }
183
184 /// The line the row is headed by. A CONVERTED chain whose root has scrolled
185 /// out of the loaded window has no truthful stand-in (threadListItems: only
186 /// a flagged reply's ref is its root's own snapshot), so the row would be a
187 /// blank line above its tally — and would read "Open thread, 3 Replies," to
188 /// a screen reader. Say what the thread view says in exactly the same case.
189 static func snippet(_ r: ChatFeedModel.ThreadListRow) -> String {
190 if !r.snippet.isEmpty { return r.snippet }
191 return r.rootLoaded ? "" : "The original message isn't available"
192 }
193
194 /// The row's accessible name — the web row's aria-label, word for word.
195 static func rowLabel(_ r: ChatFeedModel.ThreadListRow) -> String {
196 "Open thread, \(ChatFeedModel.replyCountLabel(r.count)), \(who(r))\(snippet(r))"
197 }
198
199 // The row's own highlight: filled while the pointer is over it (the web
200 // row's :hover) AND while a finger is down on it, so a tap on touch has
201 // the feedback a plain button gives nowhere. ≥34pt tall, so the target
202 // clears the 24px minimum with room to spare (WCAG 2.5.8).
203 private struct RowStyle: ButtonStyle {
204 let fill: Color
205 let hovered: Bool
206
207 func makeBody(configuration: Configuration) -> some View {
208 configuration.label
209 .contentShape(Rectangle())
210 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
211 .fill(configuration.isPressed || hovered ? fill : .clear))
212 }
213 }
214
215 // macOS has no Dynamic Type, so it keeps the fixed sizes the thread pane
216 // and the web row use (13px line, 11px tally); iOS uses text styles, which
217 // scale all the way to the accessibility sizes.
218 #if os(macOS)
219 private var whoFont: Font { .system(size: 13, weight: .semibold) }
220 private var lineFont: Font { .system(size: 13) }
221 private var countFont: Font { .system(size: 11) }
222 #else
223 private var whoFont: Font { .subheadline.weight(.semibold) }
224 private var lineFont: Font { .subheadline }
225 private var countFont: Font { .caption }
226 #endif
227}
228