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 lifted reply-thread view — the native twin of chat.js's chatapp-thread
4// overlay, shared by the iOS and Mac containers: ONE scrolling list whose
5// first message is the one the thread is about, its replies chronological
6// under it, over a dimmed transcript. The panel is frosted material so the
7// conversation stays visible through it, like the web's backdrop-filter glass.
8//
9// The root is a row, not a pinned box: a thread is a small chat window, and
10// pinning its opening message above a second scroll region split the panel in
11// two — a message, a rule, and an empty pane whenever nobody had replied yet.
12//
13// Split of responsibilities: the CONTAINER owns presentation (where the
14// overlay sits, Escape/swipe wiring) and the composer contract (staging the
15// root reply, re-staging after sends/edits, closing on banner-cancel); this
16// view owns the panel. It renders LIVE from the model's published messages
17// through the pure threadItems selector — never a cached list — so replies
18// fold in as they arrive; it pages history backward looking for an unloaded
19// root (capped, with the inline note standing in for it until it lands); and
20// it closes itself when the whole thread is deleted underneath it.
21//
22// A SECOND presentation (the Mac's docked thread pane, thread-pane spec §6):
23// `docked: true` renders the same panel CONTENT — header/✕, paging note, the
24// message list — flush, with no dim, no floating card chrome and no drag, for
25// the container to mount as a right-hand column beside its feed (the container
26// supplies the column's width, divider, background and its own composer). The
27// container contract above holds unchanged either way; iOS never passes the
28// flag, so its overlay presentation is untouched.
29public struct ReplyThreadView<Row: View>: View {
30 @ObservedObject private var model: ChatFeedModel
31 private let rootID: Int64
32 private let isGroup: Bool
33 /// iOS opts in: dragging the header down rides the panel with the finger
34 /// and releases into a close — the sheet gesture readers expect on touch.
35 private let swipeDownCloses: Bool
36 /// The docked presentation (see the header note): panel content only,
37 /// flush in the container's column — no dim, no card, no drag.
38 private let docked: Bool
39 /// A reply to land ON (a jump to a reply that lives only in its thread —
40 /// a search hit, a quote tap, a notification's newest unread): once the
41 /// rows have laid out the list scrolls it to the centre; the container
42 /// waves it through its row closure. nil = open at the newest reply, as
43 /// always. Bumping `jumpTick` re-lands a target on an already-open view
44 /// (the web lands inside .chatapp-thread-msgs the same way).
45 private let jumpTarget: Int64?
46 private let jumpTick: Int
47 private let onClose: () -> Void
48 /// Paging just surfaced the root — the container refreshes its staged
49 /// reply banner to the live snapshot (web pageInRoot's tail).
50 private let onRootPaged: (() -> Void)?
51 /// One fully-wired bubble — the container passes the same MessageBubble
52 /// wiring its feed uses, so reactions, attachments, context menus and
53 /// lightboxes all keep working inside the view. Alignment, separators and
54 /// run-grouping are this view's job.
55 private let row: (ChatFeedModel.Message, _ showSender: Bool) -> Row
56
57 // Paging history backward looking for the root (drives the inline note).
58 @State private var paging = false
59 // Whether the list sits at its bottom — gates the live auto-follow (a
60 // reader scrolled up a long thread isn't yanked down by an arrival).
61 @State private var bottomVisible = false
62 // Header-drag offset for the swipe-down close.
63 @State private var dragOffset: CGFloat = 0
64 // The jump this view has already landed (id + the tick that asked for
65 // it): landing is ONE-SHOT, so a re-appear of the replies scroll —
66 // returning from the video player or a camera fullScreenCover — can't
67 // re-aim at a target the reader has since scrolled away from. A fresh
68 // tick (the container re-aiming) is a new request and lands again.
69 @State private var landedJump: (id: Int64, tick: Int)?
70
71 public init(model: ChatFeedModel, rootID: Int64,
72 isGroup: Bool, swipeDownCloses: Bool = false,
73 docked: Bool = false,
74 jumpTarget: Int64? = nil, jumpTick: Int = 0,
75 onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil,
76 @ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) {
77 self.model = model
78 self.rootID = rootID
79 self.isGroup = isGroup
80 self.swipeDownCloses = swipeDownCloses
81 self.docked = docked
82 self.jumpTarget = jumpTarget
83 self.jumpTick = jumpTick
84 self.onClose = onClose
85 self.onRootPaged = onRootPaged
86 self.row = row
87 }
88
89 public var body: some View {
90 // Re-derived from current state on every model publish — the view
91 // never caches its list (chat.js refreshThread's rule).
92 let items = ChatFeedModel.threadItems(in: model.messages, root: rootID)
93 Group {
94 if docked {
95 // Docked: the panel content fills the container's column
96 // flush — the container draws the column chrome around it.
97 panelContent(items)
98 } else {
99 ZStack {
100 // Lighter than a sheet dim: the frosted panel reads the
101 // transcript through itself, so the backdrop only needs to
102 // recede, not vanish. Tapping it leaves the thread.
103 Color.black.opacity(0.25)
104 .contentShape(Rectangle())
105 .onTapGesture(perform: onClose)
106 panel(items)
107 .padding(10)
108 .offset(y: dragOffset)
109 .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1)
110 }
111 }
112 }
113 // The whole thread got deleted under the open view — nothing left to
114 // show (chat.js refreshThread's close-on-empty). `items` INCLUDES the
115 // root, so a thread someone just started (one row, no answers yet) is
116 // never empty and never auto-closes — only losing every loaded row is.
117 .onChange(of: items.isEmpty) { _, empty in
118 if empty, !paging { onClose() }
119 }
120 .task(id: rootID) { await pageInRoot() }
121 }
122
123 // The overlay's floating card: the shared content wrapped in the frosted
124 // glass chrome. The docked presentation mounts panelContent directly.
125 private func panel(_ items: [ChatFeedModel.Message]) -> some View {
126 panelContent(items)
127 // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) —
128 // material adapts to light/dark for free.
129 .background(.ultraThinMaterial,
130 in: RoundedRectangle(cornerRadius: 16, style: .continuous))
131 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
132 .strokeBorder(.quaternary, lineWidth: 1))
133 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
134 .shadow(color: .black.opacity(0.25), radius: 20, y: 6)
135 }
136
137 // The panel CONTENT both presentations share: header/✕, the paging note,
138 // and the one message list — root first, replies under it.
139 private func panelContent(_ items: [ChatFeedModel.Message]) -> some View {
140 VStack(spacing: 0) {
141 // The header counts the ANSWERS, never the root — the one place
142 // the tally still appears in this view, in the same words as the
143 // tally that opened it (replyCountLabel: pluralised + localised).
144 header(replyCount: ChatFeedModel.threadReplyCount(items, root: rootID))
145 Divider()
146 // With the root in the list there's no snapshot header to stand in
147 // for it: while it's missing the note carries that job alone and
148 // the list simply starts at the oldest loaded reply.
149 if let note = note(rootLoaded: items.first?.id == rootID) {
150 Text(note)
151 .font(.system(size: 12))
152 .foregroundStyle(.secondary)
153 .frame(maxWidth: .infinity, alignment: .leading)
154 .padding(.horizontal, 16)
155 .padding(.vertical, 8)
156 Divider()
157 }
158 messageList(items)
159 }
160 // Inside the view every row already IS the thread — quotes/tallies off.
161 .environment(\.inReplyThread, true)
162 }
163
164 private func header(replyCount: Int) -> some View {
165 HStack(alignment: .firstTextBaseline, spacing: 8) {
166 Text("Thread")
167 .font(.system(size: 15, weight: .bold))
168 Text(ChatFeedModel.replyCountLabel(replyCount))
169 .font(.system(size: 12))
170 .foregroundStyle(.secondary)
171 Spacer()
172 Button(action: onClose) {
173 Image(systemName: "xmark")
174 .font(.system(size: 13, weight: .semibold))
175 .foregroundStyle(.secondary)
176 .frame(width: 28, height: 28)
177 .contentShape(Circle())
178 }
179 .buttonStyle(.plain)
180 .accessibilityLabel("Close thread")
181 }
182 .padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
183 .contentShape(Rectangle())
184 .gesture(headerDrag)
185 // The overlay is modal (it lifts over a dimmed transcript); the
186 // docked pane is an ordinary sibling region, never a modal.
187 .accessibilityAddTraits(docked ? [] : .isModal)
188 }
189
190 // Ride the panel with a downward header drag; release past the threshold
191 // closes, anything else springs back. Header-only, so the replies scroll
192 // and the root's own scroll never fight the gesture; a no-op unless the
193 // container opted in (iOS — the Mac closes via ✕/Escape/dim).
194 private var headerDrag: some Gesture {
195 DragGesture(minimumDistance: 10)
196 .onChanged { v in
197 guard swipeDownCloses else { return }
198 dragOffset = max(0, v.translation.height)
199 }
200 .onEnded { v in
201 guard swipeDownCloses else { return }
202 if dragOffset > 120 || v.predictedEndTranslation.height > 300 {
203 onClose()
204 // The container removes the view; reset for a re-open of
205 // the same struct identity.
206 dragOffset = 0
207 } else {
208 withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
209 dragOffset = 0
210 }
211 }
212 }
213 }
214
215 private func note(rootLoaded: Bool) -> String? {
216 if paging { return "Loading the start of this thread…" }
217 if !rootLoaded { return "The original message isn't available — showing its replies." }
218 return nil
219 }
220
221 // The thread's messages as ONE list: the root is simply row 0 (with the
222 // dated separator that opens any transcript), its replies under it. Same
223 // grouping the feeds use, and .defaultScrollAnchor(.bottom) fills it from
224 // the composer up — a thread is a chat window, so it opens like one: a
225 // short one shows whole, a long one at its newest replies.
226 private func messageList(_ items: [ChatFeedModel.Message]) -> some View {
227 ScrollViewReader { proxy in
228 ScrollView {
229 LazyVStack(spacing: 0) {
230 ForEach(Array(items.enumerated()), id: \.element.id) { i, m in
231 messageRow(m, previous: i > 0 ? items[i - 1] : nil)
232 .id(m.id)
233 }
234 // Fixed-height follow target, and the "at bottom" probe
235 // that gates it (same sentinel trick as both feeds).
236 Color.clear.frame(height: 1).id("thread-bottom")
237 .onAppear { bottomVisible = true }
238 .onDisappear { bottomVisible = false }
239 }
240 .padding(EdgeInsets(top: 8, leading: 12, bottom: 12, trailing: 12))
241 }
242 .defaultScrollAnchor(.bottom)
243 // Follow the newest reply like the feed does, but only when
244 // already there (web refreshThread's follow rule).
245 .onChange(of: items.last?.id) { _, id in
246 if id != nil, bottomVisible {
247 withAnimation(.easeOut(duration: 0.15)) {
248 proxy.scrollTo("thread-bottom", anchor: .bottom)
249 }
250 }
251 }
252 // The jump target: on the opening mount, and again whenever the
253 // container re-aims an open view (the tick).
254 .onAppear { landJump(proxy) }
255 .onChange(of: jumpTick) { _, _ in landJump(proxy) }
256 }
257 }
258
259 // Scroll the jump target into view, ONCE per request (see landedJump).
260 // Rows are lazy and the view may still be sliding in, so give layout a
261 // beat first; a target not in the list (paged out, deleted) is left
262 // alone — the bottom anchor stands.
263 private func landJump(_ proxy: ScrollViewProxy) {
264 guard let id = jumpTarget else { return }
265 if let landed = landedJump, landed.id == id, landed.tick == jumpTick { return }
266 landedJump = (id, jumpTick)
267 DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
268 withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(id, anchor: .center) }
269 }
270 }
271
272 @ViewBuilder private func messageRow(_ m: ChatFeedModel.Message,
273 previous: ChatFeedModel.Message?) -> some View {
274 // The feeds' exact grouping (chat.js viewItems, which the web thread
275 // panel reuses): the first row — the root, when it's loaded — opens
276 // with a dated separator, then day-change/15-minute rules; a run only
277 // continues uninterrupted.
278 let sep = timeSeparator(at: m.at, previous: previous?.at)
279 let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m)
280 if let sep {
281 Group {
282 if sep == .dated {
283 Text(chatDayLabel(m.at)).bold() + Text(" \(chatTime(m.at))")
284 } else {
285 Text(chatTime(m.at))
286 }
287 }
288 .font(.system(size: 12))
289 .foregroundStyle(.secondary)
290 .frame(maxWidth: .infinity)
291 .padding(.top, 12)
292 .padding(.bottom, 6)
293 }
294 if m.action {
295 Text(m.text)
296 .font(.system(size: 13))
297 .foregroundStyle(.secondary)
298 .multilineTextAlignment(.center)
299 .frame(maxWidth: .infinity)
300 .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
301 } else {
302 HStack(spacing: 0) {
303 if m.mine { Spacer(minLength: 40) }
304 row(m, isGroup && startsRun)
305 if !m.mine { Spacer(minLength: 40) }
306 }
307 .padding(EdgeInsets(top: startsRun ? 8 : 3, leading: 0,
308 bottom: 3, trailing: 0))
309 }
310 }
311
312 // Page history backward until the thread's root is loaded — bounded (the
313 // web's THREAD_PAGE_CAP) so a reply deep in a huge history can't spin the
314 // loader forever. Pages are contiguous: once the oldest loaded id is below
315 // the root's without finding it, the root was deleted — more paging can't
316 // surface it. loadOlder self-gates (ready/cooldown/exhausted), so this
317 // shares the feed's scroll-back path safely.
318 private func pageInRoot() async {
319 guard !model.messages.contains(where: { $0.id == rootID }) else { return }
320 paging = true
321 for _ in 0..<5 {
322 if Task.isCancelled { break } // the view closed mid-page
323 if model.messages.contains(where: { $0.id == rootID }) { break }
324 guard let oldest = model.messages.first?.id, oldest > rootID,
325 !model.exhausted else { break }
326 await model.loadOlder()
327 // loadOlder's own half-second cooldown gates re-entry; give it
328 // that beat rather than burning the cap on no-op calls.
329 try? await Task.sleep(nanoseconds: 550_000_000)
330 }
331 paging = false
332 // The root may have just loaded — let the container refresh its
333 // staged reply banner to the live snapshot.
334 if !Task.isCancelled, model.messages.contains(where: { $0.id == rootID }) {
335 onRootPaged?()
336 }
337 }
338}
339