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 lifted reply-thread view — the native twin of chat.js's chatapp-thread
4// overlay, shared by the iOS and Mac containers: the thread's root pinned on
5// top (height-capped so a long one can't swallow the replies), its replies
6// chronological below, over a dimmed transcript. The panel is frosted material
7// so the conversation stays visible through it, like the web's
8// backdrop-filter glass.
9//
10// Split of responsibilities: the CONTAINER owns presentation (where the
11// overlay sits, Escape/swipe wiring) and the composer contract (staging the
12// root reply, re-staging after sends/edits, closing on banner-cancel); this
13// view owns the panel. It renders LIVE from the model's published messages
14// through the pure threadItems selector — never a cached list — so replies
15// fold in as they arrive; it pages history backward looking for an unloaded
16// root (capped, with the inline loading note, falling back to the quoted
17// snapshot as its header); and it closes itself when the whole thread is
18// deleted underneath it.
19public struct ReplyThreadView<Row: View>: View {
20 @ObservedObject private var model: ChatFeedModel
21 private let rootID: Int64
22 /// Frozen at open (the web's threadFallback): the header stand-in while
23 /// the root message isn't in loaded history.
24 private let fallback: ReplyRef?
25 private let theme: ChatTheme
26 private let isGroup: Bool
27 /// iOS opts in: dragging the header down rides the panel with the finger
28 /// and releases into a close — the sheet gesture readers expect on touch.
29 private let swipeDownCloses: Bool
30 private let onClose: () -> Void
31 /// Paging just surfaced the root — the container refreshes its staged
32 /// reply banner to the live snapshot (web pageInRoot's tail).
33 private let onRootPaged: (() -> Void)?
34 /// One fully-wired bubble — the container passes the same MessageBubble
35 /// wiring its feed uses, so reactions, attachments, context menus and
36 /// lightboxes all keep working inside the view. Alignment, separators and
37 /// run-grouping are this view's job.
38 private let row: (ChatFeedModel.Message, _ showSender: Bool) -> Row
39
40 @Environment(\.colorScheme) private var scheme
41 // Paging history backward looking for the root (drives the inline note).
42 @State private var paging = false
43 // Whether the replies list sits at its bottom — gates the live auto-follow
44 // (a reader scrolled up a long thread isn't yanked down by an arrival).
45 @State private var bottomVisible = false
46 // The pinned root's measured content height, so its box hugs a short root
47 // instead of always spending the full cap.
48 @State private var rootHeight: CGFloat = 0
49 // Header-drag offset for the swipe-down close.
50 @State private var dragOffset: CGFloat = 0
51
52 public init(model: ChatFeedModel, rootID: Int64, fallback: ReplyRef?,
53 theme: ChatTheme, isGroup: Bool, swipeDownCloses: Bool = false,
54 onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil,
55 @ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) {
56 self.model = model
57 self.rootID = rootID
58 self.fallback = fallback
59 self.theme = theme
60 self.isGroup = isGroup
61 self.swipeDownCloses = swipeDownCloses
62 self.onClose = onClose
63 self.onRootPaged = onRootPaged
64 self.row = row
65 }
66
67 public var body: some View {
68 // Re-derived from current state on every model publish — the view
69 // never caches its list (chat.js refreshThread's rule).
70 let items = ChatFeedModel.threadItems(in: model.messages, root: rootID)
71 let rootLoaded = items.first?.id == rootID
72 let replies = rootLoaded ? Array(items.dropFirst()) : items
73 GeometryReader { geo in
74 ZStack {
75 // Lighter than a sheet dim: the frosted panel reads the
76 // transcript through itself, so the backdrop only needs to
77 // recede, not vanish. Tapping it leaves the thread.
78 Color.black.opacity(0.25)
79 .contentShape(Rectangle())
80 .onTapGesture(perform: onClose)
81 panel(root: rootLoaded ? items.first : nil, replies: replies,
82 cap: geo.size.height * 0.4)
83 .padding(10)
84 .offset(y: dragOffset)
85 .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1)
86 }
87 }
88 // The whole thread got deleted under the open view — nothing left to
89 // show (chat.js refreshThread's close-on-empty).
90 .onChange(of: items.isEmpty) { _, empty in
91 if empty, !paging { onClose() }
92 }
93 .task(id: rootID) { await pageInRoot() }
94 }
95
96 private func panel(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message],
97 cap: CGFloat) -> some View {
98 VStack(spacing: 0) {
99 header(replyCount: replies.count)
100 Divider()
101 rootBox(root, cap: cap)
102 Divider()
103 if let note = note(rootLoaded: root != nil) {
104 Text(note)
105 .font(.system(size: 12))
106 .foregroundStyle(.secondary)
107 .frame(maxWidth: .infinity, alignment: .leading)
108 .padding(.horizontal, 16)
109 .padding(.vertical, 8)
110 Divider()
111 }
112 repliesList(replies)
113 }
114 // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) —
115 // material adapts to light/dark for free.
116 .background(.ultraThinMaterial,
117 in: RoundedRectangle(cornerRadius: 16, style: .continuous))
118 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
119 .strokeBorder(.quaternary, lineWidth: 1))
120 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
121 .shadow(color: .black.opacity(0.25), radius: 20, y: 6)
122 // Inside the view every row already IS the thread — quotes/tallies off.
123 .environment(\.inReplyThread, true)
124 }
125
126 private func header(replyCount: Int) -> some View {
127 HStack(alignment: .firstTextBaseline, spacing: 8) {
128 Text("Thread")
129 .font(.system(size: 15, weight: .bold))
130 Text(replyCount == 1 ? "1 reply" : "\(replyCount) replies")
131 .font(.system(size: 12))
132 .foregroundStyle(.secondary)
133 Spacer()
134 Button(action: onClose) {
135 Image(systemName: "xmark")
136 .font(.system(size: 13, weight: .semibold))
137 .foregroundStyle(.secondary)
138 .frame(width: 28, height: 28)
139 .contentShape(Circle())
140 }
141 .buttonStyle(.plain)
142 .accessibilityLabel("Close thread")
143 }
144 .padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
145 .contentShape(Rectangle())
146 .gesture(headerDrag)
147 .accessibilityAddTraits(.isModal)
148 }
149
150 // Ride the panel with a downward header drag; release past the threshold
151 // closes, anything else springs back. Header-only, so the replies scroll
152 // and the root's own scroll never fight the gesture; a no-op unless the
153 // container opted in (iOS — the Mac closes via ✕/Escape/dim).
154 private var headerDrag: some Gesture {
155 DragGesture(minimumDistance: 10)
156 .onChanged { v in
157 guard swipeDownCloses else { return }
158 dragOffset = max(0, v.translation.height)
159 }
160 .onEnded { v in
161 guard swipeDownCloses else { return }
162 if dragOffset > 120 || v.predictedEndTranslation.height > 300 {
163 onClose()
164 // The container removes the view; reset for a re-open of
165 // the same struct identity.
166 dragOffset = 0
167 } else {
168 withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
169 dragOffset = 0
170 }
171 }
172 }
173 }
174
175 // The pinned root: the real bubble when loaded (a keyed row, same wiring
176 // as the feed — reactions and edits keep working); the quoted snapshot as
177 // a stand-in while paging hunts for it. Height-capped at ~40% of the
178 // panel, scrolling within itself (web .chatapp-thread-rootmsg).
179 @ViewBuilder private func rootBox(_ root: ChatFeedModel.Message?, cap: CGFloat) -> some View {
180 ScrollView {
181 Group {
182 if let root {
183 HStack(spacing: 0) {
184 if root.mine { Spacer(minLength: 40) }
185 row(root, isGroup)
186 if !root.mine { Spacer(minLength: 40) }
187 }
188 } else {
189 // Root not loaded (yet): the frozen quote stands in
190 // (web .chatapp-thread-rootfall — accent left rule).
191 VStack(alignment: .leading, spacing: 2) {
192 if let name = fallback?.name, !name.isEmpty {
193 Text(name)
194 .font(.system(size: 11))
195 .foregroundStyle(.secondary)
196 }
197 Text(fallback?.snippet ?? "")
198 .font(.system(size: 14))
199 }
200 .padding(.leading, 10)
201 .overlay(alignment: .leading) {
202 RoundedRectangle(cornerRadius: 1.5)
203 .fill(theme.accent.resolved(scheme))
204 .frame(width: 3)
205 }
206 .frame(maxWidth: .infinity, alignment: .leading)
207 }
208 }
209 .padding(EdgeInsets(top: 12, leading: 12, bottom: 10, trailing: 12))
210 .background(GeometryReader { g in
211 Color.clear
212 .onAppear { rootHeight = g.size.height }
213 .onChange(of: g.size.height) { _, h in rootHeight = h }
214 })
215 }
216 // Bootstrap at the cap until measured — a too-tall first frame that
217 // shrinks beats a zero-height one whose content never lays out.
218 .frame(height: min(rootHeight > 0 ? rootHeight : cap, max(cap, 44)))
219 }
220
221 private func note(rootLoaded: Bool) -> String? {
222 if paging { return "Loading the start of this thread…" }
223 if !rootLoaded { return "The original message isn't available — showing its replies." }
224 return nil
225 }
226
227 private func repliesList(_ replies: [ChatFeedModel.Message]) -> some View {
228 ScrollViewReader { proxy in
229 ScrollView {
230 LazyVStack(spacing: 0) {
231 ForEach(Array(replies.enumerated()), id: \.element.id) { i, m in
232 replyRow(m, previous: i > 0 ? replies[i - 1] : nil)
233 .id(m.id)
234 }
235 // Fixed-height follow target, and the "at bottom" probe
236 // that gates it (same sentinel trick as both feeds).
237 Color.clear.frame(height: 1).id("thread-bottom")
238 .onAppear { bottomVisible = true }
239 .onDisappear { bottomVisible = false }
240 }
241 .padding(EdgeInsets(top: 8, leading: 12, bottom: 12, trailing: 12))
242 }
243 .defaultScrollAnchor(.bottom)
244 // Follow the newest reply like the feed does, but only when
245 // already there (web refreshThread's follow rule).
246 .onChange(of: replies.last?.id) { _, id in
247 if id != nil, bottomVisible {
248 withAnimation(.easeOut(duration: 0.15)) {
249 proxy.scrollTo("thread-bottom", anchor: .bottom)
250 }
251 }
252 }
253 }
254 }
255
256 @ViewBuilder private func replyRow(_ m: ChatFeedModel.Message,
257 previous: ChatFeedModel.Message?) -> some View {
258 // The feeds' exact grouping (chat.js viewItems, which the web thread
259 // panel reuses): the first reply opens with a dated separator, then
260 // day-change/15-minute rules; a run only continues uninterrupted.
261 let sep = timeSeparator(at: m.at, previous: previous?.at)
262 let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m)
263 if let sep {
264 Group {
265 if sep == .dated {
266 Text(chatDayLabel(m.at)).bold() + Text(" \(chatTime(m.at))")
267 } else {
268 Text(chatTime(m.at))
269 }
270 }
271 .font(.system(size: 12))
272 .foregroundStyle(.secondary)
273 .frame(maxWidth: .infinity)
274 .padding(.top, 12)
275 .padding(.bottom, 6)
276 }
277 if m.action {
278 Text(m.text)
279 .font(.system(size: 13))
280 .foregroundStyle(.secondary)
281 .multilineTextAlignment(.center)
282 .frame(maxWidth: .infinity)
283 .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
284 } else {
285 HStack(spacing: 0) {
286 if m.mine { Spacer(minLength: 40) }
287 row(m, isGroup && startsRun)
288 if !m.mine { Spacer(minLength: 40) }
289 }
290 .padding(EdgeInsets(top: startsRun ? 8 : 3, leading: 0,
291 bottom: 3, trailing: 0))
292 }
293 }
294
295 // Page history backward until the thread's root is loaded — bounded (the
296 // web's THREAD_PAGE_CAP) so a reply deep in a huge history can't spin the
297 // loader forever. Pages are contiguous: once the oldest loaded id is below
298 // the root's without finding it, the root was deleted — more paging can't
299 // surface it. loadOlder self-gates (ready/cooldown/exhausted), so this
300 // shares the feed's scroll-back path safely.
301 private func pageInRoot() async {
302 guard !model.messages.contains(where: { $0.id == rootID }) else { return }
303 paging = true
304 for _ in 0..<5 {
305 if Task.isCancelled { break } // the view closed mid-page
306 if model.messages.contains(where: { $0.id == rootID }) { break }
307 guard let oldest = model.messages.first?.id, oldest > rootID,
308 !model.exhausted else { break }
309 await model.loadOlder()
310 // loadOlder's own half-second cooldown gates re-entry; give it
311 // that beat rather than burning the cap on no-op calls.
312 try? await Task.sleep(nanoseconds: 550_000_000)
313 }
314 paging = false
315 // The root may have just loaded — let the container refresh its
316 // staged reply banner to the live snapshot.
317 if !Task.isCancelled, model.messages.contains(where: { $0.id == rootID }) {
318 onRootPaged?()
319 }
320 }
321}
322