| 1 | import Combine |
| 2 | import LChatCore |
| 3 | import SwiftUI |
| 4 | import UIKit |
| 5 | |
| 6 | // The iOS feed container, promoted from the /freshpane experiment |
| 7 | // (2026-07-20): a STOCK, UNFLIPPED UICollectionView in natural order — |
| 8 | // oldest at the top, newest at the bottom — with standard safe-area |
| 9 | // behaviour and no transforms. The flipped container (FlippedChatFeed) was |
| 10 | // architecturally right about one thing — UIKit owns the scroll — but its |
| 11 | // flip bought bug classes (mirrored animations, counter-flip cells, |
| 12 | // inset algebra) that the field test rejected, while this shape scrolled |
| 13 | // silky on device. What the flip gave structurally, this container does |
| 14 | // explicitly, in four small rules: |
| 15 | // |
| 16 | // - OPEN lands at the bottom on the first real layout (offset math inside |
| 17 | // layoutSubviews — never scrollToItem there; forcing layout from inside a |
| 18 | // layout pass is the recursion crash the flipped round hit). |
| 19 | // - ARRIVALS: appends never move the viewport in an unflipped list; when |
| 20 | // you're at the bottom (or it's your own send / you're composing) we |
| 21 | // scroll to reveal, otherwise you're reading and nothing yanks. |
| 22 | // - SCROLL-BACK prepends compensate the offset by the REAL laid-out height |
| 23 | // delta in the same pass — and while a fling is live, pages buffer and |
| 24 | // flush when the scroll settles, so the compensation write never kills |
| 25 | // deceleration (the "locked scroll" feel the flipped rounds fought). |
| 26 | // - The KEYBOARD/composer are SwiftUI safe areas; a height change keeps the |
| 27 | // bottom edge anchored iff you were at it. |
| 28 | // |
| 29 | // Rows are the FeedMessageRow surface the flipped container hosts. (The |
| 30 | // FeedLab strip-and-layer ladder and its debug commands are GONE — the |
| 31 | // UserDefaults level survived builds and twice wedged real phones into the |
| 32 | // diagnostic renderer; the LChatFeedTests harness + FeedMenuUITests rig |
| 33 | // replaced everything it was for.) |
| 34 | struct StockChatFeed: UIViewControllerRepresentable { |
| 35 | let model: ChatFeedModel |
| 36 | let interaction: FeedInteraction |
| 37 | let controller: FeedScrollController |
| 38 | let hooks: FeedHooks |
| 39 | // The header/composer glass the feed extends beneath (screen-measured); |
| 40 | // they come back as content insets so content RESTS between them. |
| 41 | let topOverlay: CGFloat |
| 42 | let bottomOverlay: CGFloat |
| 43 | let composerFocused: Bool |
| 44 | let unreadCutoff: Int64? |
| 45 | let onFirstLayout: (Int) -> Void |
| 46 | |
| 47 | enum FeedItem: Hashable { |
| 48 | case message(Int64) |
| 49 | case typing |
| 50 | } |
| 51 | |
| 52 | #if DEBUG |
| 53 | // The container ladder's knob (RowLadderTests): swap what a row RENDERS |
| 54 | // while the container machinery stays identical, so the scroll-cost |
| 55 | // bisect runs rung by rung in the harness. Test-target-only, in-memory, |
| 56 | // never persisted, unreachable from any app UI — the feedLabLevel |
| 57 | // UserDefaults lesson (a persisted level twice wedged real phones). |
| 58 | @MainActor |
| 59 | static var testRowOverride: ((ChatFeedModel.Message, ChatFeedModel.Message?) -> AnyView)? |
| 60 | #endif |
| 61 | |
| 62 | func makeCoordinator() -> Coordinator { |
| 63 | Coordinator(model: model, interaction: interaction, hooks: hooks, |
| 64 | unreadCutoff: unreadCutoff) |
| 65 | } |
| 66 | |
| 67 | // A view CONTROLLER hosts the feed so the glass overlays can be declared |
| 68 | // as additionalSafeAreaInsets — real, UIKit-visible safe areas. That is |
| 69 | // what Messages-style full-bleed actually is: automatic inset adjustment |
| 70 | // gets honest geometry, and the system's top-edge treatment degrades to |
| 71 | // the subtle status-bar fade instead of the half-screen squash it |
| 72 | // produced when our scroll view had .never insets and no safe areas |
| 73 | // (the 2026-07-20 hunt). |
| 74 | func makeUIViewController(context: Context) -> FeedHostController { |
| 75 | let coordinator = context.coordinator |
| 76 | coordinator.hooks = hooks |
| 77 | coordinator.composerFocused = composerFocused |
| 78 | coordinator.onFirstLayout = onFirstLayout |
| 79 | // Seed the config key NOW — leaving the dummy meant the first |
| 80 | // updateUIViewController "changed" it and reconfigured everything, |
| 81 | // re-measuring every row a second time on every open (half the |
| 82 | // 2026-07-21 freeze). |
| 83 | coordinator.configKey = ConfigKey(themeName: hooks.themeName, |
| 84 | fontSize: hooks.chatFontSize, |
| 85 | isGroup: hooks.isGroup) |
| 86 | let cv = coordinator.build() |
| 87 | let host = FeedHostController() |
| 88 | host.collectionView = cv |
| 89 | host.onJumpTap = { [weak coordinator] in coordinator?.jumpTapped() } |
| 90 | coordinator.host = host |
| 91 | controller.target = coordinator |
| 92 | coordinator.setOverlays(top: topOverlay, bottom: bottomOverlay) |
| 93 | coordinator.subscribe() |
| 94 | return host |
| 95 | } |
| 96 | |
| 97 | func updateUIViewController(_ host: FeedHostController, context: Context) { |
| 98 | let coordinator = context.coordinator |
| 99 | coordinator.hooks = hooks |
| 100 | coordinator.composerFocused = composerFocused |
| 101 | coordinator.onFirstLayout = onFirstLayout |
| 102 | coordinator.setOverlays(top: topOverlay, bottom: bottomOverlay) |
| 103 | let key = ConfigKey(themeName: hooks.themeName, fontSize: hooks.chatFontSize, |
| 104 | isGroup: hooks.isGroup) |
| 105 | if coordinator.configKey != key { |
| 106 | coordinator.configKey = key |
| 107 | coordinator.reconfigureAll() |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | struct ConfigKey: Equatable { |
| 112 | var themeName: String |
| 113 | var fontSize: CGFloat |
| 114 | var isGroup: Bool |
| 115 | } |
| 116 | |
| 117 | final class FeedHostController: UIViewController { |
| 118 | var collectionView: UICollectionView! |
| 119 | // The jump-to-latest caret is a REAL UIKit control here, not a |
| 120 | // SwiftUI overlay: an overlay button loses touch arbitration while |
| 121 | // the list is moving — taps stopped the scroll instead of firing. |
| 122 | // A UIButton beside the scroll view takes taps mid-fling, |
| 123 | // unconditionally. |
| 124 | let jumpButton = UIButton(type: .system) |
| 125 | var onJumpTap: (() -> Void)? |
| 126 | |
| 127 | override func loadView() { |
| 128 | let container = UIView() |
| 129 | collectionView.translatesAutoresizingMaskIntoConstraints = false |
| 130 | container.addSubview(collectionView) |
| 131 | |
| 132 | // Liquid glass on 26 (a 36pt lens is a dot — the pill-sized |
| 133 | // refraction hazard doesn't apply at this scale), blur below. |
| 134 | var config: UIButton.Configuration |
| 135 | if #available(iOS 26.0, *) { |
| 136 | config = .glass() |
| 137 | } else { |
| 138 | config = .plain() |
| 139 | let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) |
| 140 | blur.isUserInteractionEnabled = false |
| 141 | blur.layer.cornerRadius = 18 |
| 142 | blur.clipsToBounds = true |
| 143 | blur.translatesAutoresizingMaskIntoConstraints = false |
| 144 | jumpButton.insertSubview(blur, at: 0) |
| 145 | NSLayoutConstraint.activate([ |
| 146 | blur.topAnchor.constraint(equalTo: jumpButton.topAnchor), |
| 147 | blur.bottomAnchor.constraint(equalTo: jumpButton.bottomAnchor), |
| 148 | blur.leadingAnchor.constraint(equalTo: jumpButton.leadingAnchor), |
| 149 | blur.trailingAnchor.constraint(equalTo: jumpButton.trailingAnchor), |
| 150 | ]) |
| 151 | } |
| 152 | config.image = UIImage(systemName: "chevron.down", |
| 153 | withConfiguration: UIImage.SymbolConfiguration( |
| 154 | pointSize: 14, weight: .semibold)) |
| 155 | jumpButton.configuration = config |
| 156 | jumpButton.translatesAutoresizingMaskIntoConstraints = false |
| 157 | jumpButton.alpha = 0 |
| 158 | jumpButton.accessibilityLabel = "Jump to latest" |
| 159 | jumpButton.addAction(UIAction { [weak self] _ in self?.onJumpTap?() }, |
| 160 | for: .touchUpInside) |
| 161 | container.addSubview(jumpButton) |
| 162 | |
| 163 | NSLayoutConstraint.activate([ |
| 164 | collectionView.topAnchor.constraint(equalTo: container.topAnchor), |
| 165 | collectionView.bottomAnchor.constraint(equalTo: container.bottomAnchor), |
| 166 | collectionView.leadingAnchor.constraint(equalTo: container.leadingAnchor), |
| 167 | collectionView.trailingAnchor.constraint(equalTo: container.trailingAnchor), |
| 168 | jumpButton.widthAnchor.constraint(equalToConstant: 36), |
| 169 | jumpButton.heightAnchor.constraint(equalToConstant: 36), |
| 170 | // Right edge flush with the composer capsule's right edge |
| 171 | // (the cluster's 10pt horizontal padding). |
| 172 | jumpButton.trailingAnchor.constraint( |
| 173 | equalTo: container.trailingAnchor, constant: -10), |
| 174 | // Dialled by eye with Paul (2026-07-21): low over the |
| 175 | // composer's shoulder, right edge flush with the capsule. |
| 176 | jumpButton.bottomAnchor.constraint( |
| 177 | equalTo: container.safeAreaLayoutGuide.bottomAnchor, constant: -4), |
| 178 | ]) |
| 179 | view = container |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | @MainActor |
| 184 | final class Coordinator: NSObject, UICollectionViewDelegate, |
| 185 | UIGestureRecognizerDelegate, FeedScrolling { |
| 186 | let model: ChatFeedModel |
| 187 | let interaction: FeedInteraction |
| 188 | var hooks: FeedHooks |
| 189 | var composerFocused = false |
| 190 | var configKey = ConfigKey(themeName: "", fontSize: 0, isGroup: false) |
| 191 | var onFirstLayout: ((Int) -> Void)? |
| 192 | |
| 193 | private(set) var collectionView: StockFeedCollectionView! |
| 194 | weak var host: UIViewController? |
| 195 | private var dataSource: UICollectionViewDiffableDataSource<Int, FeedItem>! |
| 196 | private var current: [ChatFeedModel.Message] = [] |
| 197 | private var typingNames: [String] = [] |
| 198 | private var cancellables = Set<AnyCancellable>() |
| 199 | private var firstApplyDone = false |
| 200 | fileprivate var needsInitialAnchor = false |
| 201 | private var unreadCutoff: Int64? |
| 202 | private var jumpVisible = false |
| 203 | // The stick-to-bottom latch: from the moment a chat opens (or the |
| 204 | // reader pins to the latest) until they scroll away, every layout |
| 205 | // pass re-lands the bottom. This is the stock container's version of |
| 206 | // the flip's structural promise — estimated row heights settle to |
| 207 | // real ones for several passes after open (worst with images), and |
| 208 | // without the latch the "bottom" drifts out from under the offset. |
| 209 | private var stickToBottom = false |
| 210 | // True while an animated pin is in flight — the latch must not snap |
| 211 | // the animation to its end mid-frame. |
| 212 | private var animatingPin = false |
| 213 | // A publish with prepends held back while a fling is live — flushed |
| 214 | // when the scroll settles (or the reader reaches the loaded top). |
| 215 | private var pendingMessages: [ChatFeedModel.Message]? |
| 216 | private var lastPairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] |
| 217 | |
| 218 | init(model: ChatFeedModel, interaction: FeedInteraction, hooks: FeedHooks, |
| 219 | unreadCutoff: Int64?) { |
| 220 | self.model = model |
| 221 | self.interaction = interaction |
| 222 | self.hooks = hooks |
| 223 | self.unreadCutoff = unreadCutoff |
| 224 | } |
| 225 | |
| 226 | // ---- construction --------------------------------------------------- |
| 227 | |
| 228 | func build() -> UICollectionView { |
| 229 | // A MEASURED layout — no estimated self-sizing anywhere. The |
| 230 | // estimate→real correction machinery was the whole night's |
| 231 | // symptom generator (cells bouncing/flying/snapping into place as |
| 232 | // they materialised, opens drifting off the bottom): rows are |
| 233 | // measured up front against the real width and the layout only |
| 234 | // ever places exact frames, so there is nothing to correct. |
| 235 | let layout = FeedMeasuredLayout() |
| 236 | layout.heightFor = { [weak self] item, width in |
| 237 | self?.height(for: item, width: width) ?? 60 |
| 238 | } |
| 239 | layout.itemAt = { [weak self] index in |
| 240 | guard let self, let ds = self.dataSource else { return nil } |
| 241 | return ds.itemIdentifier(for: IndexPath(item: index, section: 0)) |
| 242 | } |
| 243 | |
| 244 | let cv = StockFeedCollectionView(frame: .zero, collectionViewLayout: layout) |
| 245 | cv.coordinator = self |
| 246 | cv.backgroundColor = .clear |
| 247 | cv.allowsSelection = false |
| 248 | cv.keyboardDismissMode = .interactive |
| 249 | // AUTOMATIC adjustment against the host controller's |
| 250 | // additionalSafeAreaInsets (the measured glass overlays): honest |
| 251 | // UIKit geometry, which is also what tames the system top-edge |
| 252 | // treatment into the Messages-style subtle fade — every manual |
| 253 | // variant (.never + hand insets) fed it degenerate geometry and |
| 254 | // it squashed and stacked rows over half the screen. |
| 255 | cv.contentInsetAdjustmentBehavior = .automatic |
| 256 | // .automatic silently STOPS adjusting an axis whose content fits |
| 257 | // on screen (the scrollable-axes rule) — short chats lost their |
| 258 | // safe-area insets entirely and floated. Always-bounce keeps the |
| 259 | // vertical axis "scrollable" (and is the right feel for a chat |
| 260 | // feed regardless — Messages bounces short threads too). |
| 261 | cv.alwaysBounceVertical = true |
| 262 | cv.delegate = self |
| 263 | // The long-press that opens the message menu is a plain UIKit |
| 264 | // recognizer on the LIST — the one layer that reliably sees |
| 265 | // held touches here. The rig proved the SwiftUI row gesture is |
| 266 | // starved under the scroll view's arbitration (real synthesized |
| 267 | // presses never fired it, though taps flow fine), and |
| 268 | // UIContextMenuInteractions are out entirely (2026-07-21). |
| 269 | let press = UILongPressGestureRecognizer( |
| 270 | target: self, action: #selector(feedLongPressed(_:))) |
| 271 | press.minimumPressDuration = 0.4 |
| 272 | cv.addGestureRecognizer(press) |
| 273 | // The row swipe (rightward quick-reply, leftward times peek) is |
| 274 | // a UIKit pan on the LIST, like the long-press above — NOT a |
| 275 | // SwiftUI DragGesture on rows. The SwiftUI gesture activated on |
| 276 | // ANY direction ≥18pt (its horizontal-only filter ran inside |
| 277 | // the handler, after the claim), and when it won the arbitration |
| 278 | // race against the scroll pan a fast vertical fling died |
| 279 | // entirely: crumbs showed 575pt of vertical travel consumed by |
| 280 | // rowSwipe with the scroll pan never reaching .began — Paul's |
| 281 | // Contributors fast-scroll freeze (2026-07-21). This recognizer |
| 282 | // structurally cannot take vertical motion: it refuses to begin |
| 283 | // unless the touch's velocity is horizontally dominant, so the |
| 284 | // scroll pan owns every vertical gesture by construction. |
| 285 | let swipe = UIPanGestureRecognizer( |
| 286 | target: self, action: #selector(rowSwiped(_:))) |
| 287 | swipe.delegate = self |
| 288 | cv.addGestureRecognizer(swipe) |
| 289 | rowSwipeRecognizer = swipe |
| 290 | #if DEBUG |
| 291 | // Scroll forensics (the freeze hunt above): an OBSERVING second |
| 292 | // target on the scroll pan — state transitions land in the |
| 293 | // crumb file alongside touch arrivals and offset motion, so a |
| 294 | // dead scroll tells us who ate it. |
| 295 | cv.panGestureRecognizer.addTarget(self, action: #selector(panCrumb(_:))) |
| 296 | #endif |
| 297 | collectionView = cv |
| 298 | NotificationCenter.default.addObserver( |
| 299 | self, selector: #selector(keyboardWillShow), |
| 300 | name: UIResponder.keyboardWillShowNotification, object: nil) |
| 301 | NotificationCenter.default.addObserver( |
| 302 | self, selector: #selector(keyboardWillHide), |
| 303 | name: UIResponder.keyboardWillHideNotification, object: nil) |
| 304 | |
| 305 | let registration = UICollectionView.CellRegistration<FeedHostedCell, FeedItem> { |
| 306 | [weak self] cell, _, item in |
| 307 | guard let self else { return } |
| 308 | // The entrance animation touches these; reuse must never |
| 309 | // inherit a mid-animation state. |
| 310 | cell.contentView.alpha = 1 |
| 311 | cell.contentView.transform = .identity |
| 312 | // One row builder for the live cell AND the sizing pass |
| 313 | // (height(for:)) — measured frames and rendered rows can |
| 314 | // never disagree. The typing row clips: it hides by |
| 315 | // COLLAPSING to zero height, and an unclipped hosted view |
| 316 | // paints its dots straight through a zero-height cell (the |
| 317 | // stuck-dots sighting, 2026-07-21). |
| 318 | cell.set(root: self.rowView(for: item), parent: self.host, |
| 319 | clips: item == .typing) |
| 320 | } |
| 321 | dataSource = UICollectionViewDiffableDataSource<Int, FeedItem>(collectionView: cv) { |
| 322 | cv, indexPath, item in |
| 323 | cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) |
| 324 | } |
| 325 | return cv |
| 326 | } |
| 327 | |
| 328 | // ⚠️ Rows are hosted by owned UIHostingControllers with |
| 329 | // safeAreaRegions = [] — NOT UIHostingConfiguration. Hosted |
| 330 | // configuration content PARTICIPATES in the window's safe areas, so |
| 331 | // any cell whose frame intersected the top band (every short chat — |
| 332 | // its rows sit at the top of the screen) had its SwiftUI content |
| 333 | // evicted downward and compressed to fewer lines: truncated bubbles |
| 334 | // overlapping the next row, permanently (the 2026-07-21 short-chat |
| 335 | // bunching; long chats never showed it because their visible cells |
| 336 | // sit at the bottom, and healed on recycle). UIHostingConfiguration |
| 337 | // has no safe-area switch (.ignoresSafeArea() inside it only half |
| 338 | // pushes back); UIHostingController does. Cell content is positioned |
| 339 | // by the LAYOUT's frames; the safe area must never re-arrange it |
| 340 | // from inside. |
| 341 | private func rowView(for item: FeedItem) -> AnyView { |
| 342 | switch item { |
| 343 | case .typing: |
| 344 | let label = configKey.isGroup ? typingSummary(typingNames) : nil |
| 345 | return AnyView( |
| 346 | FeedTypingRow(label: label, themeName: hooks.themeName, |
| 347 | chatFontSize: hooks.chatFontSize)) |
| 348 | case .message(let id): |
| 349 | guard let idx = index(of: id) else { return AnyView(EmptyView()) } |
| 350 | let m = current[idx] |
| 351 | let prev = idx > 0 ? current[idx - 1] : nil |
| 352 | #if DEBUG |
| 353 | if let override = StockChatFeed.testRowOverride { |
| 354 | return override(m, prev) |
| 355 | } |
| 356 | #endif |
| 357 | // No .id(m.id) here — pinning identity per message forced a |
| 358 | // full SwiftUI remount on every cell recycle, the single |
| 359 | // biggest per-row scroll cost on the ladder (~7ms/row). The |
| 360 | // two things the pin bought are held elsewhere: the recycle |
| 361 | // swap runs in a no-animation transaction (FeedHostedCell.set, |
| 362 | // so one message's tree never morph-animates into another's), |
| 363 | // and rows' @State resets when their content identity changes |
| 364 | // (the attachment views' onChange guards in MessageBubble). |
| 365 | return AnyView( |
| 366 | FeedMessageRow(message: m, previous: prev, |
| 367 | hooks: rowHooks(for: m), interaction: interaction)) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // The per-row hooks: unfurl resolutions re-measure their row — the |
| 372 | // measured layout can't see content growing inside a hosted view. |
| 373 | // peekUnfurl makes the RE-measure include the now-cached card. |
| 374 | private func rowHooks(for m: ChatFeedModel.Message) -> FeedHooks { |
| 375 | var h = hooks |
| 376 | let id = m.id |
| 377 | let base = hooks.unfurl |
| 378 | h.unfurl = { [weak self] url in |
| 379 | let card = await base(url) |
| 380 | if card != nil { self?.rowGrewLate(id) } |
| 381 | return card |
| 382 | } |
| 383 | return h |
| 384 | } |
| 385 | |
| 386 | // ---- measured heights ---------------------------------------------- |
| 387 | |
| 388 | private var heights: [FeedItem: CGFloat] = [:] |
| 389 | private var measuredWidth: CGFloat = 0 |
| 390 | |
| 391 | fileprivate func height(for item: FeedItem, width: CGFloat) -> CGFloat { |
| 392 | guard width > 0 else { return 60 } // pre-layout probe; never rendered |
| 393 | if width != measuredWidth { |
| 394 | heights.removeAll() |
| 395 | bubbleRects.removeAll() |
| 396 | measuredWidth = width |
| 397 | } |
| 398 | if let h = heights[item] { return h } |
| 399 | // The permanent typing row measures ZERO while nobody types — |
| 400 | // never the degenerate-height clamp below. |
| 401 | if case .typing = item, typingNames.isEmpty { |
| 402 | heights[item] = 0 |
| 403 | return 0 |
| 404 | } |
| 405 | // Cross-open cache first: heights are pure functions of content |
| 406 | // + layout inputs, and coordinators die with their screens — a |
| 407 | // 200-row chat re-measured on EVERY open (the other half of the |
| 408 | // 2026-07-21 freeze). rowGrewLate overwrites the entry when an |
| 409 | // unfurl lands. |
| 410 | let cacheKey = heightCacheKey(for: item, width: width) |
| 411 | if let cached = cacheKey.flatMap({ FeedHeightCache.store[$0] }) { |
| 412 | heights[item] = cached |
| 413 | return cached |
| 414 | } |
| 415 | // A FRESH hosting controller per measurement, deliberately: a |
| 416 | // persistent sizing host reconfigured across rows swaps the |
| 417 | // hosted tree asynchronously (the .id identity change), and the |
| 418 | // fitting pass could answer with the PREVIOUS row's height — |
| 419 | // tall bubbles then rendered truncated mid-sentence in frames |
| 420 | // borrowed from shorter neighbours. A fresh host has exactly one |
| 421 | // identity to size — and the SAME safeAreaRegions = [] as the |
| 422 | // live cells, so measure and render can never disagree. |
| 423 | let hc = UIHostingController(rootView: rowView(for: item)) |
| 424 | hc.safeAreaRegions = [] |
| 425 | // An unbounded height asks for the IDEAL size at this width — |
| 426 | // a bounded proposal lets SwiftUI Text satisfy it by TRUNCATING |
| 427 | // (tall bubbles measured short, rendered cut off mid-sentence). |
| 428 | let h = ceil(hc.sizeThatFits( |
| 429 | in: CGSize(width: width, height: .greatestFiniteMagnitude)).height) |
| 430 | // A degenerate answer must never produce an invisible row. |
| 431 | let clamped = max(h, 8) |
| 432 | heights[item] = clamped |
| 433 | if let cacheKey { FeedHeightCache.remember(cacheKey, clamped) } |
| 434 | return clamped |
| 435 | } |
| 436 | |
| 437 | private func heightCacheKey(for item: FeedItem, width: CGFloat) -> String? { |
| 438 | #if DEBUG |
| 439 | // Ladder rungs render different content for the same message — |
| 440 | // their heights must never seed the real cross-open cache. |
| 441 | if StockChatFeed.testRowOverride != nil { return nil } |
| 442 | #endif |
| 443 | guard case .message(let id) = item, let idx = index(of: id) else { |
| 444 | return nil // the typing row is stateful; never cached globally |
| 445 | } |
| 446 | let m = current[idx] |
| 447 | let prev = idx > 0 ? current[idx - 1] : nil |
| 448 | let sep = timeSeparator(at: m.at, previous: prev?.at) != nil |
| 449 | let startsRun = sep || !ChatFeedModel.sameSender(prev, m) |
| 450 | // FNV-1a, not String.hashValue: hashValue is seeded per PROCESS, |
| 451 | // so persisted keys silently never matched across real launches. |
| 452 | var textHash: UInt64 = 0xcbf29ce484222325 |
| 453 | for b in m.text.utf8 { textHash = (textHash ^ UInt64(b)) &* 0x100000001b3 } |
| 454 | // Both counts are in the key because both DRAW: replyCount is the |
| 455 | // root's "N Replies" tally, threadReplyCount the same line on a |
| 456 | // quoted reply's card. A row whose thread grows 1 -> 2 gains ~15pt |
| 457 | // it never had, and this cache outlives the reconfigure that |
| 458 | // noticed the change (process-global, and persisted), so without |
| 459 | // them the row keeps its pre-link frame and lays out clipped. |
| 460 | return "\(id)|\(m.at)|\(m.edited)|\(textHash)|" + |
| 461 | "\(m.reactions.count)|\(m.attachments.count)|" + |
| 462 | "\(m.reply != nil)|\(m.action)|\(hooks.foldsAsMuted(m))|" + |
| 463 | "\(m.replyCount)|\(m.threadReplyCount)|" + |
| 464 | "\(sep)|\(startsRun)|\(width)|\(hooks.chatFontSize)|" + |
| 465 | "\(hooks.themeName)|\(hooks.isGroup)" |
| 466 | } |
| 467 | |
| 468 | // A row's content resolved something AFTER its measurement (an unfurl |
| 469 | // card). Re-measure; only a REAL height change touches the layout — |
| 470 | // which also breaks the sizing-pass-fires-unfurl feedback loop. |
| 471 | private func rowGrewLate(_ id: Int64) { |
| 472 | let item = FeedItem.message(id) |
| 473 | guard let cv = collectionView, dataSource != nil, |
| 474 | let old = heights[item] else { return } |
| 475 | heights[item] = nil |
| 476 | // The stale pre-unfurl height must not answer from the global |
| 477 | // cache — drop it so the re-measure (which sees the cached card |
| 478 | // via peekUnfurl) writes the grown value. |
| 479 | if let key = heightCacheKey(for: item, width: measuredWidth) { |
| 480 | FeedHeightCache.store[key] = nil |
| 481 | } |
| 482 | bubbleRects[item] = nil |
| 483 | let new = height(for: item, width: measuredWidth) |
| 484 | guard abs(new - old) > 0.5 else { return } |
| 485 | var snap = dataSource.snapshot() |
| 486 | guard snap.itemIdentifiers.contains(item) else { return } |
| 487 | snap.reconfigureItems([item]) |
| 488 | dataSource.apply(snap, animatingDifferences: false) |
| 489 | cv.collectionViewLayout.invalidateLayout() |
| 490 | // At the tail the latch re-lands the bottom next pass, so a card |
| 491 | // that grew the newest row stays visible (the old unfurl re-pin). |
| 492 | } |
| 493 | |
| 494 | func subscribe() { |
| 495 | // The MAIN FEED's list (feedMessages): the fold minus thread-only |
| 496 | // replies, which live in the reply-thread view. Everything |
| 497 | // downstream — items, `current`, the prev-neighbour grouping and |
| 498 | // separators, newestChanged/pin decisions — runs over this list, |
| 499 | // so a thread reply arriving never scrolls or regroups the feed. |
| 500 | model.$feedMessages |
| 501 | .removeDuplicates() |
| 502 | .sink { [weak self] in self?.setMessages($0) } |
| 503 | .store(in: &cancellables) |
| 504 | model.$typing |
| 505 | .removeDuplicates() |
| 506 | .sink { [weak self] in self?.setTyping($0) } |
| 507 | .store(in: &cancellables) |
| 508 | model.$senderNames.removeDuplicates().map { _ in () } |
| 509 | .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) |
| 510 | .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) |
| 511 | .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) |
| 512 | .dropFirst(4) |
| 513 | .sink { [weak self] in self?.reconfigureAll() } |
| 514 | .store(in: &cancellables) |
| 515 | // An empty chat has no first layout to anchor — end the mount |
| 516 | // measurement when the (empty) history lands instead. |
| 517 | model.$ready |
| 518 | .filter { $0 } |
| 519 | .prefix(1) |
| 520 | .sink { [weak self] _ in |
| 521 | guard let self, !self.firstApplyDone, self.current.isEmpty else { return } |
| 522 | let cb = self.onFirstLayout |
| 523 | DispatchQueue.main.async { cb?(0) } |
| 524 | } |
| 525 | .store(in: &cancellables) |
| 526 | } |
| 527 | |
| 528 | // ---- data ----------------------------------------------------------- |
| 529 | |
| 530 | private func index(of id: Int64) -> Int? { |
| 531 | var lo = 0, hi = current.count - 1 |
| 532 | while lo <= hi { |
| 533 | let mid = (lo + hi) / 2 |
| 534 | if current[mid].id == id { return mid } |
| 535 | if current[mid].id < id { lo = mid + 1 } else { hi = mid - 1 } |
| 536 | } |
| 537 | return nil |
| 538 | } |
| 539 | |
| 540 | private func items(for list: [ChatFeedModel.Message]) -> [FeedItem] { |
| 541 | // The typing row is PERMANENT — always the tail item, measured at |
| 542 | // zero height when nobody's typing. Typing beats arrive |
| 543 | // constantly in agent chats, and when the row came and went the |
| 544 | // churn was a structural apply every few seconds (batch updates |
| 545 | // stacked cells; clean reloads read as scroll delay). A |
| 546 | // reconfigure of one zero-or-36pt row costs nothing. |
| 547 | list.map { .message($0.id) } + [.typing] |
| 548 | } |
| 549 | |
| 550 | private func setMessages(_ new: [ChatFeedModel.Message]) { |
| 551 | // Prepends compensate the offset, and an offset write kills a live |
| 552 | // fling (and fights a finger) — so while either is in flight, a |
| 553 | // prepend-carrying publish waits. Appends/edits flow through |
| 554 | // unchanged applies regardless (they never move the viewport here). |
| 555 | if let cv = collectionView, cv.isDecelerating || cv.isTracking, |
| 556 | let newOldest = new.first?.id, let oldOldest = current.first?.id, |
| 557 | newOldest < oldOldest { |
| 558 | pendingMessages = new |
| 559 | return |
| 560 | } |
| 561 | pendingMessages = nil |
| 562 | applyMessages(new) |
| 563 | } |
| 564 | |
| 565 | fileprivate func flushPending() { |
| 566 | // At rest ONLY — a structural reload racing the deceleration |
| 567 | // animator composites cells from two different offsets (the |
| 568 | // ghost-doubled rows at the top edge). scrollSettled re-fires |
| 569 | // this the moment the scroll comes to rest. |
| 570 | guard let cv = collectionView, !cv.isTracking, !cv.isDecelerating, |
| 571 | let pending = pendingMessages else { return } |
| 572 | pendingMessages = nil |
| 573 | applyMessages(pending) |
| 574 | } |
| 575 | |
| 576 | private func applyMessages(_ new: [ChatFeedModel.Message]) { |
| 577 | let oldNewest = current.last?.id |
| 578 | let oldOldest = current.first?.id |
| 579 | current = new |
| 580 | let newestChanged = new.last?.id != oldNewest |
| 581 | // A typer's arriving message retires their dots IN THIS apply: |
| 582 | // items and heights change once, so the pin scroll + entrance |
| 583 | // rise + dot removal play as one motion. Collapsing on the |
| 584 | // separate typing-off event a beat later was the pane jump |
| 585 | // (Paul, 2026-07-21). The model's own clear follows and no-ops. |
| 586 | if newestChanged, let last = new.last, !last.mine, |
| 587 | typingNames.contains(last.name) { |
| 588 | typingNames.removeAll { $0 == last.name } |
| 589 | heights[.typing] = nil |
| 590 | } |
| 591 | let newestIsMine = new.last?.mine == true |
| 592 | let prepended = new.first?.id ?? Int64.max |
| 593 | apply(hasPrepends: oldOldest.map { prepended < $0 } ?? false, |
| 594 | newestChanged: newestChanged, newestIsMine: newestIsMine) |
| 595 | } |
| 596 | |
| 597 | private func setTyping(_ names: [String]) { |
| 598 | let appeared = typingNames.isEmpty && !names.isEmpty |
| 599 | let cleared = !typingNames.isEmpty && names.isEmpty |
| 600 | typingNames = names |
| 601 | guard let cv = collectionView, dataSource != nil, firstApplyDone else { return } |
| 602 | let wasAtBottom = gapFromBottom() < 44 |
| 603 | let reduceMotion = UIAccessibility.isReduceMotionEnabled |
| 604 | let refresh = { [weak self] in |
| 605 | guard let self, let cv = self.collectionView, |
| 606 | self.dataSource != nil else { return } |
| 607 | self.heights[.typing] = nil |
| 608 | var snap = self.dataSource.snapshot() |
| 609 | guard snap.itemIdentifiers.contains(.typing) else { return } |
| 610 | snap.reconfigureItems([.typing]) |
| 611 | self.dataSource.apply(snap, animatingDifferences: false) |
| 612 | cv.collectionViewLayout.invalidateLayout() |
| 613 | } |
| 614 | // The dots bow out with a quick fade before the row collapses — |
| 615 | // the hard blink next to the arrival's rise-in read as a snap. |
| 616 | // (The height cache keeps the row's height until refresh runs, |
| 617 | // so an interleaved arrival can't collapse it mid-fade.) |
| 618 | let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, |
| 619 | section: 0) |
| 620 | if cleared, !reduceMotion, let cell = cv.cellForItem(at: typingIP) { |
| 621 | UIView.animate(withDuration: 0.12, animations: { |
| 622 | cell.contentView.alpha = 0 |
| 623 | }, completion: { _ in |
| 624 | // The registration's reuse reset also restores alpha; |
| 625 | // this covers the cell staying in place. |
| 626 | cell.contentView.alpha = 1 |
| 627 | refresh() |
| 628 | }) |
| 629 | } else { |
| 630 | refresh() |
| 631 | } |
| 632 | // The bubble appearing while you're at the tail (or composing — |
| 633 | // you're at the conversation's tail even if the keyboard hides |
| 634 | // it) should be seen; reading history is never yanked. |
| 635 | if appeared, wasAtBottom || composerFocused, !cv.isTracking { |
| 636 | pinToBottom(animated: !reduceMotion) |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | // Non-animated applies throughout — the flipped rounds proved batch |
| 641 | // animations under frequent applies strand cells mid-fade and mirror |
| 642 | // directions; reveals are done by scrolling, not by item animation. |
| 643 | private func apply(hasPrepends: Bool, newestChanged: Bool, newestIsMine: Bool) { |
| 644 | guard dataSource != nil else { return } |
| 645 | let old = dataSource.snapshot().itemIdentifiers |
| 646 | var snap = NSDiffableDataSourceSnapshot<Int, FeedItem>() |
| 647 | snap.appendSections([0]) |
| 648 | let new = items(for: current) |
| 649 | snap.appendItems(new) |
| 650 | let oldSet = Set(old) |
| 651 | var pairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] |
| 652 | pairs.reserveCapacity(current.count) |
| 653 | var changed: [FeedItem] = [] |
| 654 | for (i, m) in current.enumerated() { |
| 655 | let pair = (msg: m, prevID: i > 0 ? current[i - 1].id : nil) |
| 656 | pairs[m.id] = pair |
| 657 | if oldSet.contains(.message(m.id)), let last = lastPairs[m.id], |
| 658 | last.msg != pair.msg || last.prevID != pair.prevID { |
| 659 | changed.append(.message(m.id)) |
| 660 | } |
| 661 | } |
| 662 | lastPairs = pairs |
| 663 | for item in changed { heights[item] = nil; bubbleRects[item] = nil } |
| 664 | snap.reconfigureItems(changed) |
| 665 | |
| 666 | guard firstApplyDone else { |
| 667 | dataSource.apply(snap, animatingDifferences: false) |
| 668 | if !current.isEmpty { |
| 669 | firstApplyDone = true |
| 670 | needsInitialAnchor = true |
| 671 | collectionView.setNeedsLayout() |
| 672 | } |
| 673 | return |
| 674 | } |
| 675 | |
| 676 | let wasAtBottom = gapFromBottom() < 44 |
| 677 | // NO structural change goes through batch updates — not prepends, |
| 678 | // not deletions, not appends, not the typing row: performBatch on |
| 679 | // a custom layout computes frames against a half-transitioned |
| 680 | // item list (numberOfItems old, identifiers new), and any window |
| 681 | // where frames and cells disagree eventually shows up as stacked |
| 682 | // rows (the short-chat report: typing churn in agent chats is the |
| 683 | // busiest tail change there is). A clean reload rebuilds the |
| 684 | // visible handful — cheap, off the per-frame path — and the |
| 685 | // batch path survives only for reconfigure-only applies, which |
| 686 | // move nothing. |
| 687 | let structural = old.count != new.count || oldSet != Set(new) |
| 688 | if structural { |
| 689 | let beforeH = collectionView.contentSize.height |
| 690 | dataSource.applySnapshotUsingReloadData(snap) |
| 691 | collectionView.layoutIfNeeded() |
| 692 | if hasPrepends, !wasAtBottom { |
| 693 | // Older pages above: hold the reader's rows exactly still |
| 694 | // by the REAL laid-out growth. |
| 695 | let delta = collectionView.contentSize.height - beforeH |
| 696 | if delta > 0 { |
| 697 | collectionView.contentOffset.y += delta |
| 698 | } |
| 699 | } else if newestChanged, wasAtBottom || newestIsMine || composerFocused { |
| 700 | // Reveal the new bottom: your own send from anywhere, any |
| 701 | // growth while composing, and ordinary arrivals only when |
| 702 | // the reader is already at the tail — reading history |
| 703 | // never yanks. |
| 704 | let reduceMotion = UIAccessibility.isReduceMotionEnabled |
| 705 | pinToBottom(animated: !reduceMotion) |
| 706 | if !reduceMotion, wasAtBottom { |
| 707 | animateArrivalEntrance() |
| 708 | } |
| 709 | } else if wasAtBottom { |
| 710 | pinToBottom(animated: false) |
| 711 | } |
| 712 | } else { |
| 713 | dataSource.apply(snap, animatingDifferences: false) |
| 714 | } |
| 715 | updateJumpVisible() |
| 716 | } |
| 717 | |
| 718 | // The arrival beat, as OUR OWN cell animation (never a batch |
| 719 | // update's): the newest row rises from beneath the composer with a |
| 720 | // fade. Runs outside any layout pass; a missing cell just means the |
| 721 | // pin scroll will bring it in plainly. |
| 722 | private func animateArrivalEntrance() { |
| 723 | guard let cv = collectionView else { return } |
| 724 | cv.layoutIfNeeded() |
| 725 | let count = cv.numberOfItems(inSection: 0) |
| 726 | // The LAST item is the permanent typing row — the newest message |
| 727 | // sits one above it. Animating count-1 "worked" invisibly (a |
| 728 | // zero-height cell), so every arrival just snapped in (Paul, |
| 729 | // 2026-07-21). |
| 730 | guard count >= 2, |
| 731 | let cell = cv.cellForItem(at: IndexPath(item: count - 2, section: 0)) |
| 732 | else { return } |
| 733 | cell.contentView.alpha = 0 |
| 734 | cell.contentView.transform = CGAffineTransform(translationX: 0, y: 14) |
| 735 | UIView.animate(withDuration: 0.32, delay: 0, |
| 736 | usingSpringWithDamping: 0.85, initialSpringVelocity: 0, |
| 737 | options: [.allowUserInteraction]) { |
| 738 | cell.contentView.alpha = 1 |
| 739 | cell.contentView.transform = .identity |
| 740 | } |
| 741 | } |
| 742 | |
| 743 | func reconfigureAll() { |
| 744 | guard dataSource != nil else { return } |
| 745 | heights.removeAll() |
| 746 | bubbleRects.removeAll() |
| 747 | var snap = dataSource.snapshot() |
| 748 | snap.reconfigureItems(snap.itemIdentifiers) |
| 749 | dataSource.apply(snap, animatingDifferences: false) |
| 750 | collectionView?.collectionViewLayout.invalidateLayout() |
| 751 | } |
| 752 | |
| 753 | // ---- scrolling ------------------------------------------------------ |
| 754 | |
| 755 | private func gapFromBottom() -> CGFloat { |
| 756 | guard let cv = collectionView else { return 0 } |
| 757 | let bottom = cv.contentSize.height + cv.adjustedContentInset.bottom |
| 758 | return bottom - (cv.contentOffset.y + cv.bounds.height) |
| 759 | } |
| 760 | |
| 761 | func pinToBottom(animated: Bool) { |
| 762 | guard let cv = collectionView else { return } |
| 763 | stickToBottom = true |
| 764 | let target = max(-cv.adjustedContentInset.top, |
| 765 | cv.contentSize.height + cv.adjustedContentInset.bottom |
| 766 | - cv.bounds.height) |
| 767 | guard abs(cv.contentOffset.y - target) > 0.5 else { return } |
| 768 | if animated { |
| 769 | // Our own curve (the old feed's 0.15s ease-out), and a |
| 770 | // completion that ALWAYS clears the latch guard — UIKit's |
| 771 | // setContentOffset(animated:) never calls its end delegate |
| 772 | // for a zero-distance scroll, which stranded the guard and |
| 773 | // killed the caret. |
| 774 | animatingPin = true |
| 775 | UIView.animate(withDuration: 0.15, delay: 0, options: [.curveEaseOut]) { |
| 776 | cv.contentOffset = CGPoint(x: 0, y: target) |
| 777 | } completion: { _ in |
| 778 | self.animatingPin = false |
| 779 | } |
| 780 | } else { |
| 781 | cv.contentOffset = CGPoint(x: 0, y: target) |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | func scrollTo(id: Int64) { |
| 786 | stickToBottom = false |
| 787 | guard let ip = dataSource.indexPath(for: .message(id)) else { return } |
| 788 | // Animated only for NEAR jumps; far targets snap (estimated rows |
| 789 | // recalibrate on the way and animated long hops land rough). |
| 790 | let count = collectionView.numberOfItems(inSection: 0) |
| 791 | collectionView.scrollToItem(at: ip, at: .centeredVertically, |
| 792 | animated: count - ip.item <= 60) |
| 793 | } |
| 794 | |
| 795 | func scrollViewDidScroll(_ scrollView: UIScrollView) { |
| 796 | #if DEBUG |
| 797 | ScrollCrumbs.throttled("offset", |
| 798 | "offset y=\(Int(scrollView.contentOffset.y))") |
| 799 | #endif |
| 800 | updateJumpVisible() |
| 801 | } |
| 802 | |
| 803 | #if DEBUG |
| 804 | @objc func panCrumb(_ g: UIPanGestureRecognizer) { |
| 805 | let v = g.velocity(in: g.view) |
| 806 | let t = g.translation(in: g.view) |
| 807 | ScrollCrumbs.log("pan state=\(g.state.rawValue) " + |
| 808 | "t=(\(Int(t.x)),\(Int(t.y))) v=(\(Int(v.x)),\(Int(v.y)))") |
| 809 | } |
| 810 | |
| 811 | // The measure-vs-render audit (the 2026-07-21 separator-overlap |
| 812 | // screenshot): any visible cell whose laid-out height disagrees |
| 813 | // with its content's ideal height is the bug — crumb it with its |
| 814 | // cache key so a device pull names the row and the mismatch. |
| 815 | func auditHeights() { |
| 816 | guard let cv = collectionView, dataSource != nil else { return } |
| 817 | for cell in cv.visibleCells { |
| 818 | guard let ip = cv.indexPath(for: cell), |
| 819 | let item = dataSource.itemIdentifier(for: ip), |
| 820 | case .message(let id) = item, |
| 821 | let hosted = cell.contentView.subviews.first else { continue } |
| 822 | let ideal = hosted.sizeThatFits(CGSize( |
| 823 | width: cv.bounds.width, height: .greatestFiniteMagnitude)).height |
| 824 | if abs(cell.frame.height - ideal) > 3 { |
| 825 | ScrollCrumbs.log("HEIGHT MISMATCH id=\(id) " + |
| 826 | "cell=\(Int(cell.frame.height)) ideal=\(Int(ideal)) " + |
| 827 | "key=\(heightCacheKey(for: item, width: measuredWidth) ?? "nil")") |
| 828 | } |
| 829 | } |
| 830 | } |
| 831 | #endif |
| 832 | |
| 833 | // Jump-to-latest visibility, from scroll AND from content changes — |
| 834 | // a page landing or a delete shifts the gap without any scroll event, |
| 835 | // which left the caret stale. |
| 836 | private func updateJumpVisible() { |
| 837 | let show = gapFromBottom() > 400 |
| 838 | if show != jumpVisible { |
| 839 | jumpVisible = show |
| 840 | guard let button = (host as? FeedHostController)?.jumpButton else { return } |
| 841 | UIView.animate(withDuration: 0.12, delay: 0, |
| 842 | options: [.beginFromCurrentState, .allowUserInteraction]) { |
| 843 | button.alpha = show ? 1 : 0 |
| 844 | } |
| 845 | } |
| 846 | } |
| 847 | |
| 848 | // A mid-fling tap must redirect the scroll, not queue behind it: |
| 849 | // kill the deceleration first, then pin. |
| 850 | fileprivate func jumpTapped() { |
| 851 | if let cv = collectionView, cv.isDecelerating { |
| 852 | cv.setContentOffset(cv.contentOffset, animated: false) |
| 853 | } |
| 854 | pinToBottom(animated: true) |
| 855 | } |
| 856 | |
| 857 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { |
| 858 | // The reader took the wheel: nothing holds the bottom against |
| 859 | // their finger. |
| 860 | stickToBottom = false |
| 861 | animatingPin = false |
| 862 | } |
| 863 | |
| 864 | func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { |
| 865 | animatingPin = false |
| 866 | } |
| 867 | |
| 868 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollSettled() } |
| 869 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) { |
| 870 | if !willDecelerate { scrollSettled() } |
| 871 | } |
| 872 | |
| 873 | private func scrollSettled() { |
| 874 | flushPending() |
| 875 | // Coming to rest on the tail re-arms the latch — the bottom is |
| 876 | // sticky when you're on it (iMessage feel), free when you're not. |
| 877 | if gapFromBottom() < 44 { stickToBottom = true } |
| 878 | #if DEBUG |
| 879 | auditHeights() |
| 880 | #endif |
| 881 | } |
| 882 | |
| 883 | // ---- long-press menu (OWNED — see FeedMenu.swift) ------------------ |
| 884 | |
| 885 | // Fired from the ROW's own SwiftUI long-press (hooks.presentMenu): |
| 886 | // every system context-menu route proved broken for bubbles on |
| 887 | // device (2026-07-21 — the item pipeline platters the whole CELL, |
| 888 | // manual interactions never fire), so the menu is drawn by |
| 889 | // FeedMenuOverlay: the bubble's own pixels float in place, the |
| 890 | // reaction palette above, a labelled card below — the web app's |
| 891 | // long-press menu. Structure mirrors MessageBubble's menuItems |
| 892 | // (reaction palette → Reply/Copy/Edit → Mute/Report → mine-only |
| 893 | // Delete); keep the two in step. |
| 894 | private weak var openMenu: FeedMenuOverlay? |
| 895 | |
| 896 | // ---- the row swipe (quick-reply / times peek) ---------------------- |
| 897 | |
| 898 | private weak var rowSwipeRecognizer: UIPanGestureRecognizer? |
| 899 | // The message under the finger when the swipe began (reply target). |
| 900 | private var swipeTargetID: Int64? |
| 901 | |
| 902 | // The structural fix for the stolen-scroll freeze: this pan only |
| 903 | // BEGINS on horizontally-dominant motion, so a vertical fling can |
| 904 | // never be claimed away from the scroll pan. |
| 905 | func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { |
| 906 | guard g === rowSwipeRecognizer, |
| 907 | let pan = g as? UIPanGestureRecognizer, let cv = collectionView |
| 908 | else { return true } |
| 909 | let v = pan.velocity(in: cv) |
| 910 | return abs(v.x) > abs(v.y) * 1.6 && abs(v.x) > 60 |
| 911 | } |
| 912 | |
| 913 | @objc private func rowSwiped(_ g: UIPanGestureRecognizer) { |
| 914 | guard let cv = collectionView else { return } |
| 915 | #if DEBUG |
| 916 | ScrollCrumbs.throttled("rowPan", |
| 917 | "rowPan state=\(g.state.rawValue) " + |
| 918 | "t=(\(Int(g.translation(in: cv).x)),\(Int(g.translation(in: cv).y)))") |
| 919 | #endif |
| 920 | switch g.state { |
| 921 | case .began: |
| 922 | swipeTargetID = nil |
| 923 | if let ip = cv.indexPathForItem(at: g.location(in: cv)), |
| 924 | case .message(let id) = dataSource.itemIdentifier(for: ip), |
| 925 | let idx = index(of: id), !current[idx].action, |
| 926 | !hooks.foldsAsMuted(current[idx]) { |
| 927 | swipeTargetID = id |
| 928 | } |
| 929 | fallthrough |
| 930 | case .changed: |
| 931 | let x = g.translation(in: cv).x |
| 932 | if x > 0, let id = swipeTargetID { |
| 933 | // Rightward: the bubble follows the finger, arming |
| 934 | // quick-reply at the threshold (haptic once). |
| 935 | let drag = min(72, x * 0.6) |
| 936 | interaction.replyDrag[id] = drag |
| 937 | interaction.timesReveal = 0 |
| 938 | if drag >= 40, !interaction.replyArmed { |
| 939 | interaction.replyArmed = true |
| 940 | UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| 941 | } |
| 942 | } else if x < 0 { |
| 943 | // Leftward: peek the times gutter. |
| 944 | interaction.timesReveal = min(FeedMessageRow.timesGutter, -x) |
| 945 | if let id = swipeTargetID { interaction.replyDrag[id] = 0 } |
| 946 | } |
| 947 | case .ended, .cancelled, .failed: |
| 948 | if let id = swipeTargetID, (interaction.replyDrag[id] ?? 0) >= 40, |
| 949 | let idx = index(of: id) { |
| 950 | hooks.startReply(current[idx]) |
| 951 | } |
| 952 | interaction.replyArmed = false |
| 953 | let id = swipeTargetID |
| 954 | swipeTargetID = nil |
| 955 | withAnimation(.spring(response: 0.35, dampingFraction: 0.72)) { |
| 956 | if let id { interaction.replyDrag[id] = 0 } |
| 957 | interaction.timesReveal = 0 |
| 958 | } |
| 959 | default: |
| 960 | break |
| 961 | } |
| 962 | } |
| 963 | |
| 964 | @objc private func feedLongPressed(_ g: UILongPressGestureRecognizer) { |
| 965 | menuCrumb("press state=\(g.state.rawValue)") |
| 966 | guard g.state == .began, let cv = collectionView else { return } |
| 967 | guard let ip = cv.indexPathForItem(at: g.location(in: cv)) else { |
| 968 | menuCrumb("no indexPath at \(g.location(in: cv))"); return |
| 969 | } |
| 970 | guard case .message(let id) = dataSource.itemIdentifier(for: ip) else { |
| 971 | menuCrumb("item at \(ip.item) not a message"); return |
| 972 | } |
| 973 | menuCrumb("press → message \(id)") |
| 974 | presentMenu(for: id) |
| 975 | } |
| 976 | |
| 977 | #if DEBUG |
| 978 | // The silent-bail autopsy (2026-07-21): sim-green, device-dead — |
| 979 | // every step writes a breadcrumb pullable over devicectl. |
| 980 | private var menuCrumbs: [String] = [] |
| 981 | func menuCrumb(_ line: String) { |
| 982 | menuCrumbs.append(line) |
| 983 | let dir = FileManager.default.urls(for: .libraryDirectory, |
| 984 | in: .userDomainMask)[0] |
| 985 | .appendingPathComponent("Logs", isDirectory: true) |
| 986 | try? FileManager.default.createDirectory( |
| 987 | at: dir, withIntermediateDirectories: true) |
| 988 | try? menuCrumbs.suffix(40).joined(separator: "\n") |
| 989 | .write(to: dir.appendingPathComponent("menu-debug.txt"), |
| 990 | atomically: true, encoding: .utf8) |
| 991 | } |
| 992 | #else |
| 993 | func menuCrumb(_ line: String) {} |
| 994 | #endif |
| 995 | |
| 996 | // The bubble's rect within its row, measured the same deterministic |
| 997 | // way row heights are (the hosted SwiftUI tree exposes NO UIKit |
| 998 | // bubble view on device). bubbleRow's geometry constants: 12pt side |
| 999 | // padding + the 40pt minimum spacer on the far side; 3pt bottom |
| 1000 | // padding, bubble bottom-anchored in the row. Cached like heights. |
| 1001 | private var bubbleRects: [FeedItem: CGRect] = [:] |
| 1002 | |
| 1003 | func bubbleRect(for item: FeedItem) -> CGRect? { |
| 1004 | if let hit = bubbleRects[item] { return hit } |
| 1005 | guard case .message(let id) = item, let idx = index(of: id), |
| 1006 | measuredWidth > 64 else { return nil } |
| 1007 | let m = current[idx] |
| 1008 | guard !m.action, !hooks.foldsAsMuted(m) else { return nil } |
| 1009 | let prev = idx > 0 ? current[idx - 1] : nil |
| 1010 | let sep = timeSeparator(at: m.at, previous: prev?.at) |
| 1011 | let startsRun = sep != nil || !ChatFeedModel.sameSender(prev, m) |
| 1012 | let bubble = MessageBubble( |
| 1013 | message: m, showSender: hooks.isGroup && startsRun, |
| 1014 | senderName: hooks.senderName(m), |
| 1015 | theme: .named(hooks.themeName), |
| 1016 | blobSource: hooks.blobSource, |
| 1017 | unfurl: hooks.unfurl, |
| 1018 | peekUnfurl: hooks.peekUnfurl) |
| 1019 | .environment(\.chatFontSize, hooks.chatFontSize) |
| 1020 | // Mention chips show the viewer's name for the person — the |
| 1021 | // measured height must see the same text the row renders. |
| 1022 | .environment(\.mentionIndex, hooks.mentionIndex()) |
| 1023 | let hc = UIHostingController(rootView: bubble) |
| 1024 | hc.safeAreaRegions = [] |
| 1025 | let size = hc.sizeThatFits(in: CGSize( |
| 1026 | width: measuredWidth - 64, height: .greatestFiniteMagnitude)) |
| 1027 | let rowH = height(for: item, width: measuredWidth) |
| 1028 | guard size.width > 1, size.height > 1, size.height <= rowH |
| 1029 | else { return nil } |
| 1030 | let rect = CGRect(x: m.mine ? measuredWidth - 12 - size.width : 12, |
| 1031 | y: rowH - 3 - size.height, |
| 1032 | width: size.width, height: size.height) |
| 1033 | bubbleRects[item] = rect |
| 1034 | return rect |
| 1035 | } |
| 1036 | |
| 1037 | func presentMenu(for id: Int64) { |
| 1038 | guard openMenu == nil else { menuCrumb("bail: menu open"); return } |
| 1039 | guard let idx = index(of: id) else { menuCrumb("bail: no index"); return } |
| 1040 | guard let container = host?.view else { menuCrumb("bail: no host"); return } |
| 1041 | let m = current[idx] |
| 1042 | guard !m.action, !hooks.foldsAsMuted(m) else { |
| 1043 | menuCrumb("bail: action/muted"); return |
| 1044 | } |
| 1045 | let actions = hooks.actions(m) |
| 1046 | |
| 1047 | guard let ip = dataSource.indexPath(for: .message(id)), |
| 1048 | let cell = collectionView.cellForItem(at: ip), |
| 1049 | let hosted = cell.contentView.subviews.first else { |
| 1050 | menuCrumb("bail: no cell/hosted"); return |
| 1051 | } |
| 1052 | guard let rect = bubbleRect(for: .message(id)) else { |
| 1053 | menuCrumb("bail: no bubbleRect (w=\(measuredWidth) " + |
| 1054 | "rowH=\(height(for: .message(id), width: measuredWidth)))") |
| 1055 | return |
| 1056 | } |
| 1057 | menuCrumb("presenting id=\(id) rect=\(rect)") |
| 1058 | let image = UIGraphicsImageRenderer(bounds: rect).image { _ in |
| 1059 | hosted.drawHierarchy(in: hosted.bounds, afterScreenUpdates: false) |
| 1060 | } |
| 1061 | let frameInHost = cell.contentView.convert(rect, to: container) |
| 1062 | |
| 1063 | var items: [FeedMenuOverlay.Item] = [] |
| 1064 | if let reply = actions.onReply { |
| 1065 | items.append(.init(title: "Reply…", |
| 1066 | icon: "arrowshape.turn.up.left", handler: reply)) |
| 1067 | } |
| 1068 | // Right after Reply, same gating (thread-pane spec §3.5): the |
| 1069 | // same reply glyph, the label differentiates. |
| 1070 | if let inThread = actions.onReplyInThread { |
| 1071 | items.append(.init(title: "Reply in thread…", |
| 1072 | icon: "arrowshape.turn.up.left", handler: inThread)) |
| 1073 | } |
| 1074 | // Only where there's an inline chain under this message to move |
| 1075 | // (ChatFeedModel.inlineChainIDs decides; the action is nil |
| 1076 | // otherwise). Arrows folding toward a line — the web's ICON_FOLD_IN. |
| 1077 | if let convert = actions.onConvertToThread { |
| 1078 | items.append(.init(title: "Convert to thread…", |
| 1079 | icon: "arrow.down.right.and.arrow.up.left", handler: convert)) |
| 1080 | } |
| 1081 | if let copy = actions.onCopy { |
| 1082 | items.append(.init(title: "Copy", icon: "doc.on.doc", handler: copy)) |
| 1083 | } |
| 1084 | if actions.canEdit, let edit = actions.onEdit { |
| 1085 | items.append(.init(title: "Edit…", icon: "pencil", handler: edit)) |
| 1086 | } |
| 1087 | if let mute = actions.onMutePerson { |
| 1088 | items.append(.init(title: actions.mutePersonTitle, |
| 1089 | icon: "bell.slash", handler: mute)) |
| 1090 | } |
| 1091 | if let report = actions.onReport { |
| 1092 | items.append(.init(title: "Report", icon: "flag", handler: report)) |
| 1093 | } |
| 1094 | if m.mine, let delete = actions.onDelete { |
| 1095 | items.append(.init(title: "Delete…", icon: "trash", |
| 1096 | destructive: true, handler: delete)) |
| 1097 | } |
| 1098 | |
| 1099 | UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| 1100 | let overlay = FeedMenuOverlay( |
| 1101 | container: container, bubbleImage: image, bubbleFrame: frameInHost, |
| 1102 | mine: m.mine, |
| 1103 | reactions: actions.onReact != nil ? ChatFeedModel.allowedReactions : [], |
| 1104 | onReact: actions.onReact, items: items, |
| 1105 | onDismiss: { [weak self] in self?.openMenu = nil }) |
| 1106 | container.addSubview(overlay) |
| 1107 | openMenu = overlay |
| 1108 | } |
| 1109 | |
| 1110 | // Scroll-back: nearing the top of the loaded rows pages more in (the |
| 1111 | // model's cooldown paces it). A buffered page flushes as the reader |
| 1112 | // arrives, momentum or not — a blank top is worse than a settle. |
| 1113 | func collectionView(_ collectionView: UICollectionView, |
| 1114 | willDisplay cell: UICollectionViewCell, |
| 1115 | forItemAt indexPath: IndexPath) { |
| 1116 | guard firstApplyDone else { return } |
| 1117 | if indexPath.item < 8 { |
| 1118 | // No flushing from here, deferred or otherwise: pages apply |
| 1119 | // at scroll-settle only (flushPending's own guard). Reaching |
| 1120 | // the loaded top mid-fling shows the current top for a beat; |
| 1121 | // the page lands the moment the scroll rests. |
| 1122 | let model = model |
| 1123 | Task { await model.loadOlder() } |
| 1124 | } |
| 1125 | } |
| 1126 | |
| 1127 | // ---- first layout --------------------------------------------------- |
| 1128 | |
| 1129 | // Runs INSIDE the first real layout pass: offset writes only (the |
| 1130 | // no-forced-layout contract from the flipped container's crash). |
| 1131 | fileprivate func performInitialAnchor() { |
| 1132 | needsInitialAnchor = false |
| 1133 | guard let cv = collectionView else { return } |
| 1134 | if let cutoff = unreadCutoff, |
| 1135 | let target = current.first(where: { $0.at > cutoff && !$0.mine }), |
| 1136 | let ip = dataSource.indexPath(for: .message(target.id)) { |
| 1137 | // First unread at the TOP — reading continues where it left |
| 1138 | // off. Estimated frames are fine for the first beat; one |
| 1139 | // exact correction lands after the pass. |
| 1140 | if let attr = cv.layoutAttributesForItem(at: ip) { |
| 1141 | let maxY = max(-cv.adjustedContentInset.top, |
| 1142 | cv.contentSize.height + cv.adjustedContentInset.bottom |
| 1143 | - cv.bounds.height) |
| 1144 | cv.contentOffset.y = min(max(attr.frame.minY - cv.adjustedContentInset.top, |
| 1145 | -cv.adjustedContentInset.top), maxY) |
| 1146 | } |
| 1147 | DispatchQueue.main.async { [weak self] in |
| 1148 | guard let self, |
| 1149 | let ip = self.dataSource.indexPath(for: .message(target.id)) |
| 1150 | else { return } |
| 1151 | self.collectionView.scrollToItem(at: ip, at: .top, animated: false) |
| 1152 | } |
| 1153 | } else { |
| 1154 | // The latch (see stickToBottom) keeps re-landing the bottom |
| 1155 | // as estimated heights settle to real ones over the next |
| 1156 | // passes — the one-shot pin this replaced drifted on |
| 1157 | // image-heavy threads and opens landed short of the tail. |
| 1158 | pinToBottom(animated: false) |
| 1159 | } |
| 1160 | unreadCutoff = nil |
| 1161 | let count = current.count |
| 1162 | let cb = onFirstLayout |
| 1163 | DispatchQueue.main.async { cb?(count) } |
| 1164 | #if DEBUG |
| 1165 | DispatchQueue.main.async { [weak self] in self?.dumpHeights() } |
| 1166 | #endif |
| 1167 | } |
| 1168 | |
| 1169 | // ---- insets --------------------------------------------------------- |
| 1170 | |
| 1171 | private var topOverlay: CGFloat = 0 |
| 1172 | private var bottomOverlay: CGFloat = 0 |
| 1173 | private var keyboardUp = false |
| 1174 | |
| 1175 | fileprivate func refreshInsets() { updateInsets(force: true) } |
| 1176 | |
| 1177 | fileprivate func setOverlays(top: CGFloat, bottom: CGFloat) { |
| 1178 | // Deadband, not equality: the glass overlays are measured by |
| 1179 | // SwiftUI probes and arrive with sub-point jitter across renders. |
| 1180 | // Every inset write invalidates the WHOLE compositional layout — |
| 1181 | // jitter-driven writes mid-scroll were the correction storm that |
| 1182 | // read as bouncing (animated) and then jank (suppressed). |
| 1183 | guard abs(top - topOverlay) > 0.5 || abs(bottom - bottomOverlay) > 0.5 else { return } |
| 1184 | topOverlay = top |
| 1185 | bottomOverlay = bottom |
| 1186 | updateInsets() |
| 1187 | } |
| 1188 | |
| 1189 | fileprivate func windowChanged() { updateInsets() } |
| 1190 | |
| 1191 | // The gap between the newest message and the composer capsule must |
| 1192 | // read IDENTICAL with the keyboard up or down (Paul, 2026-07-21) — |
| 1193 | // but the two states arrive via different machinery (home-bar |
| 1194 | // handoff at rest, UIKit keyboard avoidance when up), so each gets |
| 1195 | // its own trim. Both target ~10pt above the glass. (History: these |
| 1196 | // were dialled to 38/0 on device — 38 turned out to be a safe-area |
| 1197 | // DOUBLE-COUNT being cancelled by eye, see below; 4/0 with the |
| 1198 | // double-count gone lands the identical geometry.) |
| 1199 | private static let composerTrimResting: CGFloat = 4 |
| 1200 | private static let composerTrimKeyboard: CGFloat = 0 |
| 1201 | |
| 1202 | // Overlay insets, declared as ADDITIONAL safe areas on the host |
| 1203 | // controller. The host already INHERITS the window's safe areas |
| 1204 | // (status bar/island above, home bar below) from its position, so |
| 1205 | // only the glass overlays are added here — adding safeTop/safeBottom |
| 1206 | // again was the double-count that floated short chats a quarter |
| 1207 | // screen down (long chats hid it: nobody rests at their top, and the |
| 1208 | // bottom's extra 34 was absorbed by the old dialled trim). SwiftUI |
| 1209 | // still owns the keyboard-driven FRAME shrink; automatic adjustment |
| 1210 | // turns these into content insets. |
| 1211 | private func updateInsets(force: Bool = false) { |
| 1212 | guard let cv = collectionView, let host else { return } |
| 1213 | let inset = UIEdgeInsets(top: topOverlay, left: 0, |
| 1214 | bottom: max(0, bottomOverlay |
| 1215 | - (keyboardUp ? Self.composerTrimKeyboard |
| 1216 | : Self.composerTrimResting)), |
| 1217 | right: 0) |
| 1218 | guard force || inset != host.additionalSafeAreaInsets else { return } |
| 1219 | let keepBottom = stickToBottom || atBottomNow |
| 1220 | host.additionalSafeAreaInsets = inset |
| 1221 | if keepBottom, !cv.isTracking { |
| 1222 | pinToBottom(animated: false) |
| 1223 | } |
| 1224 | } |
| 1225 | |
| 1226 | @objc private func keyboardWillShow() { |
| 1227 | keyboardUp = true |
| 1228 | updateInsets() |
| 1229 | } |
| 1230 | |
| 1231 | @objc private func keyboardWillHide() { |
| 1232 | keyboardUp = false |
| 1233 | updateInsets() |
| 1234 | } |
| 1235 | |
| 1236 | #if DEBUG |
| 1237 | // Height-pipeline truth for the frozen-chat triage (2026-07-21): |
| 1238 | // every apply writes each row's measured height + laid frame + shape |
| 1239 | // flags to Library/Logs/feed-heights.txt — pullable over devicectl, |
| 1240 | // so 'which rows collapse and what are they' stops being a guess. |
| 1241 | fileprivate func dumpHeights() { |
| 1242 | guard let cv = collectionView, dataSource != nil else { return } |
| 1243 | var out = ["== heights rows=\(current.count) contentH=\(cv.contentSize.height) =="] |
| 1244 | for (i, m) in current.enumerated() { |
| 1245 | guard let ip = dataSource.indexPath(for: .message(m.id)) else { continue } |
| 1246 | let h = heights[.message(m.id)] ?? -1 |
| 1247 | let f = cv.layoutAttributesForItem(at: ip)?.frame ?? .zero |
| 1248 | let t = String(m.text.prefix(24)).replacingOccurrences(of: "\n", with: " ") |
| 1249 | out.append(String(format: "#%d id=%d h=%.0f y=%.0f fh=%.0f atts=%d reply=%@ act=%d rx=%d '%@'", |
| 1250 | i, m.id, h, f.origin.y, f.height, m.attachments.count, |
| 1251 | m.reply == nil ? "n" : "y", m.action ? 1 : 0, |
| 1252 | m.reactions.count, t)) |
| 1253 | } |
| 1254 | let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| 1255 | .appendingPathComponent("Logs", isDirectory: true) |
| 1256 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 1257 | try? out.joined(separator: "\n") |
| 1258 | .write(to: dir.appendingPathComponent("feed-heights.txt"), |
| 1259 | atomically: true, encoding: .utf8) |
| 1260 | } |
| 1261 | #endif |
| 1262 | |
| 1263 | fileprivate func boundsHeightChanged(wasAtBottom: Bool) { |
| 1264 | // The keyboard/composer resize the feed via safe areas; keep the |
| 1265 | // bottom edge anchored only if the reader was on it. |
| 1266 | if wasAtBottom, !collectionView.isTracking { |
| 1267 | pinToBottom(animated: false) |
| 1268 | } |
| 1269 | } |
| 1270 | |
| 1271 | // The latch's per-layout landing: offset write only, skipped while |
| 1272 | // the reader (or an animated pin) is steering. |
| 1273 | fileprivate func stickPinIfNeeded() { |
| 1274 | guard stickToBottom, !animatingPin, let cv = collectionView, |
| 1275 | !cv.isTracking, !cv.isDecelerating else { return } |
| 1276 | let target = max(-cv.adjustedContentInset.top, |
| 1277 | cv.contentSize.height + cv.adjustedContentInset.bottom |
| 1278 | - cv.bounds.height) |
| 1279 | if abs(cv.contentOffset.y - target) > 0.5 { |
| 1280 | #if DEBUG |
| 1281 | ScrollCrumbs.log("stickPin write y=\(Int(target)) " + |
| 1282 | "(was \(Int(cv.contentOffset.y)))") |
| 1283 | #endif |
| 1284 | cv.contentOffset.y = target |
| 1285 | } |
| 1286 | } |
| 1287 | |
| 1288 | fileprivate var atBottomNow: Bool { gapFromBottom() < 44 } |
| 1289 | } |
| 1290 | |
| 1291 | // A row cell that OWNS its UIHostingController — created once, rootView |
| 1292 | // swapped on every (re)configure. Two things UIHostingConfiguration |
| 1293 | // cannot give: safeAreaRegions = [] (the short-chat content-eviction |
| 1294 | // fix — see rowView), and a persistent hosting view across recycles |
| 1295 | // (a rootView swap, not a contentView teardown). |
| 1296 | final class FeedHostedCell: UICollectionViewCell { |
| 1297 | private var hostVC: UIHostingController<AnyView>? |
| 1298 | |
| 1299 | override init(frame: CGRect) { |
| 1300 | super.init(frame: frame) |
| 1301 | // No system platter, ever: the context-menu lift snapshots the |
| 1302 | // CELL's configured background before our targeted preview |
| 1303 | // engages — any non-clear default is the row-wide flash behind |
| 1304 | // the lifting bubble. |
| 1305 | backgroundConfiguration = .clear() |
| 1306 | // And no hover/highlight effect: the long-press "highlight" |
| 1307 | // phase (pre-menu) paints a full-row rounded platter — the |
| 1308 | // frame-by-frame aura from Paul's 2026-07-21 recording. Its |
| 1309 | // default shape is the cell's bounds; Messages shows none, so |
| 1310 | // none here. |
| 1311 | var style = UIHoverStyle(effect: .automatic) |
| 1312 | style.isEnabled = false |
| 1313 | hoverStyle = style |
| 1314 | } |
| 1315 | |
| 1316 | required init?(coder: NSCoder) { fatalError("unused") } |
| 1317 | |
| 1318 | override func updateConfiguration(using state: UICellConfigurationState) { |
| 1319 | backgroundConfiguration = .clear() |
| 1320 | } |
| 1321 | |
| 1322 | func set(root: AnyView, parent: UIViewController?, clips: Bool = false) { |
| 1323 | if let hostVC { |
| 1324 | hostVC.view.clipsToBounds = clips |
| 1325 | // A recycle re-purposes this tree for a DIFFERENT message — |
| 1326 | // never animate the diff (rows used to pin .id per message |
| 1327 | // for this; the pin's full remount cost ~7ms per recycle). |
| 1328 | var tx = Transaction() |
| 1329 | tx.disablesAnimations = true |
| 1330 | withTransaction(tx) { hostVC.rootView = root } |
| 1331 | return |
| 1332 | } |
| 1333 | let hc = UIHostingController(rootView: root) |
| 1334 | hc.view.backgroundColor = .clear |
| 1335 | hc.view.clipsToBounds = clips |
| 1336 | // The load-bearing line: hosted cell content must never |
| 1337 | // rearrange itself around the window's safe areas — the layout's |
| 1338 | // frame is the whole truth of where a row sits. |
| 1339 | hc.safeAreaRegions = [] |
| 1340 | // The measured layout owns sizing; the hosting view just fills. |
| 1341 | hc.sizingOptions = [] |
| 1342 | hostVC = hc |
| 1343 | parent?.addChild(hc) |
| 1344 | hc.view.frame = contentView.bounds |
| 1345 | hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] |
| 1346 | contentView.insertSubview(hc.view, at: 0) |
| 1347 | if let parent { hc.didMove(toParent: parent) } |
| 1348 | } |
| 1349 | |
| 1350 | } |
| 1351 | |
| 1352 | // The stock collection view: initial-anchor timing + keep-bottom across |
| 1353 | // height changes, nothing else. No transforms anywhere. |
| 1354 | final class StockFeedCollectionView: UICollectionView { |
| 1355 | weak var coordinator: Coordinator? |
| 1356 | private var lastSize: CGSize = .zero |
| 1357 | private var wasAtBottomBeforeResize = false |
| 1358 | |
| 1359 | override func didMoveToWindow() { |
| 1360 | super.didMoveToWindow() |
| 1361 | // The window's safe areas feed the rest insets; they only exist |
| 1362 | // once attached. |
| 1363 | coordinator?.windowChanged() |
| 1364 | } |
| 1365 | |
| 1366 | #if DEBUG |
| 1367 | // Forensics: prove touches are (or are not) reaching the feed at |
| 1368 | // all — an invisible blocker in front of the list means hitTest |
| 1369 | // here goes silent while the user is clearly touching the screen. |
| 1370 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { |
| 1371 | let hit = super.hitTest(point, with: event) |
| 1372 | ScrollCrumbs.throttled("hit", |
| 1373 | "hitTest (\(Int(point.x)),\(Int(point.y))) -> " + |
| 1374 | "\(hit.map { String(describing: type(of: $0)) } ?? "nil")") |
| 1375 | return hit |
| 1376 | } |
| 1377 | #endif |
| 1378 | |
| 1379 | override func layoutSubviews() { |
| 1380 | if bounds.size != lastSize, lastSize != .zero { |
| 1381 | wasAtBottomBeforeResize = coordinator?.atBottomNow ?? false |
| 1382 | } |
| 1383 | // Cell placement must NEVER animate on ambient transactions: a |
| 1384 | // layout pass reached from inside a SwiftUI animated update (the |
| 1385 | // glass overlays' measurement probes, safe-area changes, any |
| 1386 | // withAnimation upstream) inherits that CA transaction, and |
| 1387 | // materialising cells visibly BOUNCE into place instead of just |
| 1388 | // being a scrolling list. Deliberate motion here is scroll |
| 1389 | // animation (contentOffset), which this does not touch. |
| 1390 | UIView.performWithoutAnimation { |
| 1391 | super.layoutSubviews() |
| 1392 | } |
| 1393 | if bounds.size != lastSize { |
| 1394 | let hadSize = lastSize != .zero |
| 1395 | lastSize = bounds.size |
| 1396 | if hadSize { |
| 1397 | coordinator?.boundsHeightChanged(wasAtBottom: wasAtBottomBeforeResize) |
| 1398 | } |
| 1399 | } |
| 1400 | if bounds.height > 0, coordinator?.needsInitialAnchor == true { |
| 1401 | coordinator?.performInitialAnchor() |
| 1402 | } |
| 1403 | coordinator?.stickPinIfNeeded() |
| 1404 | } |
| 1405 | } |
| 1406 | } |
| 1407 | |
| 1408 | |
| 1409 | // The measured layout: a single column of EXACT frames from the coordinator's |
| 1410 | // height cache — no estimated self-sizing, so nothing ever corrects, and |
| 1411 | // nothing corrects means nothing bounces, flies, or snaps while scrolling. |
| 1412 | // prepare() is a running sum (O(rows), plain arithmetic); queries binary-search |
| 1413 | // the y-sorted frames. |
| 1414 | final class FeedMeasuredLayout: UICollectionViewLayout { |
| 1415 | var heightFor: ((StockChatFeed.FeedItem, CGFloat) -> CGFloat)! |
| 1416 | var itemAt: ((Int) -> StockChatFeed.FeedItem?)! |
| 1417 | private var frames: [CGRect] = [] |
| 1418 | private var contentHeight: CGFloat = 0 |
| 1419 | |
| 1420 | override func prepare() { |
| 1421 | super.prepare() |
| 1422 | guard let cv = collectionView else { return } |
| 1423 | let width = cv.bounds.width |
| 1424 | let count = cv.numberOfItems(inSection: 0) |
| 1425 | frames.removeAll(keepingCapacity: true) |
| 1426 | frames.reserveCapacity(count) |
| 1427 | var y: CGFloat = 0 |
| 1428 | for i in 0..<count { |
| 1429 | let h = itemAt(i).map { heightFor($0, width) } ?? 0 |
| 1430 | frames.append(CGRect(x: 0, y: y, width: width, height: h)) |
| 1431 | y += h |
| 1432 | } |
| 1433 | contentHeight = y |
| 1434 | } |
| 1435 | |
| 1436 | override var collectionViewContentSize: CGSize { |
| 1437 | CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight) |
| 1438 | } |
| 1439 | |
| 1440 | override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { |
| 1441 | guard !frames.isEmpty else { return [] } |
| 1442 | var lo = 0, hi = frames.count - 1, start = frames.count |
| 1443 | while lo <= hi { |
| 1444 | let mid = (lo + hi) / 2 |
| 1445 | if frames[mid].maxY >= rect.minY { |
| 1446 | start = mid |
| 1447 | hi = mid - 1 |
| 1448 | } else { |
| 1449 | lo = mid + 1 |
| 1450 | } |
| 1451 | } |
| 1452 | var out: [UICollectionViewLayoutAttributes] = [] |
| 1453 | var i = start |
| 1454 | while i < frames.count, frames[i].minY <= rect.maxY { |
| 1455 | out.append(attributes(at: i)) |
| 1456 | i += 1 |
| 1457 | } |
| 1458 | return out |
| 1459 | } |
| 1460 | |
| 1461 | override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { |
| 1462 | guard indexPath.item < frames.count else { return nil } |
| 1463 | return attributes(at: indexPath.item) |
| 1464 | } |
| 1465 | |
| 1466 | private func attributes(at index: Int) -> UICollectionViewLayoutAttributes { |
| 1467 | let a = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0)) |
| 1468 | a.frame = frames[index] |
| 1469 | return a |
| 1470 | } |
| 1471 | |
| 1472 | // Height changes (keyboard, composer) never re-measure; width changes do. |
| 1473 | override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { |
| 1474 | newBounds.width != collectionView?.bounds.width |
| 1475 | } |
| 1476 | } |
| 1477 | |
| 1478 | |
| 1479 | #if DEBUG |
| 1480 | // Timestamped scroll forensics for device pulls (the menu-debug.txt |
| 1481 | // pattern): buffered in memory, flushed to Library/Logs/scroll-debug.txt |
| 1482 | // at most ~1/s so logging never becomes the jank it hunts. |
| 1483 | @MainActor |
| 1484 | enum ScrollCrumbs { |
| 1485 | private static var lines: [String] = [] |
| 1486 | private static var lastFlush = Date.distantPast |
| 1487 | private static var lastByTag: [String: Date] = [:] |
| 1488 | |
| 1489 | static func log(_ s: String) { |
| 1490 | let now = Date() |
| 1491 | lines.append(String(format: "%.3f %@", now.timeIntervalSince1970, s)) |
| 1492 | if lines.count > 6000 { lines.removeFirst(3000) } |
| 1493 | if now.timeIntervalSince(lastFlush) > 1.0 { flush() } |
| 1494 | } |
| 1495 | |
| 1496 | /// Rate-limited variant for chatty sources (hit-tests, offset motion). |
| 1497 | static func throttled(_ tag: String, _ s: String, every: TimeInterval = 0.15) { |
| 1498 | let now = Date() |
| 1499 | if let last = lastByTag[tag], now.timeIntervalSince(last) < every { return } |
| 1500 | lastByTag[tag] = now |
| 1501 | log(s) |
| 1502 | } |
| 1503 | |
| 1504 | static func flush() { |
| 1505 | lastFlush = Date() |
| 1506 | let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| 1507 | .appendingPathComponent("Logs", isDirectory: true) |
| 1508 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 1509 | try? lines.suffix(5000).joined(separator: "\n") |
| 1510 | .write(to: dir.appendingPathComponent("scroll-debug.txt"), |
| 1511 | atomically: true, encoding: .utf8) |
| 1512 | } |
| 1513 | } |
| 1514 | #endif |
| 1515 | |
| 1516 | // The cross-open row-height cache: heights are deterministic in their key |
| 1517 | // (content + relation + width + type settings), coordinators are not |
| 1518 | // (they die with their screens). Bounded by a dumb full reset — measures |
| 1519 | // re-fill lazily and correctness never depends on a hit. |
| 1520 | // |
| 1521 | // PERSISTED to Library/Caches across launches: the first open of a |
| 1522 | // 200-message chat is ~200 fresh SwiftUI measurements — 3.1s on a device |
| 1523 | // Debug build (Paul's ledger, 2026-07-21) — and a process-local cache pays |
| 1524 | // that again for every chat on every launch. Heights are pure functions of |
| 1525 | // their key, so the only cross-launch hazard is the CODE changing what a |
| 1526 | // row measures; the stamp (the app binary's mtime) discards the whole file |
| 1527 | // on any new install, so a dev build can never serve heights measured by a |
| 1528 | // different layout. One accepted transient: a link row persisted at its |
| 1529 | // unfurled (grown) height re-opens with the card not yet cached, showing |
| 1530 | // the gap until the unfurl resolves — the same rowGrewLate path that grew |
| 1531 | // it then confirms or corrects it. |
| 1532 | @MainActor |
| 1533 | enum FeedHeightCache { |
| 1534 | static var store: [String: CGFloat] = [:] |
| 1535 | private static var saveScheduled = false |
| 1536 | |
| 1537 | static func remember(_ key: String, _ h: CGFloat) { |
| 1538 | if store.count > 8000 { store.removeAll(keepingCapacity: true) } |
| 1539 | store[key] = h |
| 1540 | scheduleSave() |
| 1541 | } |
| 1542 | |
| 1543 | // ---- disk persistence ---------------------------------------------- |
| 1544 | |
| 1545 | private static var fileURL: URL { |
| 1546 | FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] |
| 1547 | .appendingPathComponent("feed-heights-v1.json") |
| 1548 | } |
| 1549 | |
| 1550 | // The invalidation stamp: any new binary = any possible row-layout |
| 1551 | // change = start over. Cheap, coarse, and safe by construction. |
| 1552 | private static var binaryStamp: String { |
| 1553 | guard let exe = Bundle.main.executableURL, |
| 1554 | let date = (try? exe.resourceValues(forKeys: [.contentModificationDateKey]))? |
| 1555 | .contentModificationDate else { return "unstamped" } |
| 1556 | return String(Int(date.timeIntervalSince1970)) |
| 1557 | } |
| 1558 | |
| 1559 | private struct Snapshot: Codable { |
| 1560 | var stamp: String |
| 1561 | var heights: [String: CGFloat] |
| 1562 | } |
| 1563 | |
| 1564 | /// Kick the disk load at app boot — merges on main well before any |
| 1565 | /// chat opens, and never blocks the first frame. Fresh measurements |
| 1566 | /// always win over disk (merge fills gaps only). |
| 1567 | static func warm() { |
| 1568 | let url = fileURL |
| 1569 | let stamp = binaryStamp |
| 1570 | Task.detached(priority: .utility) { |
| 1571 | guard let data = try? Data(contentsOf: url), |
| 1572 | let snap = try? JSONDecoder().decode(Snapshot.self, from: data), |
| 1573 | snap.stamp == stamp else { return } |
| 1574 | await MainActor.run { |
| 1575 | store.merge(snap.heights) { current, _ in current } |
| 1576 | } |
| 1577 | } |
| 1578 | } |
| 1579 | |
| 1580 | private static func scheduleSave() { |
| 1581 | guard !saveScheduled else { return } |
| 1582 | saveScheduled = true |
| 1583 | Task { @MainActor in |
| 1584 | // Debounce past the measure burst a thread-open produces. |
| 1585 | try? await Task.sleep(nanoseconds: 3_000_000_000) |
| 1586 | saveScheduled = false |
| 1587 | flushNow() |
| 1588 | } |
| 1589 | } |
| 1590 | |
| 1591 | /// Write the current store out (also the test hook — persistence must |
| 1592 | /// be checkable without waiting out the debounce). |
| 1593 | static func flushNow() { |
| 1594 | let snap = Snapshot(stamp: binaryStamp, heights: store) |
| 1595 | let url = fileURL |
| 1596 | Task.detached(priority: .utility) { |
| 1597 | if let data = try? JSONEncoder().encode(snap) { |
| 1598 | try? data.write(to: url, options: .atomic) |
| 1599 | } |
| 1600 | } |
| 1601 | } |
| 1602 | } |
| 1603 | |