| 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: 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. |
| 19 | public 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 | |