eleven / ocho Public

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

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 // 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.
1414final 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
1484enum 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
1533enum 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