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