| 1 | import 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. |
| 30 | public 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 | |