eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import LChatCore
3import SwiftUI
4import 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.)
34struct 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 return "\(id)|\(m.at)|\(m.edited)|\(textHash)|" +
455 "\(m.reactions.count)|\(m.attachments.count)|" +
456 "\(m.reply != nil)|\(m.action)|\(hooks.foldsAsMuted(m))|" +
457 "\(sep)|\(startsRun)|\(width)|\(hooks.chatFontSize)|" +
458 "\(hooks.themeName)|\(hooks.isGroup)"
459 }
460
461 // A row's content resolved something AFTER its measurement (an unfurl
462 // card). Re-measure; only a REAL height change touches the layout —
463 // which also breaks the sizing-pass-fires-unfurl feedback loop.
464 private func rowGrewLate(_ id: Int64) {
465 let item = FeedItem.message(id)
466 guard let cv = collectionView, dataSource != nil,
467 let old = heights[item] else { return }
468 heights[item] = nil
469 // The stale pre-unfurl height must not answer from the global
470 // cache — drop it so the re-measure (which sees the cached card
471 // via peekUnfurl) writes the grown value.
472 if let key = heightCacheKey(for: item, width: measuredWidth) {
473 FeedHeightCache.store[key] = nil
474 }
475 bubbleRects[item] = nil
476 let new = height(for: item, width: measuredWidth)
477 guard abs(new - old) > 0.5 else { return }
478 var snap = dataSource.snapshot()
479 guard snap.itemIdentifiers.contains(item) else { return }
480 snap.reconfigureItems([item])
481 dataSource.apply(snap, animatingDifferences: false)
482 cv.collectionViewLayout.invalidateLayout()
483 // At the tail the latch re-lands the bottom next pass, so a card
484 // that grew the newest row stays visible (the old unfurl re-pin).
485 }
486
487 func subscribe() {
488 model.$messages
489 .removeDuplicates()
490 .sink { [weak self] in self?.setMessages($0) }
491 .store(in: &cancellables)
492 model.$typing
493 .removeDuplicates()
494 .sink { [weak self] in self?.setTyping($0) }
495 .store(in: &cancellables)
496 model.$senderNames.removeDuplicates().map { _ in () }
497 .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () })
498 .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () })
499 .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () })
500 .dropFirst(4)
501 .sink { [weak self] in self?.reconfigureAll() }
502 .store(in: &cancellables)
503 // An empty chat has no first layout to anchor — end the mount
504 // measurement when the (empty) history lands instead.
505 model.$ready
506 .filter { $0 }
507 .prefix(1)
508 .sink { [weak self] _ in
509 guard let self, !self.firstApplyDone, self.current.isEmpty else { return }
510 let cb = self.onFirstLayout
511 DispatchQueue.main.async { cb?(0) }
512 }
513 .store(in: &cancellables)
514 }
515
516 // ---- data -----------------------------------------------------------
517
518 private func index(of id: Int64) -> Int? {
519 var lo = 0, hi = current.count - 1
520 while lo <= hi {
521 let mid = (lo + hi) / 2
522 if current[mid].id == id { return mid }
523 if current[mid].id < id { lo = mid + 1 } else { hi = mid - 1 }
524 }
525 return nil
526 }
527
528 private func items(for list: [ChatFeedModel.Message]) -> [FeedItem] {
529 // The typing row is PERMANENT — always the tail item, measured at
530 // zero height when nobody's typing. Typing beats arrive
531 // constantly in agent chats, and when the row came and went the
532 // churn was a structural apply every few seconds (batch updates
533 // stacked cells; clean reloads read as scroll delay). A
534 // reconfigure of one zero-or-36pt row costs nothing.
535 list.map { .message($0.id) } + [.typing]
536 }
537
538 private func setMessages(_ new: [ChatFeedModel.Message]) {
539 // Prepends compensate the offset, and an offset write kills a live
540 // fling (and fights a finger) — so while either is in flight, a
541 // prepend-carrying publish waits. Appends/edits flow through
542 // unchanged applies regardless (they never move the viewport here).
543 if let cv = collectionView, cv.isDecelerating || cv.isTracking,
544 let newOldest = new.first?.id, let oldOldest = current.first?.id,
545 newOldest < oldOldest {
546 pendingMessages = new
547 return
548 }
549 pendingMessages = nil
550 applyMessages(new)
551 }
552
553 fileprivate func flushPending() {
554 // At rest ONLY — a structural reload racing the deceleration
555 // animator composites cells from two different offsets (the
556 // ghost-doubled rows at the top edge). scrollSettled re-fires
557 // this the moment the scroll comes to rest.
558 guard let cv = collectionView, !cv.isTracking, !cv.isDecelerating,
559 let pending = pendingMessages else { return }
560 pendingMessages = nil
561 applyMessages(pending)
562 }
563
564 private func applyMessages(_ new: [ChatFeedModel.Message]) {
565 let oldNewest = current.last?.id
566 let oldOldest = current.first?.id
567 current = new
568 let newestChanged = new.last?.id != oldNewest
569 // A typer's arriving message retires their dots IN THIS apply:
570 // items and heights change once, so the pin scroll + entrance
571 // rise + dot removal play as one motion. Collapsing on the
572 // separate typing-off event a beat later was the pane jump
573 // (Paul, 2026-07-21). The model's own clear follows and no-ops.
574 if newestChanged, let last = new.last, !last.mine,
575 typingNames.contains(last.name) {
576 typingNames.removeAll { $0 == last.name }
577 heights[.typing] = nil
578 }
579 let newestIsMine = new.last?.mine == true
580 let prepended = new.first?.id ?? Int64.max
581 apply(hasPrepends: oldOldest.map { prepended < $0 } ?? false,
582 newestChanged: newestChanged, newestIsMine: newestIsMine)
583 }
584
585 private func setTyping(_ names: [String]) {
586 let appeared = typingNames.isEmpty && !names.isEmpty
587 let cleared = !typingNames.isEmpty && names.isEmpty
588 typingNames = names
589 guard let cv = collectionView, dataSource != nil, firstApplyDone else { return }
590 let wasAtBottom = gapFromBottom() < 44
591 let reduceMotion = UIAccessibility.isReduceMotionEnabled
592 let refresh = { [weak self] in
593 guard let self, let cv = self.collectionView,
594 self.dataSource != nil else { return }
595 self.heights[.typing] = nil
596 var snap = self.dataSource.snapshot()
597 guard snap.itemIdentifiers.contains(.typing) else { return }
598 snap.reconfigureItems([.typing])
599 self.dataSource.apply(snap, animatingDifferences: false)
600 cv.collectionViewLayout.invalidateLayout()
601 }
602 // The dots bow out with a quick fade before the row collapses —
603 // the hard blink next to the arrival's rise-in read as a snap.
604 // (The height cache keeps the row's height until refresh runs,
605 // so an interleaved arrival can't collapse it mid-fade.)
606 let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1,
607 section: 0)
608 if cleared, !reduceMotion, let cell = cv.cellForItem(at: typingIP) {
609 UIView.animate(withDuration: 0.12, animations: {
610 cell.contentView.alpha = 0
611 }, completion: { _ in
612 // The registration's reuse reset also restores alpha;
613 // this covers the cell staying in place.
614 cell.contentView.alpha = 1
615 refresh()
616 })
617 } else {
618 refresh()
619 }
620 // The bubble appearing while you're at the tail (or composing —
621 // you're at the conversation's tail even if the keyboard hides
622 // it) should be seen; reading history is never yanked.
623 if appeared, wasAtBottom || composerFocused, !cv.isTracking {
624 pinToBottom(animated: !reduceMotion)
625 }
626 }
627
628 // Non-animated applies throughout — the flipped rounds proved batch
629 // animations under frequent applies strand cells mid-fade and mirror
630 // directions; reveals are done by scrolling, not by item animation.
631 private func apply(hasPrepends: Bool, newestChanged: Bool, newestIsMine: Bool) {
632 guard dataSource != nil else { return }
633 let old = dataSource.snapshot().itemIdentifiers
634 var snap = NSDiffableDataSourceSnapshot<Int, FeedItem>()
635 snap.appendSections([0])
636 let new = items(for: current)
637 snap.appendItems(new)
638 let oldSet = Set(old)
639 var pairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:]
640 pairs.reserveCapacity(current.count)
641 var changed: [FeedItem] = []
642 for (i, m) in current.enumerated() {
643 let pair = (msg: m, prevID: i > 0 ? current[i - 1].id : nil)
644 pairs[m.id] = pair
645 if oldSet.contains(.message(m.id)), let last = lastPairs[m.id],
646 last.msg != pair.msg || last.prevID != pair.prevID {
647 changed.append(.message(m.id))
648 }
649 }
650 lastPairs = pairs
651 for item in changed { heights[item] = nil; bubbleRects[item] = nil }
652 snap.reconfigureItems(changed)
653
654 guard firstApplyDone else {
655 dataSource.apply(snap, animatingDifferences: false)
656 if !current.isEmpty {
657 firstApplyDone = true
658 needsInitialAnchor = true
659 collectionView.setNeedsLayout()
660 }
661 return
662 }
663
664 let wasAtBottom = gapFromBottom() < 44
665 // NO structural change goes through batch updates — not prepends,
666 // not deletions, not appends, not the typing row: performBatch on
667 // a custom layout computes frames against a half-transitioned
668 // item list (numberOfItems old, identifiers new), and any window
669 // where frames and cells disagree eventually shows up as stacked
670 // rows (the short-chat report: typing churn in agent chats is the
671 // busiest tail change there is). A clean reload rebuilds the
672 // visible handful — cheap, off the per-frame path — and the
673 // batch path survives only for reconfigure-only applies, which
674 // move nothing.
675 let structural = old.count != new.count || oldSet != Set(new)
676 if structural {
677 let beforeH = collectionView.contentSize.height
678 dataSource.applySnapshotUsingReloadData(snap)
679 collectionView.layoutIfNeeded()
680 if hasPrepends, !wasAtBottom {
681 // Older pages above: hold the reader's rows exactly still
682 // by the REAL laid-out growth.
683 let delta = collectionView.contentSize.height - beforeH
684 if delta > 0 {
685 collectionView.contentOffset.y += delta
686 }
687 } else if newestChanged, wasAtBottom || newestIsMine || composerFocused {
688 // Reveal the new bottom: your own send from anywhere, any
689 // growth while composing, and ordinary arrivals only when
690 // the reader is already at the tail — reading history
691 // never yanks.
692 let reduceMotion = UIAccessibility.isReduceMotionEnabled
693 pinToBottom(animated: !reduceMotion)
694 if !reduceMotion, wasAtBottom {
695 animateArrivalEntrance()
696 }
697 } else if wasAtBottom {
698 pinToBottom(animated: false)
699 }
700 } else {
701 dataSource.apply(snap, animatingDifferences: false)
702 }
703 updateJumpVisible()
704 }
705
706 // The arrival beat, as OUR OWN cell animation (never a batch
707 // update's): the newest row rises from beneath the composer with a
708 // fade. Runs outside any layout pass; a missing cell just means the
709 // pin scroll will bring it in plainly.
710 private func animateArrivalEntrance() {
711 guard let cv = collectionView else { return }
712 cv.layoutIfNeeded()
713 let count = cv.numberOfItems(inSection: 0)
714 // The LAST item is the permanent typing row — the newest message
715 // sits one above it. Animating count-1 "worked" invisibly (a
716 // zero-height cell), so every arrival just snapped in (Paul,
717 // 2026-07-21).
718 guard count >= 2,
719 let cell = cv.cellForItem(at: IndexPath(item: count - 2, section: 0))
720 else { return }
721 cell.contentView.alpha = 0
722 cell.contentView.transform = CGAffineTransform(translationX: 0, y: 14)
723 UIView.animate(withDuration: 0.32, delay: 0,
724 usingSpringWithDamping: 0.85, initialSpringVelocity: 0,
725 options: [.allowUserInteraction]) {
726 cell.contentView.alpha = 1
727 cell.contentView.transform = .identity
728 }
729 }
730
731 func reconfigureAll() {
732 guard dataSource != nil else { return }
733 heights.removeAll()
734 bubbleRects.removeAll()
735 var snap = dataSource.snapshot()
736 snap.reconfigureItems(snap.itemIdentifiers)
737 dataSource.apply(snap, animatingDifferences: false)
738 collectionView?.collectionViewLayout.invalidateLayout()
739 }
740
741 // ---- scrolling ------------------------------------------------------
742
743 private func gapFromBottom() -> CGFloat {
744 guard let cv = collectionView else { return 0 }
745 let bottom = cv.contentSize.height + cv.adjustedContentInset.bottom
746 return bottom - (cv.contentOffset.y + cv.bounds.height)
747 }
748
749 func pinToBottom(animated: Bool) {
750 guard let cv = collectionView else { return }
751 stickToBottom = true
752 let target = max(-cv.adjustedContentInset.top,
753 cv.contentSize.height + cv.adjustedContentInset.bottom
754 - cv.bounds.height)
755 guard abs(cv.contentOffset.y - target) > 0.5 else { return }
756 if animated {
757 // Our own curve (the old feed's 0.15s ease-out), and a
758 // completion that ALWAYS clears the latch guard — UIKit's
759 // setContentOffset(animated:) never calls its end delegate
760 // for a zero-distance scroll, which stranded the guard and
761 // killed the caret.
762 animatingPin = true
763 UIView.animate(withDuration: 0.15, delay: 0, options: [.curveEaseOut]) {
764 cv.contentOffset = CGPoint(x: 0, y: target)
765 } completion: { _ in
766 self.animatingPin = false
767 }
768 } else {
769 cv.contentOffset = CGPoint(x: 0, y: target)
770 }
771 }
772
773 func scrollTo(id: Int64) {
774 stickToBottom = false
775 guard let ip = dataSource.indexPath(for: .message(id)) else { return }
776 // Animated only for NEAR jumps; far targets snap (estimated rows
777 // recalibrate on the way and animated long hops land rough).
778 let count = collectionView.numberOfItems(inSection: 0)
779 collectionView.scrollToItem(at: ip, at: .centeredVertically,
780 animated: count - ip.item <= 60)
781 }
782
783 func scrollViewDidScroll(_ scrollView: UIScrollView) {
784 #if DEBUG
785 ScrollCrumbs.throttled("offset",
786 "offset y=\(Int(scrollView.contentOffset.y))")
787 #endif
788 updateJumpVisible()
789 }
790
791 #if DEBUG
792 @objc func panCrumb(_ g: UIPanGestureRecognizer) {
793 let v = g.velocity(in: g.view)
794 let t = g.translation(in: g.view)
795 ScrollCrumbs.log("pan state=\(g.state.rawValue) " +
796 "t=(\(Int(t.x)),\(Int(t.y))) v=(\(Int(v.x)),\(Int(v.y)))")
797 }
798
799 // The measure-vs-render audit (the 2026-07-21 separator-overlap
800 // screenshot): any visible cell whose laid-out height disagrees
801 // with its content's ideal height is the bug — crumb it with its
802 // cache key so a device pull names the row and the mismatch.
803 func auditHeights() {
804 guard let cv = collectionView, dataSource != nil else { return }
805 for cell in cv.visibleCells {
806 guard let ip = cv.indexPath(for: cell),
807 let item = dataSource.itemIdentifier(for: ip),
808 case .message(let id) = item,
809 let hosted = cell.contentView.subviews.first else { continue }
810 let ideal = hosted.sizeThatFits(CGSize(
811 width: cv.bounds.width, height: .greatestFiniteMagnitude)).height
812 if abs(cell.frame.height - ideal) > 3 {
813 ScrollCrumbs.log("HEIGHT MISMATCH id=\(id) " +
814 "cell=\(Int(cell.frame.height)) ideal=\(Int(ideal)) " +
815 "key=\(heightCacheKey(for: item, width: measuredWidth) ?? "nil")")
816 }
817 }
818 }
819 #endif
820
821 // Jump-to-latest visibility, from scroll AND from content changes —
822 // a page landing or a delete shifts the gap without any scroll event,
823 // which left the caret stale.
824 private func updateJumpVisible() {
825 let show = gapFromBottom() > 400
826 if show != jumpVisible {
827 jumpVisible = show
828 guard let button = (host as? FeedHostController)?.jumpButton else { return }
829 UIView.animate(withDuration: 0.12, delay: 0,
830 options: [.beginFromCurrentState, .allowUserInteraction]) {
831 button.alpha = show ? 1 : 0
832 }
833 }
834 }
835
836 // A mid-fling tap must redirect the scroll, not queue behind it:
837 // kill the deceleration first, then pin.
838 fileprivate func jumpTapped() {
839 if let cv = collectionView, cv.isDecelerating {
840 cv.setContentOffset(cv.contentOffset, animated: false)
841 }
842 pinToBottom(animated: true)
843 }
844
845 func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
846 // The reader took the wheel: nothing holds the bottom against
847 // their finger.
848 stickToBottom = false
849 animatingPin = false
850 }
851
852 func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
853 animatingPin = false
854 }
855
856 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollSettled() }
857 func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
858 if !willDecelerate { scrollSettled() }
859 }
860
861 private func scrollSettled() {
862 flushPending()
863 // Coming to rest on the tail re-arms the latch — the bottom is
864 // sticky when you're on it (iMessage feel), free when you're not.
865 if gapFromBottom() < 44 { stickToBottom = true }
866 #if DEBUG
867 auditHeights()
868 #endif
869 }
870
871 // ---- long-press menu (OWNED — see FeedMenu.swift) ------------------
872
873 // Fired from the ROW's own SwiftUI long-press (hooks.presentMenu):
874 // every system context-menu route proved broken for bubbles on
875 // device (2026-07-21 — the item pipeline platters the whole CELL,
876 // manual interactions never fire), so the menu is drawn by
877 // FeedMenuOverlay: the bubble's own pixels float in place, the
878 // reaction palette above, a labelled card below — the web app's
879 // long-press menu. Structure mirrors MessageBubble's menuItems
880 // (reaction palette → Reply/Copy/Edit → Mute/Report → mine-only
881 // Delete); keep the two in step.
882 private weak var openMenu: FeedMenuOverlay?
883
884 // ---- the row swipe (quick-reply / times peek) ----------------------
885
886 private weak var rowSwipeRecognizer: UIPanGestureRecognizer?
887 // The message under the finger when the swipe began (reply target).
888 private var swipeTargetID: Int64?
889
890 // The structural fix for the stolen-scroll freeze: this pan only
891 // BEGINS on horizontally-dominant motion, so a vertical fling can
892 // never be claimed away from the scroll pan.
893 func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
894 guard g === rowSwipeRecognizer,
895 let pan = g as? UIPanGestureRecognizer, let cv = collectionView
896 else { return true }
897 let v = pan.velocity(in: cv)
898 return abs(v.x) > abs(v.y) * 1.6 && abs(v.x) > 60
899 }
900
901 @objc private func rowSwiped(_ g: UIPanGestureRecognizer) {
902 guard let cv = collectionView else { return }
903 #if DEBUG
904 ScrollCrumbs.throttled("rowPan",
905 "rowPan state=\(g.state.rawValue) " +
906 "t=(\(Int(g.translation(in: cv).x)),\(Int(g.translation(in: cv).y)))")
907 #endif
908 switch g.state {
909 case .began:
910 swipeTargetID = nil
911 if let ip = cv.indexPathForItem(at: g.location(in: cv)),
912 case .message(let id) = dataSource.itemIdentifier(for: ip),
913 let idx = index(of: id), !current[idx].action,
914 !hooks.foldsAsMuted(current[idx]) {
915 swipeTargetID = id
916 }
917 fallthrough
918 case .changed:
919 let x = g.translation(in: cv).x
920 if x > 0, let id = swipeTargetID {
921 // Rightward: the bubble follows the finger, arming
922 // quick-reply at the threshold (haptic once).
923 let drag = min(72, x * 0.6)
924 interaction.replyDrag[id] = drag
925 interaction.timesReveal = 0
926 if drag >= 40, !interaction.replyArmed {
927 interaction.replyArmed = true
928 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
929 }
930 } else if x < 0 {
931 // Leftward: peek the times gutter.
932 interaction.timesReveal = min(FeedMessageRow.timesGutter, -x)
933 if let id = swipeTargetID { interaction.replyDrag[id] = 0 }
934 }
935 case .ended, .cancelled, .failed:
936 if let id = swipeTargetID, (interaction.replyDrag[id] ?? 0) >= 40,
937 let idx = index(of: id) {
938 hooks.startReply(current[idx])
939 }
940 interaction.replyArmed = false
941 let id = swipeTargetID
942 swipeTargetID = nil
943 withAnimation(.spring(response: 0.35, dampingFraction: 0.72)) {
944 if let id { interaction.replyDrag[id] = 0 }
945 interaction.timesReveal = 0
946 }
947 default:
948 break
949 }
950 }
951
952 @objc private func feedLongPressed(_ g: UILongPressGestureRecognizer) {
953 menuCrumb("press state=\(g.state.rawValue)")
954 guard g.state == .began, let cv = collectionView else { return }
955 guard let ip = cv.indexPathForItem(at: g.location(in: cv)) else {
956 menuCrumb("no indexPath at \(g.location(in: cv))"); return
957 }
958 guard case .message(let id) = dataSource.itemIdentifier(for: ip) else {
959 menuCrumb("item at \(ip.item) not a message"); return
960 }
961 menuCrumb("press → message \(id)")
962 presentMenu(for: id)
963 }
964
965 #if DEBUG
966 // The silent-bail autopsy (2026-07-21): sim-green, device-dead —
967 // every step writes a breadcrumb pullable over devicectl.
968 private var menuCrumbs: [String] = []
969 func menuCrumb(_ line: String) {
970 menuCrumbs.append(line)
971 let dir = FileManager.default.urls(for: .libraryDirectory,
972 in: .userDomainMask)[0]
973 .appendingPathComponent("Logs", isDirectory: true)
974 try? FileManager.default.createDirectory(
975 at: dir, withIntermediateDirectories: true)
976 try? menuCrumbs.suffix(40).joined(separator: "\n")
977 .write(to: dir.appendingPathComponent("menu-debug.txt"),
978 atomically: true, encoding: .utf8)
979 }
980 #else
981 func menuCrumb(_ line: String) {}
982 #endif
983
984 // The bubble's rect within its row, measured the same deterministic
985 // way row heights are (the hosted SwiftUI tree exposes NO UIKit
986 // bubble view on device). bubbleRow's geometry constants: 12pt side
987 // padding + the 40pt minimum spacer on the far side; 3pt bottom
988 // padding, bubble bottom-anchored in the row. Cached like heights.
989 private var bubbleRects: [FeedItem: CGRect] = [:]
990
991 func bubbleRect(for item: FeedItem) -> CGRect? {
992 if let hit = bubbleRects[item] { return hit }
993 guard case .message(let id) = item, let idx = index(of: id),
994 measuredWidth > 64 else { return nil }
995 let m = current[idx]
996 guard !m.action, !hooks.foldsAsMuted(m) else { return nil }
997 let prev = idx > 0 ? current[idx - 1] : nil
998 let sep = timeSeparator(at: m.at, previous: prev?.at)
999 let startsRun = sep != nil || !ChatFeedModel.sameSender(prev, m)
1000 let bubble = MessageBubble(
1001 message: m, showSender: hooks.isGroup && startsRun,
1002 senderName: hooks.senderName(m),
1003 theme: .named(hooks.themeName),
1004 blobSource: hooks.blobSource,
1005 unfurl: hooks.unfurl,
1006 peekUnfurl: hooks.peekUnfurl)
1007 .environment(\.chatFontSize, hooks.chatFontSize)
1008 // Mention chips show the viewer's name for the person — the
1009 // measured height must see the same text the row renders.
1010 .environment(\.mentionIndex, hooks.mentionIndex())
1011 let hc = UIHostingController(rootView: bubble)
1012 hc.safeAreaRegions = []
1013 let size = hc.sizeThatFits(in: CGSize(
1014 width: measuredWidth - 64, height: .greatestFiniteMagnitude))
1015 let rowH = height(for: item, width: measuredWidth)
1016 guard size.width > 1, size.height > 1, size.height <= rowH
1017 else { return nil }
1018 let rect = CGRect(x: m.mine ? measuredWidth - 12 - size.width : 12,
1019 y: rowH - 3 - size.height,
1020 width: size.width, height: size.height)
1021 bubbleRects[item] = rect
1022 return rect
1023 }
1024
1025 func presentMenu(for id: Int64) {
1026 guard openMenu == nil else { menuCrumb("bail: menu open"); return }
1027 guard let idx = index(of: id) else { menuCrumb("bail: no index"); return }
1028 guard let container = host?.view else { menuCrumb("bail: no host"); return }
1029 let m = current[idx]
1030 guard !m.action, !hooks.foldsAsMuted(m) else {
1031 menuCrumb("bail: action/muted"); return
1032 }
1033 let actions = hooks.actions(m)
1034
1035 guard let ip = dataSource.indexPath(for: .message(id)),
1036 let cell = collectionView.cellForItem(at: ip),
1037 let hosted = cell.contentView.subviews.first else {
1038 menuCrumb("bail: no cell/hosted"); return
1039 }
1040 guard let rect = bubbleRect(for: .message(id)) else {
1041 menuCrumb("bail: no bubbleRect (w=\(measuredWidth) " +
1042 "rowH=\(height(for: .message(id), width: measuredWidth)))")
1043 return
1044 }
1045 menuCrumb("presenting id=\(id) rect=\(rect)")
1046 let image = UIGraphicsImageRenderer(bounds: rect).image { _ in
1047 hosted.drawHierarchy(in: hosted.bounds, afterScreenUpdates: false)
1048 }
1049 let frameInHost = cell.contentView.convert(rect, to: container)
1050
1051 var items: [FeedMenuOverlay.Item] = []
1052 if let reply = actions.onReply {
1053 items.append(.init(title: "Reply…",
1054 icon: "arrowshape.turn.up.left", handler: reply))
1055 }
1056 if let copy = actions.onCopy {
1057 items.append(.init(title: "Copy", icon: "doc.on.doc", handler: copy))
1058 }
1059 if actions.canEdit, let edit = actions.onEdit {
1060 items.append(.init(title: "Edit…", icon: "pencil", handler: edit))
1061 }
1062 if let mute = actions.onMutePerson {
1063 items.append(.init(title: actions.mutePersonTitle,
1064 icon: "bell.slash", handler: mute))
1065 }
1066 if let report = actions.onReport {
1067 items.append(.init(title: "Report", icon: "flag", handler: report))
1068 }
1069 if m.mine, let delete = actions.onDelete {
1070 items.append(.init(title: "Delete…", icon: "trash",
1071 destructive: true, handler: delete))
1072 }
1073
1074 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
1075 let overlay = FeedMenuOverlay(
1076 container: container, bubbleImage: image, bubbleFrame: frameInHost,
1077 mine: m.mine,
1078 reactions: actions.onReact != nil ? ChatFeedModel.allowedReactions : [],
1079 onReact: actions.onReact, items: items,
1080 onDismiss: { [weak self] in self?.openMenu = nil })
1081 container.addSubview(overlay)
1082 openMenu = overlay
1083 }
1084
1085 // Scroll-back: nearing the top of the loaded rows pages more in (the
1086 // model's cooldown paces it). A buffered page flushes as the reader
1087 // arrives, momentum or not — a blank top is worse than a settle.
1088 func collectionView(_ collectionView: UICollectionView,
1089 willDisplay cell: UICollectionViewCell,
1090 forItemAt indexPath: IndexPath) {
1091 guard firstApplyDone else { return }
1092 if indexPath.item < 8 {
1093 // No flushing from here, deferred or otherwise: pages apply
1094 // at scroll-settle only (flushPending's own guard). Reaching
1095 // the loaded top mid-fling shows the current top for a beat;
1096 // the page lands the moment the scroll rests.
1097 let model = model
1098 Task { await model.loadOlder() }
1099 }
1100 }
1101
1102 // ---- first layout ---------------------------------------------------
1103
1104 // Runs INSIDE the first real layout pass: offset writes only (the
1105 // no-forced-layout contract from the flipped container's crash).
1106 fileprivate func performInitialAnchor() {
1107 needsInitialAnchor = false
1108 guard let cv = collectionView else { return }
1109 if let cutoff = unreadCutoff,
1110 let target = current.first(where: { $0.at > cutoff && !$0.mine }),
1111 let ip = dataSource.indexPath(for: .message(target.id)) {
1112 // First unread at the TOP — reading continues where it left
1113 // off. Estimated frames are fine for the first beat; one
1114 // exact correction lands after the pass.
1115 if let attr = cv.layoutAttributesForItem(at: ip) {
1116 let maxY = max(-cv.adjustedContentInset.top,
1117 cv.contentSize.height + cv.adjustedContentInset.bottom
1118 - cv.bounds.height)
1119 cv.contentOffset.y = min(max(attr.frame.minY - cv.adjustedContentInset.top,
1120 -cv.adjustedContentInset.top), maxY)
1121 }
1122 DispatchQueue.main.async { [weak self] in
1123 guard let self,
1124 let ip = self.dataSource.indexPath(for: .message(target.id))
1125 else { return }
1126 self.collectionView.scrollToItem(at: ip, at: .top, animated: false)
1127 }
1128 } else {
1129 // The latch (see stickToBottom) keeps re-landing the bottom
1130 // as estimated heights settle to real ones over the next
1131 // passes — the one-shot pin this replaced drifted on
1132 // image-heavy threads and opens landed short of the tail.
1133 pinToBottom(animated: false)
1134 }
1135 unreadCutoff = nil
1136 let count = current.count
1137 let cb = onFirstLayout
1138 DispatchQueue.main.async { cb?(count) }
1139 #if DEBUG
1140 DispatchQueue.main.async { [weak self] in self?.dumpHeights() }
1141 #endif
1142 }
1143
1144 // ---- insets ---------------------------------------------------------
1145
1146 private var topOverlay: CGFloat = 0
1147 private var bottomOverlay: CGFloat = 0
1148 private var keyboardUp = false
1149
1150 fileprivate func refreshInsets() { updateInsets(force: true) }
1151
1152 fileprivate func setOverlays(top: CGFloat, bottom: CGFloat) {
1153 // Deadband, not equality: the glass overlays are measured by
1154 // SwiftUI probes and arrive with sub-point jitter across renders.
1155 // Every inset write invalidates the WHOLE compositional layout —
1156 // jitter-driven writes mid-scroll were the correction storm that
1157 // read as bouncing (animated) and then jank (suppressed).
1158 guard abs(top - topOverlay) > 0.5 || abs(bottom - bottomOverlay) > 0.5 else { return }
1159 topOverlay = top
1160 bottomOverlay = bottom
1161 updateInsets()
1162 }
1163
1164 fileprivate func windowChanged() { updateInsets() }
1165
1166 // The gap between the newest message and the composer capsule must
1167 // read IDENTICAL with the keyboard up or down (Paul, 2026-07-21) —
1168 // but the two states arrive via different machinery (home-bar
1169 // handoff at rest, UIKit keyboard avoidance when up), so each gets
1170 // its own trim. Both target ~10pt above the glass. (History: these
1171 // were dialled to 38/0 on device — 38 turned out to be a safe-area
1172 // DOUBLE-COUNT being cancelled by eye, see below; 4/0 with the
1173 // double-count gone lands the identical geometry.)
1174 private static let composerTrimResting: CGFloat = 4
1175 private static let composerTrimKeyboard: CGFloat = 0
1176
1177 // Overlay insets, declared as ADDITIONAL safe areas on the host
1178 // controller. The host already INHERITS the window's safe areas
1179 // (status bar/island above, home bar below) from its position, so
1180 // only the glass overlays are added here — adding safeTop/safeBottom
1181 // again was the double-count that floated short chats a quarter
1182 // screen down (long chats hid it: nobody rests at their top, and the
1183 // bottom's extra 34 was absorbed by the old dialled trim). SwiftUI
1184 // still owns the keyboard-driven FRAME shrink; automatic adjustment
1185 // turns these into content insets.
1186 private func updateInsets(force: Bool = false) {
1187 guard let cv = collectionView, let host else { return }
1188 let inset = UIEdgeInsets(top: topOverlay, left: 0,
1189 bottom: max(0, bottomOverlay
1190 - (keyboardUp ? Self.composerTrimKeyboard
1191 : Self.composerTrimResting)),
1192 right: 0)
1193 guard force || inset != host.additionalSafeAreaInsets else { return }
1194 let keepBottom = stickToBottom || atBottomNow
1195 host.additionalSafeAreaInsets = inset
1196 if keepBottom, !cv.isTracking {
1197 pinToBottom(animated: false)
1198 }
1199 }
1200
1201 @objc private func keyboardWillShow() {
1202 keyboardUp = true
1203 updateInsets()
1204 }
1205
1206 @objc private func keyboardWillHide() {
1207 keyboardUp = false
1208 updateInsets()
1209 }
1210
1211 #if DEBUG
1212 // Height-pipeline truth for the frozen-chat triage (2026-07-21):
1213 // every apply writes each row's measured height + laid frame + shape
1214 // flags to Library/Logs/feed-heights.txt — pullable over devicectl,
1215 // so 'which rows collapse and what are they' stops being a guess.
1216 fileprivate func dumpHeights() {
1217 guard let cv = collectionView, dataSource != nil else { return }
1218 var out = ["== heights rows=\(current.count) contentH=\(cv.contentSize.height) =="]
1219 for (i, m) in current.enumerated() {
1220 guard let ip = dataSource.indexPath(for: .message(m.id)) else { continue }
1221 let h = heights[.message(m.id)] ?? -1
1222 let f = cv.layoutAttributesForItem(at: ip)?.frame ?? .zero
1223 let t = String(m.text.prefix(24)).replacingOccurrences(of: "\n", with: " ")
1224 out.append(String(format: "#%d id=%d h=%.0f y=%.0f fh=%.0f atts=%d reply=%@ act=%d rx=%d '%@'",
1225 i, m.id, h, f.origin.y, f.height, m.attachments.count,
1226 m.reply == nil ? "n" : "y", m.action ? 1 : 0,
1227 m.reactions.count, t))
1228 }
1229 let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
1230 .appendingPathComponent("Logs", isDirectory: true)
1231 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1232 try? out.joined(separator: "\n")
1233 .write(to: dir.appendingPathComponent("feed-heights.txt"),
1234 atomically: true, encoding: .utf8)
1235 }
1236 #endif
1237
1238 fileprivate func boundsHeightChanged(wasAtBottom: Bool) {
1239 // The keyboard/composer resize the feed via safe areas; keep the
1240 // bottom edge anchored only if the reader was on it.
1241 if wasAtBottom, !collectionView.isTracking {
1242 pinToBottom(animated: false)
1243 }
1244 }
1245
1246 // The latch's per-layout landing: offset write only, skipped while
1247 // the reader (or an animated pin) is steering.
1248 fileprivate func stickPinIfNeeded() {
1249 guard stickToBottom, !animatingPin, let cv = collectionView,
1250 !cv.isTracking, !cv.isDecelerating else { return }
1251 let target = max(-cv.adjustedContentInset.top,
1252 cv.contentSize.height + cv.adjustedContentInset.bottom
1253 - cv.bounds.height)
1254 if abs(cv.contentOffset.y - target) > 0.5 {
1255 #if DEBUG
1256 ScrollCrumbs.log("stickPin write y=\(Int(target)) " +
1257 "(was \(Int(cv.contentOffset.y)))")
1258 #endif
1259 cv.contentOffset.y = target
1260 }
1261 }
1262
1263 fileprivate var atBottomNow: Bool { gapFromBottom() < 44 }
1264 }
1265
1266 // A row cell that OWNS its UIHostingController — created once, rootView
1267 // swapped on every (re)configure. Two things UIHostingConfiguration
1268 // cannot give: safeAreaRegions = [] (the short-chat content-eviction
1269 // fix — see rowView), and a persistent hosting view across recycles
1270 // (a rootView swap, not a contentView teardown).
1271 final class FeedHostedCell: UICollectionViewCell {
1272 private var hostVC: UIHostingController<AnyView>?
1273
1274 override init(frame: CGRect) {
1275 super.init(frame: frame)
1276 // No system platter, ever: the context-menu lift snapshots the
1277 // CELL's configured background before our targeted preview
1278 // engages — any non-clear default is the row-wide flash behind
1279 // the lifting bubble.
1280 backgroundConfiguration = .clear()
1281 // And no hover/highlight effect: the long-press "highlight"
1282 // phase (pre-menu) paints a full-row rounded platter — the
1283 // frame-by-frame aura from Paul's 2026-07-21 recording. Its
1284 // default shape is the cell's bounds; Messages shows none, so
1285 // none here.
1286 var style = UIHoverStyle(effect: .automatic)
1287 style.isEnabled = false
1288 hoverStyle = style
1289 }
1290
1291 required init?(coder: NSCoder) { fatalError("unused") }
1292
1293 override func updateConfiguration(using state: UICellConfigurationState) {
1294 backgroundConfiguration = .clear()
1295 }
1296
1297 func set(root: AnyView, parent: UIViewController?, clips: Bool = false) {
1298 if let hostVC {
1299 hostVC.view.clipsToBounds = clips
1300 // A recycle re-purposes this tree for a DIFFERENT message —
1301 // never animate the diff (rows used to pin .id per message
1302 // for this; the pin's full remount cost ~7ms per recycle).
1303 var tx = Transaction()
1304 tx.disablesAnimations = true
1305 withTransaction(tx) { hostVC.rootView = root }
1306 return
1307 }
1308 let hc = UIHostingController(rootView: root)
1309 hc.view.backgroundColor = .clear
1310 hc.view.clipsToBounds = clips
1311 // The load-bearing line: hosted cell content must never
1312 // rearrange itself around the window's safe areas — the layout's
1313 // frame is the whole truth of where a row sits.
1314 hc.safeAreaRegions = []
1315 // The measured layout owns sizing; the hosting view just fills.
1316 hc.sizingOptions = []
1317 hostVC = hc
1318 parent?.addChild(hc)
1319 hc.view.frame = contentView.bounds
1320 hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1321 contentView.insertSubview(hc.view, at: 0)
1322 if let parent { hc.didMove(toParent: parent) }
1323 }
1324
1325 }
1326
1327 // The stock collection view: initial-anchor timing + keep-bottom across
1328 // height changes, nothing else. No transforms anywhere.
1329 final class StockFeedCollectionView: UICollectionView {
1330 weak var coordinator: Coordinator?
1331 private var lastSize: CGSize = .zero
1332 private var wasAtBottomBeforeResize = false
1333
1334 override func didMoveToWindow() {
1335 super.didMoveToWindow()
1336 // The window's safe areas feed the rest insets; they only exist
1337 // once attached.
1338 coordinator?.windowChanged()
1339 }
1340
1341 #if DEBUG
1342 // Forensics: prove touches are (or are not) reaching the feed at
1343 // all — an invisible blocker in front of the list means hitTest
1344 // here goes silent while the user is clearly touching the screen.
1345 override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1346 let hit = super.hitTest(point, with: event)
1347 ScrollCrumbs.throttled("hit",
1348 "hitTest (\(Int(point.x)),\(Int(point.y))) -> " +
1349 "\(hit.map { String(describing: type(of: $0)) } ?? "nil")")
1350 return hit
1351 }
1352 #endif
1353
1354 override func layoutSubviews() {
1355 if bounds.size != lastSize, lastSize != .zero {
1356 wasAtBottomBeforeResize = coordinator?.atBottomNow ?? false
1357 }
1358 // Cell placement must NEVER animate on ambient transactions: a
1359 // layout pass reached from inside a SwiftUI animated update (the
1360 // glass overlays' measurement probes, safe-area changes, any
1361 // withAnimation upstream) inherits that CA transaction, and
1362 // materialising cells visibly BOUNCE into place instead of just
1363 // being a scrolling list. Deliberate motion here is scroll
1364 // animation (contentOffset), which this does not touch.
1365 UIView.performWithoutAnimation {
1366 super.layoutSubviews()
1367 }
1368 if bounds.size != lastSize {
1369 let hadSize = lastSize != .zero
1370 lastSize = bounds.size
1371 if hadSize {
1372 coordinator?.boundsHeightChanged(wasAtBottom: wasAtBottomBeforeResize)
1373 }
1374 }
1375 if bounds.height > 0, coordinator?.needsInitialAnchor == true {
1376 coordinator?.performInitialAnchor()
1377 }
1378 coordinator?.stickPinIfNeeded()
1379 }
1380 }
1381}
1382
1383
1384// The measured layout: a single column of EXACT frames from the coordinator's
1385// height cache — no estimated self-sizing, so nothing ever corrects, and
1386// nothing corrects means nothing bounces, flies, or snaps while scrolling.
1387// prepare() is a running sum (O(rows), plain arithmetic); queries binary-search
1388// the y-sorted frames.
1389final class FeedMeasuredLayout: UICollectionViewLayout {
1390 var heightFor: ((StockChatFeed.FeedItem, CGFloat) -> CGFloat)!
1391 var itemAt: ((Int) -> StockChatFeed.FeedItem?)!
1392 private var frames: [CGRect] = []
1393 private var contentHeight: CGFloat = 0
1394
1395 override func prepare() {
1396 super.prepare()
1397 guard let cv = collectionView else { return }
1398 let width = cv.bounds.width
1399 let count = cv.numberOfItems(inSection: 0)
1400 frames.removeAll(keepingCapacity: true)
1401 frames.reserveCapacity(count)
1402 var y: CGFloat = 0
1403 for i in 0..<count {
1404 let h = itemAt(i).map { heightFor($0, width) } ?? 0
1405 frames.append(CGRect(x: 0, y: y, width: width, height: h))
1406 y += h
1407 }
1408 contentHeight = y
1409 }
1410
1411 override var collectionViewContentSize: CGSize {
1412 CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight)
1413 }
1414
1415 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
1416 guard !frames.isEmpty else { return [] }
1417 var lo = 0, hi = frames.count - 1, start = frames.count
1418 while lo <= hi {
1419 let mid = (lo + hi) / 2
1420 if frames[mid].maxY >= rect.minY {
1421 start = mid
1422 hi = mid - 1
1423 } else {
1424 lo = mid + 1
1425 }
1426 }
1427 var out: [UICollectionViewLayoutAttributes] = []
1428 var i = start
1429 while i < frames.count, frames[i].minY <= rect.maxY {
1430 out.append(attributes(at: i))
1431 i += 1
1432 }
1433 return out
1434 }
1435
1436 override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
1437 guard indexPath.item < frames.count else { return nil }
1438 return attributes(at: indexPath.item)
1439 }
1440
1441 private func attributes(at index: Int) -> UICollectionViewLayoutAttributes {
1442 let a = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0))
1443 a.frame = frames[index]
1444 return a
1445 }
1446
1447 // Height changes (keyboard, composer) never re-measure; width changes do.
1448 override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
1449 newBounds.width != collectionView?.bounds.width
1450 }
1451}
1452
1453
1454#if DEBUG
1455// Timestamped scroll forensics for device pulls (the menu-debug.txt
1456// pattern): buffered in memory, flushed to Library/Logs/scroll-debug.txt
1457// at most ~1/s so logging never becomes the jank it hunts.
1458@MainActor
1459enum ScrollCrumbs {
1460 private static var lines: [String] = []
1461 private static var lastFlush = Date.distantPast
1462 private static var lastByTag: [String: Date] = [:]
1463
1464 static func log(_ s: String) {
1465 let now = Date()
1466 lines.append(String(format: "%.3f %@", now.timeIntervalSince1970, s))
1467 if lines.count > 6000 { lines.removeFirst(3000) }
1468 if now.timeIntervalSince(lastFlush) > 1.0 { flush() }
1469 }
1470
1471 /// Rate-limited variant for chatty sources (hit-tests, offset motion).
1472 static func throttled(_ tag: String, _ s: String, every: TimeInterval = 0.15) {
1473 let now = Date()
1474 if let last = lastByTag[tag], now.timeIntervalSince(last) < every { return }
1475 lastByTag[tag] = now
1476 log(s)
1477 }
1478
1479 static func flush() {
1480 lastFlush = Date()
1481 let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
1482 .appendingPathComponent("Logs", isDirectory: true)
1483 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1484 try? lines.suffix(5000).joined(separator: "\n")
1485 .write(to: dir.appendingPathComponent("scroll-debug.txt"),
1486 atomically: true, encoding: .utf8)
1487 }
1488}
1489#endif
1490
1491// The cross-open row-height cache: heights are deterministic in their key
1492// (content + relation + width + type settings), coordinators are not
1493// (they die with their screens). Bounded by a dumb full reset — measures
1494// re-fill lazily and correctness never depends on a hit.
1495//
1496// PERSISTED to Library/Caches across launches: the first open of a
1497// 200-message chat is ~200 fresh SwiftUI measurements — 3.1s on a device
1498// Debug build (Paul's ledger, 2026-07-21) — and a process-local cache pays
1499// that again for every chat on every launch. Heights are pure functions of
1500// their key, so the only cross-launch hazard is the CODE changing what a
1501// row measures; the stamp (the app binary's mtime) discards the whole file
1502// on any new install, so a dev build can never serve heights measured by a
1503// different layout. One accepted transient: a link row persisted at its
1504// unfurled (grown) height re-opens with the card not yet cached, showing
1505// the gap until the unfurl resolves — the same rowGrewLate path that grew
1506// it then confirms or corrects it.
1507@MainActor
1508enum FeedHeightCache {
1509 static var store: [String: CGFloat] = [:]
1510 private static var saveScheduled = false
1511
1512 static func remember(_ key: String, _ h: CGFloat) {
1513 if store.count > 8000 { store.removeAll(keepingCapacity: true) }
1514 store[key] = h
1515 scheduleSave()
1516 }
1517
1518 // ---- disk persistence ----------------------------------------------
1519
1520 private static var fileURL: URL {
1521 FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
1522 .appendingPathComponent("feed-heights-v1.json")
1523 }
1524
1525 // The invalidation stamp: any new binary = any possible row-layout
1526 // change = start over. Cheap, coarse, and safe by construction.
1527 private static var binaryStamp: String {
1528 guard let exe = Bundle.main.executableURL,
1529 let date = (try? exe.resourceValues(forKeys: [.contentModificationDateKey]))?
1530 .contentModificationDate else { return "unstamped" }
1531 return String(Int(date.timeIntervalSince1970))
1532 }
1533
1534 private struct Snapshot: Codable {
1535 var stamp: String
1536 var heights: [String: CGFloat]
1537 }
1538
1539 /// Kick the disk load at app boot — merges on main well before any
1540 /// chat opens, and never blocks the first frame. Fresh measurements
1541 /// always win over disk (merge fills gaps only).
1542 static func warm() {
1543 let url = fileURL
1544 let stamp = binaryStamp
1545 Task.detached(priority: .utility) {
1546 guard let data = try? Data(contentsOf: url),
1547 let snap = try? JSONDecoder().decode(Snapshot.self, from: data),
1548 snap.stamp == stamp else { return }
1549 await MainActor.run {
1550 store.merge(snap.heights) { current, _ in current }
1551 }
1552 }
1553 }
1554
1555 private static func scheduleSave() {
1556 guard !saveScheduled else { return }
1557 saveScheduled = true
1558 Task { @MainActor in
1559 // Debounce past the measure burst a thread-open produces.
1560 try? await Task.sleep(nanoseconds: 3_000_000_000)
1561 saveScheduled = false
1562 flushNow()
1563 }
1564 }
1565
1566 /// Write the current store out (also the test hook — persistence must
1567 /// be checkable without waiting out the debounce).
1568 static func flushNow() {
1569 let snap = Snapshot(stamp: binaryStamp, heights: store)
1570 let url = fileURL
1571 Task.detached(priority: .utility) {
1572 if let data = try? JSONEncoder().encode(snap) {
1573 try? data.write(to: url, options: .atomic)
1574 }
1575 }
1576 }
1577}
1578