import Combine import LChatCore import SwiftUI import UIKit // The iOS feed container, promoted from the /freshpane experiment // (2026-07-20): a STOCK, UNFLIPPED UICollectionView in natural order — // oldest at the top, newest at the bottom — with standard safe-area // behaviour and no transforms. The flipped container (FlippedChatFeed) was // architecturally right about one thing — UIKit owns the scroll — but its // flip bought bug classes (mirrored animations, counter-flip cells, // inset algebra) that the field test rejected, while this shape scrolled // silky on device. What the flip gave structurally, this container does // explicitly, in four small rules: // // - OPEN lands at the bottom on the first real layout (offset math inside // layoutSubviews — never scrollToItem there; forcing layout from inside a // layout pass is the recursion crash the flipped round hit). // - ARRIVALS: appends never move the viewport in an unflipped list; when // you're at the bottom (or it's your own send / you're composing) we // scroll to reveal, otherwise you're reading and nothing yanks. // - SCROLL-BACK prepends compensate the offset by the REAL laid-out height // delta in the same pass — and while a fling is live, pages buffer and // flush when the scroll settles, so the compensation write never kills // deceleration (the "locked scroll" feel the flipped rounds fought). // - The KEYBOARD/composer are SwiftUI safe areas; a height change keeps the // bottom edge anchored iff you were at it. // // Rows are the FeedMessageRow surface the flipped container hosts. (The // FeedLab strip-and-layer ladder and its debug commands are GONE — the // UserDefaults level survived builds and twice wedged real phones into the // diagnostic renderer; the LChatFeedTests harness + FeedMenuUITests rig // replaced everything it was for.) struct StockChatFeed: UIViewControllerRepresentable { let model: ChatFeedModel let interaction: FeedInteraction let controller: FeedScrollController let hooks: FeedHooks // The header/composer glass the feed extends beneath (screen-measured); // they come back as content insets so content RESTS between them. let topOverlay: CGFloat let bottomOverlay: CGFloat let composerFocused: Bool let unreadCutoff: Int64? let onFirstLayout: (Int) -> Void enum FeedItem: Hashable { case message(Int64) case typing } #if DEBUG // The container ladder's knob (RowLadderTests): swap what a row RENDERS // while the container machinery stays identical, so the scroll-cost // bisect runs rung by rung in the harness. Test-target-only, in-memory, // never persisted, unreachable from any app UI — the feedLabLevel // UserDefaults lesson (a persisted level twice wedged real phones). @MainActor static var testRowOverride: ((ChatFeedModel.Message, ChatFeedModel.Message?) -> AnyView)? #endif func makeCoordinator() -> Coordinator { Coordinator(model: model, interaction: interaction, hooks: hooks, unreadCutoff: unreadCutoff) } // A view CONTROLLER hosts the feed so the glass overlays can be declared // as additionalSafeAreaInsets — real, UIKit-visible safe areas. That is // what Messages-style full-bleed actually is: automatic inset adjustment // gets honest geometry, and the system's top-edge treatment degrades to // the subtle status-bar fade instead of the half-screen squash it // produced when our scroll view had .never insets and no safe areas // (the 2026-07-20 hunt). func makeUIViewController(context: Context) -> FeedHostController { let coordinator = context.coordinator coordinator.hooks = hooks coordinator.composerFocused = composerFocused coordinator.onFirstLayout = onFirstLayout // Seed the config key NOW — leaving the dummy meant the first // updateUIViewController "changed" it and reconfigured everything, // re-measuring every row a second time on every open (half the // 2026-07-21 freeze). coordinator.configKey = ConfigKey(themeName: hooks.themeName, fontSize: hooks.chatFontSize, isGroup: hooks.isGroup) let cv = coordinator.build() let host = FeedHostController() host.collectionView = cv host.onJumpTap = { [weak coordinator] in coordinator?.jumpTapped() } coordinator.host = host controller.target = coordinator coordinator.setOverlays(top: topOverlay, bottom: bottomOverlay) coordinator.subscribe() return host } func updateUIViewController(_ host: FeedHostController, context: Context) { let coordinator = context.coordinator coordinator.hooks = hooks coordinator.composerFocused = composerFocused coordinator.onFirstLayout = onFirstLayout coordinator.setOverlays(top: topOverlay, bottom: bottomOverlay) let key = ConfigKey(themeName: hooks.themeName, fontSize: hooks.chatFontSize, isGroup: hooks.isGroup) if coordinator.configKey != key { coordinator.configKey = key coordinator.reconfigureAll() } } struct ConfigKey: Equatable { var themeName: String var fontSize: CGFloat var isGroup: Bool } final class FeedHostController: UIViewController { var collectionView: UICollectionView! // The jump-to-latest caret is a REAL UIKit control here, not a // SwiftUI overlay: an overlay button loses touch arbitration while // the list is moving — taps stopped the scroll instead of firing. // A UIButton beside the scroll view takes taps mid-fling, // unconditionally. let jumpButton = UIButton(type: .system) var onJumpTap: (() -> Void)? override func loadView() { let container = UIView() collectionView.translatesAutoresizingMaskIntoConstraints = false container.addSubview(collectionView) // Liquid glass on 26 (a 36pt lens is a dot — the pill-sized // refraction hazard doesn't apply at this scale), blur below. var config: UIButton.Configuration if #available(iOS 26.0, *) { config = .glass() } else { config = .plain() let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) blur.isUserInteractionEnabled = false blur.layer.cornerRadius = 18 blur.clipsToBounds = true blur.translatesAutoresizingMaskIntoConstraints = false jumpButton.insertSubview(blur, at: 0) NSLayoutConstraint.activate([ blur.topAnchor.constraint(equalTo: jumpButton.topAnchor), blur.bottomAnchor.constraint(equalTo: jumpButton.bottomAnchor), blur.leadingAnchor.constraint(equalTo: jumpButton.leadingAnchor), blur.trailingAnchor.constraint(equalTo: jumpButton.trailingAnchor), ]) } config.image = UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration( pointSize: 14, weight: .semibold)) jumpButton.configuration = config jumpButton.translatesAutoresizingMaskIntoConstraints = false jumpButton.alpha = 0 jumpButton.accessibilityLabel = "Jump to latest" jumpButton.addAction(UIAction { [weak self] _ in self?.onJumpTap?() }, for: .touchUpInside) container.addSubview(jumpButton) NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: container.topAnchor), collectionView.bottomAnchor.constraint(equalTo: container.bottomAnchor), collectionView.leadingAnchor.constraint(equalTo: container.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: container.trailingAnchor), jumpButton.widthAnchor.constraint(equalToConstant: 36), jumpButton.heightAnchor.constraint(equalToConstant: 36), // Right edge flush with the composer capsule's right edge // (the cluster's 10pt horizontal padding). jumpButton.trailingAnchor.constraint( equalTo: container.trailingAnchor, constant: -10), // Dialled by eye with Paul (2026-07-21): low over the // composer's shoulder, right edge flush with the capsule. jumpButton.bottomAnchor.constraint( equalTo: container.safeAreaLayoutGuide.bottomAnchor, constant: -4), ]) view = container } } @MainActor final class Coordinator: NSObject, UICollectionViewDelegate, UIGestureRecognizerDelegate, FeedScrolling { let model: ChatFeedModel let interaction: FeedInteraction var hooks: FeedHooks var composerFocused = false var configKey = ConfigKey(themeName: "", fontSize: 0, isGroup: false) var onFirstLayout: ((Int) -> Void)? private(set) var collectionView: StockFeedCollectionView! weak var host: UIViewController? private var dataSource: UICollectionViewDiffableDataSource! private var current: [ChatFeedModel.Message] = [] private var typingNames: [String] = [] private var cancellables = Set() private var firstApplyDone = false fileprivate var needsInitialAnchor = false private var unreadCutoff: Int64? private var jumpVisible = false // The stick-to-bottom latch: from the moment a chat opens (or the // reader pins to the latest) until they scroll away, every layout // pass re-lands the bottom. This is the stock container's version of // the flip's structural promise — estimated row heights settle to // real ones for several passes after open (worst with images), and // without the latch the "bottom" drifts out from under the offset. private var stickToBottom = false // True while an animated pin is in flight — the latch must not snap // the animation to its end mid-frame. private var animatingPin = false // A publish with prepends held back while a fling is live — flushed // when the scroll settles (or the reader reaches the loaded top). private var pendingMessages: [ChatFeedModel.Message]? private var lastPairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] init(model: ChatFeedModel, interaction: FeedInteraction, hooks: FeedHooks, unreadCutoff: Int64?) { self.model = model self.interaction = interaction self.hooks = hooks self.unreadCutoff = unreadCutoff } // ---- construction --------------------------------------------------- func build() -> UICollectionView { // A MEASURED layout — no estimated self-sizing anywhere. The // estimate→real correction machinery was the whole night's // symptom generator (cells bouncing/flying/snapping into place as // they materialised, opens drifting off the bottom): rows are // measured up front against the real width and the layout only // ever places exact frames, so there is nothing to correct. let layout = FeedMeasuredLayout() layout.heightFor = { [weak self] item, width in self?.height(for: item, width: width) ?? 60 } layout.itemAt = { [weak self] index in guard let self, let ds = self.dataSource else { return nil } return ds.itemIdentifier(for: IndexPath(item: index, section: 0)) } let cv = StockFeedCollectionView(frame: .zero, collectionViewLayout: layout) cv.coordinator = self cv.backgroundColor = .clear cv.allowsSelection = false cv.keyboardDismissMode = .interactive // AUTOMATIC adjustment against the host controller's // additionalSafeAreaInsets (the measured glass overlays): honest // UIKit geometry, which is also what tames the system top-edge // treatment into the Messages-style subtle fade — every manual // variant (.never + hand insets) fed it degenerate geometry and // it squashed and stacked rows over half the screen. cv.contentInsetAdjustmentBehavior = .automatic // .automatic silently STOPS adjusting an axis whose content fits // on screen (the scrollable-axes rule) — short chats lost their // safe-area insets entirely and floated. Always-bounce keeps the // vertical axis "scrollable" (and is the right feel for a chat // feed regardless — Messages bounces short threads too). cv.alwaysBounceVertical = true cv.delegate = self // The long-press that opens the message menu is a plain UIKit // recognizer on the LIST — the one layer that reliably sees // held touches here. The rig proved the SwiftUI row gesture is // starved under the scroll view's arbitration (real synthesized // presses never fired it, though taps flow fine), and // UIContextMenuInteractions are out entirely (2026-07-21). let press = UILongPressGestureRecognizer( target: self, action: #selector(feedLongPressed(_:))) press.minimumPressDuration = 0.4 cv.addGestureRecognizer(press) // The row swipe (rightward quick-reply, leftward times peek) is // a UIKit pan on the LIST, like the long-press above — NOT a // SwiftUI DragGesture on rows. The SwiftUI gesture activated on // ANY direction ≥18pt (its horizontal-only filter ran inside // the handler, after the claim), and when it won the arbitration // race against the scroll pan a fast vertical fling died // entirely: crumbs showed 575pt of vertical travel consumed by // rowSwipe with the scroll pan never reaching .began — Paul's // Contributors fast-scroll freeze (2026-07-21). This recognizer // structurally cannot take vertical motion: it refuses to begin // unless the touch's velocity is horizontally dominant, so the // scroll pan owns every vertical gesture by construction. let swipe = UIPanGestureRecognizer( target: self, action: #selector(rowSwiped(_:))) swipe.delegate = self cv.addGestureRecognizer(swipe) rowSwipeRecognizer = swipe #if DEBUG // Scroll forensics (the freeze hunt above): an OBSERVING second // target on the scroll pan — state transitions land in the // crumb file alongside touch arrivals and offset motion, so a // dead scroll tells us who ate it. cv.panGestureRecognizer.addTarget(self, action: #selector(panCrumb(_:))) #endif collectionView = cv NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) let registration = UICollectionView.CellRegistration { [weak self] cell, _, item in guard let self else { return } // The entrance animation touches these; reuse must never // inherit a mid-animation state. cell.contentView.alpha = 1 cell.contentView.transform = .identity // One row builder for the live cell AND the sizing pass // (height(for:)) — measured frames and rendered rows can // never disagree. The typing row clips: it hides by // COLLAPSING to zero height, and an unclipped hosted view // paints its dots straight through a zero-height cell (the // stuck-dots sighting, 2026-07-21). cell.set(root: self.rowView(for: item), parent: self.host, clips: item == .typing) } dataSource = UICollectionViewDiffableDataSource(collectionView: cv) { cv, indexPath, item in cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) } return cv } // ⚠️ Rows are hosted by owned UIHostingControllers with // safeAreaRegions = [] — NOT UIHostingConfiguration. Hosted // configuration content PARTICIPATES in the window's safe areas, so // any cell whose frame intersected the top band (every short chat — // its rows sit at the top of the screen) had its SwiftUI content // evicted downward and compressed to fewer lines: truncated bubbles // overlapping the next row, permanently (the 2026-07-21 short-chat // bunching; long chats never showed it because their visible cells // sit at the bottom, and healed on recycle). UIHostingConfiguration // has no safe-area switch (.ignoresSafeArea() inside it only half // pushes back); UIHostingController does. Cell content is positioned // by the LAYOUT's frames; the safe area must never re-arrange it // from inside. private func rowView(for item: FeedItem) -> AnyView { switch item { case .typing: let label = configKey.isGroup ? typingSummary(typingNames) : nil return AnyView( FeedTypingRow(label: label, themeName: hooks.themeName, chatFontSize: hooks.chatFontSize)) case .message(let id): guard let idx = index(of: id) else { return AnyView(EmptyView()) } let m = current[idx] let prev = idx > 0 ? current[idx - 1] : nil #if DEBUG if let override = StockChatFeed.testRowOverride { return override(m, prev) } #endif // No .id(m.id) here — pinning identity per message forced a // full SwiftUI remount on every cell recycle, the single // biggest per-row scroll cost on the ladder (~7ms/row). The // two things the pin bought are held elsewhere: the recycle // swap runs in a no-animation transaction (FeedHostedCell.set, // so one message's tree never morph-animates into another's), // and rows' @State resets when their content identity changes // (the attachment views' onChange guards in MessageBubble). return AnyView( FeedMessageRow(message: m, previous: prev, hooks: rowHooks(for: m), interaction: interaction)) } } // The per-row hooks: unfurl resolutions re-measure their row — the // measured layout can't see content growing inside a hosted view. // peekUnfurl makes the RE-measure include the now-cached card. private func rowHooks(for m: ChatFeedModel.Message) -> FeedHooks { var h = hooks let id = m.id let base = hooks.unfurl h.unfurl = { [weak self] url in let card = await base(url) if card != nil { self?.rowGrewLate(id) } return card } return h } // ---- measured heights ---------------------------------------------- private var heights: [FeedItem: CGFloat] = [:] private var measuredWidth: CGFloat = 0 fileprivate func height(for item: FeedItem, width: CGFloat) -> CGFloat { guard width > 0 else { return 60 } // pre-layout probe; never rendered if width != measuredWidth { heights.removeAll() bubbleRects.removeAll() measuredWidth = width } if let h = heights[item] { return h } // The permanent typing row measures ZERO while nobody types — // never the degenerate-height clamp below. if case .typing = item, typingNames.isEmpty { heights[item] = 0 return 0 } // Cross-open cache first: heights are pure functions of content // + layout inputs, and coordinators die with their screens — a // 200-row chat re-measured on EVERY open (the other half of the // 2026-07-21 freeze). rowGrewLate overwrites the entry when an // unfurl lands. let cacheKey = heightCacheKey(for: item, width: width) if let cached = cacheKey.flatMap({ FeedHeightCache.store[$0] }) { heights[item] = cached return cached } // A FRESH hosting controller per measurement, deliberately: a // persistent sizing host reconfigured across rows swaps the // hosted tree asynchronously (the .id identity change), and the // fitting pass could answer with the PREVIOUS row's height — // tall bubbles then rendered truncated mid-sentence in frames // borrowed from shorter neighbours. A fresh host has exactly one // identity to size — and the SAME safeAreaRegions = [] as the // live cells, so measure and render can never disagree. let hc = UIHostingController(rootView: rowView(for: item)) hc.safeAreaRegions = [] // An unbounded height asks for the IDEAL size at this width — // a bounded proposal lets SwiftUI Text satisfy it by TRUNCATING // (tall bubbles measured short, rendered cut off mid-sentence). let h = ceil(hc.sizeThatFits( in: CGSize(width: width, height: .greatestFiniteMagnitude)).height) // A degenerate answer must never produce an invisible row. let clamped = max(h, 8) heights[item] = clamped if let cacheKey { FeedHeightCache.remember(cacheKey, clamped) } return clamped } private func heightCacheKey(for item: FeedItem, width: CGFloat) -> String? { #if DEBUG // Ladder rungs render different content for the same message — // their heights must never seed the real cross-open cache. if StockChatFeed.testRowOverride != nil { return nil } #endif guard case .message(let id) = item, let idx = index(of: id) else { return nil // the typing row is stateful; never cached globally } let m = current[idx] let prev = idx > 0 ? current[idx - 1] : nil let sep = timeSeparator(at: m.at, previous: prev?.at) != nil let startsRun = sep || !ChatFeedModel.sameSender(prev, m) // FNV-1a, not String.hashValue: hashValue is seeded per PROCESS, // so persisted keys silently never matched across real launches. var textHash: UInt64 = 0xcbf29ce484222325 for b in m.text.utf8 { textHash = (textHash ^ UInt64(b)) &* 0x100000001b3 } return "\(id)|\(m.at)|\(m.edited)|\(textHash)|" + "\(m.reactions.count)|\(m.attachments.count)|" + "\(m.reply != nil)|\(m.action)|\(hooks.foldsAsMuted(m))|" + "\(sep)|\(startsRun)|\(width)|\(hooks.chatFontSize)|" + "\(hooks.themeName)|\(hooks.isGroup)" } // A row's content resolved something AFTER its measurement (an unfurl // card). Re-measure; only a REAL height change touches the layout — // which also breaks the sizing-pass-fires-unfurl feedback loop. private func rowGrewLate(_ id: Int64) { let item = FeedItem.message(id) guard let cv = collectionView, dataSource != nil, let old = heights[item] else { return } heights[item] = nil // The stale pre-unfurl height must not answer from the global // cache — drop it so the re-measure (which sees the cached card // via peekUnfurl) writes the grown value. if let key = heightCacheKey(for: item, width: measuredWidth) { FeedHeightCache.store[key] = nil } bubbleRects[item] = nil let new = height(for: item, width: measuredWidth) guard abs(new - old) > 0.5 else { return } var snap = dataSource.snapshot() guard snap.itemIdentifiers.contains(item) else { return } snap.reconfigureItems([item]) dataSource.apply(snap, animatingDifferences: false) cv.collectionViewLayout.invalidateLayout() // At the tail the latch re-lands the bottom next pass, so a card // that grew the newest row stays visible (the old unfurl re-pin). } func subscribe() { model.$messages .removeDuplicates() .sink { [weak self] in self?.setMessages($0) } .store(in: &cancellables) model.$typing .removeDuplicates() .sink { [weak self] in self?.setTyping($0) } .store(in: &cancellables) model.$senderNames.removeDuplicates().map { _ in () } .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) .dropFirst(4) .sink { [weak self] in self?.reconfigureAll() } .store(in: &cancellables) // An empty chat has no first layout to anchor — end the mount // measurement when the (empty) history lands instead. model.$ready .filter { $0 } .prefix(1) .sink { [weak self] _ in guard let self, !self.firstApplyDone, self.current.isEmpty else { return } let cb = self.onFirstLayout DispatchQueue.main.async { cb?(0) } } .store(in: &cancellables) } // ---- data ----------------------------------------------------------- private func index(of id: Int64) -> Int? { var lo = 0, hi = current.count - 1 while lo <= hi { let mid = (lo + hi) / 2 if current[mid].id == id { return mid } if current[mid].id < id { lo = mid + 1 } else { hi = mid - 1 } } return nil } private func items(for list: [ChatFeedModel.Message]) -> [FeedItem] { // The typing row is PERMANENT — always the tail item, measured at // zero height when nobody's typing. Typing beats arrive // constantly in agent chats, and when the row came and went the // churn was a structural apply every few seconds (batch updates // stacked cells; clean reloads read as scroll delay). A // reconfigure of one zero-or-36pt row costs nothing. list.map { .message($0.id) } + [.typing] } private func setMessages(_ new: [ChatFeedModel.Message]) { // Prepends compensate the offset, and an offset write kills a live // fling (and fights a finger) — so while either is in flight, a // prepend-carrying publish waits. Appends/edits flow through // unchanged applies regardless (they never move the viewport here). if let cv = collectionView, cv.isDecelerating || cv.isTracking, let newOldest = new.first?.id, let oldOldest = current.first?.id, newOldest < oldOldest { pendingMessages = new return } pendingMessages = nil applyMessages(new) } fileprivate func flushPending() { // At rest ONLY — a structural reload racing the deceleration // animator composites cells from two different offsets (the // ghost-doubled rows at the top edge). scrollSettled re-fires // this the moment the scroll comes to rest. guard let cv = collectionView, !cv.isTracking, !cv.isDecelerating, let pending = pendingMessages else { return } pendingMessages = nil applyMessages(pending) } private func applyMessages(_ new: [ChatFeedModel.Message]) { let oldNewest = current.last?.id let oldOldest = current.first?.id current = new let newestChanged = new.last?.id != oldNewest // A typer's arriving message retires their dots IN THIS apply: // items and heights change once, so the pin scroll + entrance // rise + dot removal play as one motion. Collapsing on the // separate typing-off event a beat later was the pane jump // (Paul, 2026-07-21). The model's own clear follows and no-ops. if newestChanged, let last = new.last, !last.mine, typingNames.contains(last.name) { typingNames.removeAll { $0 == last.name } heights[.typing] = nil } let newestIsMine = new.last?.mine == true let prepended = new.first?.id ?? Int64.max apply(hasPrepends: oldOldest.map { prepended < $0 } ?? false, newestChanged: newestChanged, newestIsMine: newestIsMine) } private func setTyping(_ names: [String]) { let appeared = typingNames.isEmpty && !names.isEmpty let cleared = !typingNames.isEmpty && names.isEmpty typingNames = names guard let cv = collectionView, dataSource != nil, firstApplyDone else { return } let wasAtBottom = gapFromBottom() < 44 let reduceMotion = UIAccessibility.isReduceMotionEnabled let refresh = { [weak self] in guard let self, let cv = self.collectionView, self.dataSource != nil else { return } self.heights[.typing] = nil var snap = self.dataSource.snapshot() guard snap.itemIdentifiers.contains(.typing) else { return } snap.reconfigureItems([.typing]) self.dataSource.apply(snap, animatingDifferences: false) cv.collectionViewLayout.invalidateLayout() } // The dots bow out with a quick fade before the row collapses — // the hard blink next to the arrival's rise-in read as a snap. // (The height cache keeps the row's height until refresh runs, // so an interleaved arrival can't collapse it mid-fade.) let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) if cleared, !reduceMotion, let cell = cv.cellForItem(at: typingIP) { UIView.animate(withDuration: 0.12, animations: { cell.contentView.alpha = 0 }, completion: { _ in // The registration's reuse reset also restores alpha; // this covers the cell staying in place. cell.contentView.alpha = 1 refresh() }) } else { refresh() } // The bubble appearing while you're at the tail (or composing — // you're at the conversation's tail even if the keyboard hides // it) should be seen; reading history is never yanked. if appeared, wasAtBottom || composerFocused, !cv.isTracking { pinToBottom(animated: !reduceMotion) } } // Non-animated applies throughout — the flipped rounds proved batch // animations under frequent applies strand cells mid-fade and mirror // directions; reveals are done by scrolling, not by item animation. private func apply(hasPrepends: Bool, newestChanged: Bool, newestIsMine: Bool) { guard dataSource != nil else { return } let old = dataSource.snapshot().itemIdentifiers var snap = NSDiffableDataSourceSnapshot() snap.appendSections([0]) let new = items(for: current) snap.appendItems(new) let oldSet = Set(old) var pairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] pairs.reserveCapacity(current.count) var changed: [FeedItem] = [] for (i, m) in current.enumerated() { let pair = (msg: m, prevID: i > 0 ? current[i - 1].id : nil) pairs[m.id] = pair if oldSet.contains(.message(m.id)), let last = lastPairs[m.id], last.msg != pair.msg || last.prevID != pair.prevID { changed.append(.message(m.id)) } } lastPairs = pairs for item in changed { heights[item] = nil; bubbleRects[item] = nil } snap.reconfigureItems(changed) guard firstApplyDone else { dataSource.apply(snap, animatingDifferences: false) if !current.isEmpty { firstApplyDone = true needsInitialAnchor = true collectionView.setNeedsLayout() } return } let wasAtBottom = gapFromBottom() < 44 // NO structural change goes through batch updates — not prepends, // not deletions, not appends, not the typing row: performBatch on // a custom layout computes frames against a half-transitioned // item list (numberOfItems old, identifiers new), and any window // where frames and cells disagree eventually shows up as stacked // rows (the short-chat report: typing churn in agent chats is the // busiest tail change there is). A clean reload rebuilds the // visible handful — cheap, off the per-frame path — and the // batch path survives only for reconfigure-only applies, which // move nothing. let structural = old.count != new.count || oldSet != Set(new) if structural { let beforeH = collectionView.contentSize.height dataSource.applySnapshotUsingReloadData(snap) collectionView.layoutIfNeeded() if hasPrepends, !wasAtBottom { // Older pages above: hold the reader's rows exactly still // by the REAL laid-out growth. let delta = collectionView.contentSize.height - beforeH if delta > 0 { collectionView.contentOffset.y += delta } } else if newestChanged, wasAtBottom || newestIsMine || composerFocused { // Reveal the new bottom: your own send from anywhere, any // growth while composing, and ordinary arrivals only when // the reader is already at the tail — reading history // never yanks. let reduceMotion = UIAccessibility.isReduceMotionEnabled pinToBottom(animated: !reduceMotion) if !reduceMotion, wasAtBottom { animateArrivalEntrance() } } else if wasAtBottom { pinToBottom(animated: false) } } else { dataSource.apply(snap, animatingDifferences: false) } updateJumpVisible() } // The arrival beat, as OUR OWN cell animation (never a batch // update's): the newest row rises from beneath the composer with a // fade. Runs outside any layout pass; a missing cell just means the // pin scroll will bring it in plainly. private func animateArrivalEntrance() { guard let cv = collectionView else { return } cv.layoutIfNeeded() let count = cv.numberOfItems(inSection: 0) // The LAST item is the permanent typing row — the newest message // sits one above it. Animating count-1 "worked" invisibly (a // zero-height cell), so every arrival just snapped in (Paul, // 2026-07-21). guard count >= 2, let cell = cv.cellForItem(at: IndexPath(item: count - 2, section: 0)) else { return } cell.contentView.alpha = 0 cell.contentView.transform = CGAffineTransform(translationX: 0, y: 14) UIView.animate(withDuration: 0.32, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: [.allowUserInteraction]) { cell.contentView.alpha = 1 cell.contentView.transform = .identity } } func reconfigureAll() { guard dataSource != nil else { return } heights.removeAll() bubbleRects.removeAll() var snap = dataSource.snapshot() snap.reconfigureItems(snap.itemIdentifiers) dataSource.apply(snap, animatingDifferences: false) collectionView?.collectionViewLayout.invalidateLayout() } // ---- scrolling ------------------------------------------------------ private func gapFromBottom() -> CGFloat { guard let cv = collectionView else { return 0 } let bottom = cv.contentSize.height + cv.adjustedContentInset.bottom return bottom - (cv.contentOffset.y + cv.bounds.height) } func pinToBottom(animated: Bool) { guard let cv = collectionView else { return } stickToBottom = true let target = max(-cv.adjustedContentInset.top, cv.contentSize.height + cv.adjustedContentInset.bottom - cv.bounds.height) guard abs(cv.contentOffset.y - target) > 0.5 else { return } if animated { // Our own curve (the old feed's 0.15s ease-out), and a // completion that ALWAYS clears the latch guard — UIKit's // setContentOffset(animated:) never calls its end delegate // for a zero-distance scroll, which stranded the guard and // killed the caret. animatingPin = true UIView.animate(withDuration: 0.15, delay: 0, options: [.curveEaseOut]) { cv.contentOffset = CGPoint(x: 0, y: target) } completion: { _ in self.animatingPin = false } } else { cv.contentOffset = CGPoint(x: 0, y: target) } } func scrollTo(id: Int64) { stickToBottom = false guard let ip = dataSource.indexPath(for: .message(id)) else { return } // Animated only for NEAR jumps; far targets snap (estimated rows // recalibrate on the way and animated long hops land rough). let count = collectionView.numberOfItems(inSection: 0) collectionView.scrollToItem(at: ip, at: .centeredVertically, animated: count - ip.item <= 60) } func scrollViewDidScroll(_ scrollView: UIScrollView) { #if DEBUG ScrollCrumbs.throttled("offset", "offset y=\(Int(scrollView.contentOffset.y))") #endif updateJumpVisible() } #if DEBUG @objc func panCrumb(_ g: UIPanGestureRecognizer) { let v = g.velocity(in: g.view) let t = g.translation(in: g.view) ScrollCrumbs.log("pan state=\(g.state.rawValue) " + "t=(\(Int(t.x)),\(Int(t.y))) v=(\(Int(v.x)),\(Int(v.y)))") } // The measure-vs-render audit (the 2026-07-21 separator-overlap // screenshot): any visible cell whose laid-out height disagrees // with its content's ideal height is the bug — crumb it with its // cache key so a device pull names the row and the mismatch. func auditHeights() { guard let cv = collectionView, dataSource != nil else { return } for cell in cv.visibleCells { guard let ip = cv.indexPath(for: cell), let item = dataSource.itemIdentifier(for: ip), case .message(let id) = item, let hosted = cell.contentView.subviews.first else { continue } let ideal = hosted.sizeThatFits(CGSize( width: cv.bounds.width, height: .greatestFiniteMagnitude)).height if abs(cell.frame.height - ideal) > 3 { ScrollCrumbs.log("HEIGHT MISMATCH id=\(id) " + "cell=\(Int(cell.frame.height)) ideal=\(Int(ideal)) " + "key=\(heightCacheKey(for: item, width: measuredWidth) ?? "nil")") } } } #endif // Jump-to-latest visibility, from scroll AND from content changes — // a page landing or a delete shifts the gap without any scroll event, // which left the caret stale. private func updateJumpVisible() { let show = gapFromBottom() > 400 if show != jumpVisible { jumpVisible = show guard let button = (host as? FeedHostController)?.jumpButton else { return } UIView.animate(withDuration: 0.12, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) { button.alpha = show ? 1 : 0 } } } // A mid-fling tap must redirect the scroll, not queue behind it: // kill the deceleration first, then pin. fileprivate func jumpTapped() { if let cv = collectionView, cv.isDecelerating { cv.setContentOffset(cv.contentOffset, animated: false) } pinToBottom(animated: true) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { // The reader took the wheel: nothing holds the bottom against // their finger. stickToBottom = false animatingPin = false } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { animatingPin = false } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollSettled() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) { if !willDecelerate { scrollSettled() } } private func scrollSettled() { flushPending() // Coming to rest on the tail re-arms the latch — the bottom is // sticky when you're on it (iMessage feel), free when you're not. if gapFromBottom() < 44 { stickToBottom = true } #if DEBUG auditHeights() #endif } // ---- long-press menu (OWNED — see FeedMenu.swift) ------------------ // Fired from the ROW's own SwiftUI long-press (hooks.presentMenu): // every system context-menu route proved broken for bubbles on // device (2026-07-21 — the item pipeline platters the whole CELL, // manual interactions never fire), so the menu is drawn by // FeedMenuOverlay: the bubble's own pixels float in place, the // reaction palette above, a labelled card below — the web app's // long-press menu. Structure mirrors MessageBubble's menuItems // (reaction palette → Reply/Copy/Edit → Mute/Report → mine-only // Delete); keep the two in step. private weak var openMenu: FeedMenuOverlay? // ---- the row swipe (quick-reply / times peek) ---------------------- private weak var rowSwipeRecognizer: UIPanGestureRecognizer? // The message under the finger when the swipe began (reply target). private var swipeTargetID: Int64? // The structural fix for the stolen-scroll freeze: this pan only // BEGINS on horizontally-dominant motion, so a vertical fling can // never be claimed away from the scroll pan. func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { guard g === rowSwipeRecognizer, let pan = g as? UIPanGestureRecognizer, let cv = collectionView else { return true } let v = pan.velocity(in: cv) return abs(v.x) > abs(v.y) * 1.6 && abs(v.x) > 60 } @objc private func rowSwiped(_ g: UIPanGestureRecognizer) { guard let cv = collectionView else { return } #if DEBUG ScrollCrumbs.throttled("rowPan", "rowPan state=\(g.state.rawValue) " + "t=(\(Int(g.translation(in: cv).x)),\(Int(g.translation(in: cv).y)))") #endif switch g.state { case .began: swipeTargetID = nil if let ip = cv.indexPathForItem(at: g.location(in: cv)), case .message(let id) = dataSource.itemIdentifier(for: ip), let idx = index(of: id), !current[idx].action, !hooks.foldsAsMuted(current[idx]) { swipeTargetID = id } fallthrough case .changed: let x = g.translation(in: cv).x if x > 0, let id = swipeTargetID { // Rightward: the bubble follows the finger, arming // quick-reply at the threshold (haptic once). let drag = min(72, x * 0.6) interaction.replyDrag[id] = drag interaction.timesReveal = 0 if drag >= 40, !interaction.replyArmed { interaction.replyArmed = true UIImpactFeedbackGenerator(style: .medium).impactOccurred() } } else if x < 0 { // Leftward: peek the times gutter. interaction.timesReveal = min(FeedMessageRow.timesGutter, -x) if let id = swipeTargetID { interaction.replyDrag[id] = 0 } } case .ended, .cancelled, .failed: if let id = swipeTargetID, (interaction.replyDrag[id] ?? 0) >= 40, let idx = index(of: id) { hooks.startReply(current[idx]) } interaction.replyArmed = false let id = swipeTargetID swipeTargetID = nil withAnimation(.spring(response: 0.35, dampingFraction: 0.72)) { if let id { interaction.replyDrag[id] = 0 } interaction.timesReveal = 0 } default: break } } @objc private func feedLongPressed(_ g: UILongPressGestureRecognizer) { menuCrumb("press state=\(g.state.rawValue)") guard g.state == .began, let cv = collectionView else { return } guard let ip = cv.indexPathForItem(at: g.location(in: cv)) else { menuCrumb("no indexPath at \(g.location(in: cv))"); return } guard case .message(let id) = dataSource.itemIdentifier(for: ip) else { menuCrumb("item at \(ip.item) not a message"); return } menuCrumb("press → message \(id)") presentMenu(for: id) } #if DEBUG // The silent-bail autopsy (2026-07-21): sim-green, device-dead — // every step writes a breadcrumb pullable over devicectl. private var menuCrumbs: [String] = [] func menuCrumb(_ line: String) { menuCrumbs.append(line) let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] .appendingPathComponent("Logs", isDirectory: true) try? FileManager.default.createDirectory( at: dir, withIntermediateDirectories: true) try? menuCrumbs.suffix(40).joined(separator: "\n") .write(to: dir.appendingPathComponent("menu-debug.txt"), atomically: true, encoding: .utf8) } #else func menuCrumb(_ line: String) {} #endif // The bubble's rect within its row, measured the same deterministic // way row heights are (the hosted SwiftUI tree exposes NO UIKit // bubble view on device). bubbleRow's geometry constants: 12pt side // padding + the 40pt minimum spacer on the far side; 3pt bottom // padding, bubble bottom-anchored in the row. Cached like heights. private var bubbleRects: [FeedItem: CGRect] = [:] func bubbleRect(for item: FeedItem) -> CGRect? { if let hit = bubbleRects[item] { return hit } guard case .message(let id) = item, let idx = index(of: id), measuredWidth > 64 else { return nil } let m = current[idx] guard !m.action, !hooks.foldsAsMuted(m) else { return nil } let prev = idx > 0 ? current[idx - 1] : nil let sep = timeSeparator(at: m.at, previous: prev?.at) let startsRun = sep != nil || !ChatFeedModel.sameSender(prev, m) let bubble = MessageBubble( message: m, showSender: hooks.isGroup && startsRun, senderName: hooks.senderName(m), theme: .named(hooks.themeName), blobSource: hooks.blobSource, unfurl: hooks.unfurl, peekUnfurl: hooks.peekUnfurl) .environment(\.chatFontSize, hooks.chatFontSize) // Mention chips show the viewer's name for the person — the // measured height must see the same text the row renders. .environment(\.mentionIndex, hooks.mentionIndex()) let hc = UIHostingController(rootView: bubble) hc.safeAreaRegions = [] let size = hc.sizeThatFits(in: CGSize( width: measuredWidth - 64, height: .greatestFiniteMagnitude)) let rowH = height(for: item, width: measuredWidth) guard size.width > 1, size.height > 1, size.height <= rowH else { return nil } let rect = CGRect(x: m.mine ? measuredWidth - 12 - size.width : 12, y: rowH - 3 - size.height, width: size.width, height: size.height) bubbleRects[item] = rect return rect } func presentMenu(for id: Int64) { guard openMenu == nil else { menuCrumb("bail: menu open"); return } guard let idx = index(of: id) else { menuCrumb("bail: no index"); return } guard let container = host?.view else { menuCrumb("bail: no host"); return } let m = current[idx] guard !m.action, !hooks.foldsAsMuted(m) else { menuCrumb("bail: action/muted"); return } let actions = hooks.actions(m) guard let ip = dataSource.indexPath(for: .message(id)), let cell = collectionView.cellForItem(at: ip), let hosted = cell.contentView.subviews.first else { menuCrumb("bail: no cell/hosted"); return } guard let rect = bubbleRect(for: .message(id)) else { menuCrumb("bail: no bubbleRect (w=\(measuredWidth) " + "rowH=\(height(for: .message(id), width: measuredWidth)))") return } menuCrumb("presenting id=\(id) rect=\(rect)") let image = UIGraphicsImageRenderer(bounds: rect).image { _ in hosted.drawHierarchy(in: hosted.bounds, afterScreenUpdates: false) } let frameInHost = cell.contentView.convert(rect, to: container) var items: [FeedMenuOverlay.Item] = [] if let reply = actions.onReply { items.append(.init(title: "Reply…", icon: "arrowshape.turn.up.left", handler: reply)) } if let copy = actions.onCopy { items.append(.init(title: "Copy", icon: "doc.on.doc", handler: copy)) } if actions.canEdit, let edit = actions.onEdit { items.append(.init(title: "Edit…", icon: "pencil", handler: edit)) } if let mute = actions.onMutePerson { items.append(.init(title: actions.mutePersonTitle, icon: "bell.slash", handler: mute)) } if let report = actions.onReport { items.append(.init(title: "Report", icon: "flag", handler: report)) } if m.mine, let delete = actions.onDelete { items.append(.init(title: "Delete…", icon: "trash", destructive: true, handler: delete)) } UIImpactFeedbackGenerator(style: .medium).impactOccurred() let overlay = FeedMenuOverlay( container: container, bubbleImage: image, bubbleFrame: frameInHost, mine: m.mine, reactions: actions.onReact != nil ? ChatFeedModel.allowedReactions : [], onReact: actions.onReact, items: items, onDismiss: { [weak self] in self?.openMenu = nil }) container.addSubview(overlay) openMenu = overlay } // Scroll-back: nearing the top of the loaded rows pages more in (the // model's cooldown paces it). A buffered page flushes as the reader // arrives, momentum or not — a blank top is worse than a settle. func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard firstApplyDone else { return } if indexPath.item < 8 { // No flushing from here, deferred or otherwise: pages apply // at scroll-settle only (flushPending's own guard). Reaching // the loaded top mid-fling shows the current top for a beat; // the page lands the moment the scroll rests. let model = model Task { await model.loadOlder() } } } // ---- first layout --------------------------------------------------- // Runs INSIDE the first real layout pass: offset writes only (the // no-forced-layout contract from the flipped container's crash). fileprivate func performInitialAnchor() { needsInitialAnchor = false guard let cv = collectionView else { return } if let cutoff = unreadCutoff, let target = current.first(where: { $0.at > cutoff && !$0.mine }), let ip = dataSource.indexPath(for: .message(target.id)) { // First unread at the TOP — reading continues where it left // off. Estimated frames are fine for the first beat; one // exact correction lands after the pass. if let attr = cv.layoutAttributesForItem(at: ip) { let maxY = max(-cv.adjustedContentInset.top, cv.contentSize.height + cv.adjustedContentInset.bottom - cv.bounds.height) cv.contentOffset.y = min(max(attr.frame.minY - cv.adjustedContentInset.top, -cv.adjustedContentInset.top), maxY) } DispatchQueue.main.async { [weak self] in guard let self, let ip = self.dataSource.indexPath(for: .message(target.id)) else { return } self.collectionView.scrollToItem(at: ip, at: .top, animated: false) } } else { // The latch (see stickToBottom) keeps re-landing the bottom // as estimated heights settle to real ones over the next // passes — the one-shot pin this replaced drifted on // image-heavy threads and opens landed short of the tail. pinToBottom(animated: false) } unreadCutoff = nil let count = current.count let cb = onFirstLayout DispatchQueue.main.async { cb?(count) } #if DEBUG DispatchQueue.main.async { [weak self] in self?.dumpHeights() } #endif } // ---- insets --------------------------------------------------------- private var topOverlay: CGFloat = 0 private var bottomOverlay: CGFloat = 0 private var keyboardUp = false fileprivate func refreshInsets() { updateInsets(force: true) } fileprivate func setOverlays(top: CGFloat, bottom: CGFloat) { // Deadband, not equality: the glass overlays are measured by // SwiftUI probes and arrive with sub-point jitter across renders. // Every inset write invalidates the WHOLE compositional layout — // jitter-driven writes mid-scroll were the correction storm that // read as bouncing (animated) and then jank (suppressed). guard abs(top - topOverlay) > 0.5 || abs(bottom - bottomOverlay) > 0.5 else { return } topOverlay = top bottomOverlay = bottom updateInsets() } fileprivate func windowChanged() { updateInsets() } // The gap between the newest message and the composer capsule must // read IDENTICAL with the keyboard up or down (Paul, 2026-07-21) — // but the two states arrive via different machinery (home-bar // handoff at rest, UIKit keyboard avoidance when up), so each gets // its own trim. Both target ~10pt above the glass. (History: these // were dialled to 38/0 on device — 38 turned out to be a safe-area // DOUBLE-COUNT being cancelled by eye, see below; 4/0 with the // double-count gone lands the identical geometry.) private static let composerTrimResting: CGFloat = 4 private static let composerTrimKeyboard: CGFloat = 0 // Overlay insets, declared as ADDITIONAL safe areas on the host // controller. The host already INHERITS the window's safe areas // (status bar/island above, home bar below) from its position, so // only the glass overlays are added here — adding safeTop/safeBottom // again was the double-count that floated short chats a quarter // screen down (long chats hid it: nobody rests at their top, and the // bottom's extra 34 was absorbed by the old dialled trim). SwiftUI // still owns the keyboard-driven FRAME shrink; automatic adjustment // turns these into content insets. private func updateInsets(force: Bool = false) { guard let cv = collectionView, let host else { return } let inset = UIEdgeInsets(top: topOverlay, left: 0, bottom: max(0, bottomOverlay - (keyboardUp ? Self.composerTrimKeyboard : Self.composerTrimResting)), right: 0) guard force || inset != host.additionalSafeAreaInsets else { return } let keepBottom = stickToBottom || atBottomNow host.additionalSafeAreaInsets = inset if keepBottom, !cv.isTracking { pinToBottom(animated: false) } } @objc private func keyboardWillShow() { keyboardUp = true updateInsets() } @objc private func keyboardWillHide() { keyboardUp = false updateInsets() } #if DEBUG // Height-pipeline truth for the frozen-chat triage (2026-07-21): // every apply writes each row's measured height + laid frame + shape // flags to Library/Logs/feed-heights.txt — pullable over devicectl, // so 'which rows collapse and what are they' stops being a guess. fileprivate func dumpHeights() { guard let cv = collectionView, dataSource != nil else { return } var out = ["== heights rows=\(current.count) contentH=\(cv.contentSize.height) =="] for (i, m) in current.enumerated() { guard let ip = dataSource.indexPath(for: .message(m.id)) else { continue } let h = heights[.message(m.id)] ?? -1 let f = cv.layoutAttributesForItem(at: ip)?.frame ?? .zero let t = String(m.text.prefix(24)).replacingOccurrences(of: "\n", with: " ") out.append(String(format: "#%d id=%d h=%.0f y=%.0f fh=%.0f atts=%d reply=%@ act=%d rx=%d '%@'", i, m.id, h, f.origin.y, f.height, m.attachments.count, m.reply == nil ? "n" : "y", m.action ? 1 : 0, m.reactions.count, t)) } let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] .appendingPathComponent("Logs", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? out.joined(separator: "\n") .write(to: dir.appendingPathComponent("feed-heights.txt"), atomically: true, encoding: .utf8) } #endif fileprivate func boundsHeightChanged(wasAtBottom: Bool) { // The keyboard/composer resize the feed via safe areas; keep the // bottom edge anchored only if the reader was on it. if wasAtBottom, !collectionView.isTracking { pinToBottom(animated: false) } } // The latch's per-layout landing: offset write only, skipped while // the reader (or an animated pin) is steering. fileprivate func stickPinIfNeeded() { guard stickToBottom, !animatingPin, let cv = collectionView, !cv.isTracking, !cv.isDecelerating else { return } let target = max(-cv.adjustedContentInset.top, cv.contentSize.height + cv.adjustedContentInset.bottom - cv.bounds.height) if abs(cv.contentOffset.y - target) > 0.5 { #if DEBUG ScrollCrumbs.log("stickPin write y=\(Int(target)) " + "(was \(Int(cv.contentOffset.y)))") #endif cv.contentOffset.y = target } } fileprivate var atBottomNow: Bool { gapFromBottom() < 44 } } // A row cell that OWNS its UIHostingController — created once, rootView // swapped on every (re)configure. Two things UIHostingConfiguration // cannot give: safeAreaRegions = [] (the short-chat content-eviction // fix — see rowView), and a persistent hosting view across recycles // (a rootView swap, not a contentView teardown). final class FeedHostedCell: UICollectionViewCell { private var hostVC: UIHostingController? override init(frame: CGRect) { super.init(frame: frame) // No system platter, ever: the context-menu lift snapshots the // CELL's configured background before our targeted preview // engages — any non-clear default is the row-wide flash behind // the lifting bubble. backgroundConfiguration = .clear() // And no hover/highlight effect: the long-press "highlight" // phase (pre-menu) paints a full-row rounded platter — the // frame-by-frame aura from Paul's 2026-07-21 recording. Its // default shape is the cell's bounds; Messages shows none, so // none here. var style = UIHoverStyle(effect: .automatic) style.isEnabled = false hoverStyle = style } required init?(coder: NSCoder) { fatalError("unused") } override func updateConfiguration(using state: UICellConfigurationState) { backgroundConfiguration = .clear() } func set(root: AnyView, parent: UIViewController?, clips: Bool = false) { if let hostVC { hostVC.view.clipsToBounds = clips // A recycle re-purposes this tree for a DIFFERENT message — // never animate the diff (rows used to pin .id per message // for this; the pin's full remount cost ~7ms per recycle). var tx = Transaction() tx.disablesAnimations = true withTransaction(tx) { hostVC.rootView = root } return } let hc = UIHostingController(rootView: root) hc.view.backgroundColor = .clear hc.view.clipsToBounds = clips // The load-bearing line: hosted cell content must never // rearrange itself around the window's safe areas — the layout's // frame is the whole truth of where a row sits. hc.safeAreaRegions = [] // The measured layout owns sizing; the hosting view just fills. hc.sizingOptions = [] hostVC = hc parent?.addChild(hc) hc.view.frame = contentView.bounds hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] contentView.insertSubview(hc.view, at: 0) if let parent { hc.didMove(toParent: parent) } } } // The stock collection view: initial-anchor timing + keep-bottom across // height changes, nothing else. No transforms anywhere. final class StockFeedCollectionView: UICollectionView { weak var coordinator: Coordinator? private var lastSize: CGSize = .zero private var wasAtBottomBeforeResize = false override func didMoveToWindow() { super.didMoveToWindow() // The window's safe areas feed the rest insets; they only exist // once attached. coordinator?.windowChanged() } #if DEBUG // Forensics: prove touches are (or are not) reaching the feed at // all — an invisible blocker in front of the list means hitTest // here goes silent while the user is clearly touching the screen. override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let hit = super.hitTest(point, with: event) ScrollCrumbs.throttled("hit", "hitTest (\(Int(point.x)),\(Int(point.y))) -> " + "\(hit.map { String(describing: type(of: $0)) } ?? "nil")") return hit } #endif override func layoutSubviews() { if bounds.size != lastSize, lastSize != .zero { wasAtBottomBeforeResize = coordinator?.atBottomNow ?? false } // Cell placement must NEVER animate on ambient transactions: a // layout pass reached from inside a SwiftUI animated update (the // glass overlays' measurement probes, safe-area changes, any // withAnimation upstream) inherits that CA transaction, and // materialising cells visibly BOUNCE into place instead of just // being a scrolling list. Deliberate motion here is scroll // animation (contentOffset), which this does not touch. UIView.performWithoutAnimation { super.layoutSubviews() } if bounds.size != lastSize { let hadSize = lastSize != .zero lastSize = bounds.size if hadSize { coordinator?.boundsHeightChanged(wasAtBottom: wasAtBottomBeforeResize) } } if bounds.height > 0, coordinator?.needsInitialAnchor == true { coordinator?.performInitialAnchor() } coordinator?.stickPinIfNeeded() } } } // The measured layout: a single column of EXACT frames from the coordinator's // height cache — no estimated self-sizing, so nothing ever corrects, and // nothing corrects means nothing bounces, flies, or snaps while scrolling. // prepare() is a running sum (O(rows), plain arithmetic); queries binary-search // the y-sorted frames. final class FeedMeasuredLayout: UICollectionViewLayout { var heightFor: ((StockChatFeed.FeedItem, CGFloat) -> CGFloat)! var itemAt: ((Int) -> StockChatFeed.FeedItem?)! private var frames: [CGRect] = [] private var contentHeight: CGFloat = 0 override func prepare() { super.prepare() guard let cv = collectionView else { return } let width = cv.bounds.width let count = cv.numberOfItems(inSection: 0) frames.removeAll(keepingCapacity: true) frames.reserveCapacity(count) var y: CGFloat = 0 for i in 0.. [UICollectionViewLayoutAttributes]? { guard !frames.isEmpty else { return [] } var lo = 0, hi = frames.count - 1, start = frames.count while lo <= hi { let mid = (lo + hi) / 2 if frames[mid].maxY >= rect.minY { start = mid hi = mid - 1 } else { lo = mid + 1 } } var out: [UICollectionViewLayoutAttributes] = [] var i = start while i < frames.count, frames[i].minY <= rect.maxY { out.append(attributes(at: i)) i += 1 } return out } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard indexPath.item < frames.count else { return nil } return attributes(at: indexPath.item) } private func attributes(at index: Int) -> UICollectionViewLayoutAttributes { let a = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0)) a.frame = frames[index] return a } // Height changes (keyboard, composer) never re-measure; width changes do. override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { newBounds.width != collectionView?.bounds.width } } #if DEBUG // Timestamped scroll forensics for device pulls (the menu-debug.txt // pattern): buffered in memory, flushed to Library/Logs/scroll-debug.txt // at most ~1/s so logging never becomes the jank it hunts. @MainActor enum ScrollCrumbs { private static var lines: [String] = [] private static var lastFlush = Date.distantPast private static var lastByTag: [String: Date] = [:] static func log(_ s: String) { let now = Date() lines.append(String(format: "%.3f %@", now.timeIntervalSince1970, s)) if lines.count > 6000 { lines.removeFirst(3000) } if now.timeIntervalSince(lastFlush) > 1.0 { flush() } } /// Rate-limited variant for chatty sources (hit-tests, offset motion). static func throttled(_ tag: String, _ s: String, every: TimeInterval = 0.15) { let now = Date() if let last = lastByTag[tag], now.timeIntervalSince(last) < every { return } lastByTag[tag] = now log(s) } static func flush() { lastFlush = Date() let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] .appendingPathComponent("Logs", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? lines.suffix(5000).joined(separator: "\n") .write(to: dir.appendingPathComponent("scroll-debug.txt"), atomically: true, encoding: .utf8) } } #endif // The cross-open row-height cache: heights are deterministic in their key // (content + relation + width + type settings), coordinators are not // (they die with their screens). Bounded by a dumb full reset — measures // re-fill lazily and correctness never depends on a hit. // // PERSISTED to Library/Caches across launches: the first open of a // 200-message chat is ~200 fresh SwiftUI measurements — 3.1s on a device // Debug build (Paul's ledger, 2026-07-21) — and a process-local cache pays // that again for every chat on every launch. Heights are pure functions of // their key, so the only cross-launch hazard is the CODE changing what a // row measures; the stamp (the app binary's mtime) discards the whole file // on any new install, so a dev build can never serve heights measured by a // different layout. One accepted transient: a link row persisted at its // unfurled (grown) height re-opens with the card not yet cached, showing // the gap until the unfurl resolves — the same rowGrewLate path that grew // it then confirms or corrects it. @MainActor enum FeedHeightCache { static var store: [String: CGFloat] = [:] private static var saveScheduled = false static func remember(_ key: String, _ h: CGFloat) { if store.count > 8000 { store.removeAll(keepingCapacity: true) } store[key] = h scheduleSave() } // ---- disk persistence ---------------------------------------------- private static var fileURL: URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("feed-heights-v1.json") } // The invalidation stamp: any new binary = any possible row-layout // change = start over. Cheap, coarse, and safe by construction. private static var binaryStamp: String { guard let exe = Bundle.main.executableURL, let date = (try? exe.resourceValues(forKeys: [.contentModificationDateKey]))? .contentModificationDate else { return "unstamped" } return String(Int(date.timeIntervalSince1970)) } private struct Snapshot: Codable { var stamp: String var heights: [String: CGFloat] } /// Kick the disk load at app boot — merges on main well before any /// chat opens, and never blocks the first frame. Fresh measurements /// always win over disk (merge fills gaps only). static func warm() { let url = fileURL let stamp = binaryStamp Task.detached(priority: .utility) { guard let data = try? Data(contentsOf: url), let snap = try? JSONDecoder().decode(Snapshot.self, from: data), snap.stamp == stamp else { return } await MainActor.run { store.merge(snap.heights) { current, _ in current } } } } private static func scheduleSave() { guard !saveScheduled else { return } saveScheduled = true Task { @MainActor in // Debounce past the measure burst a thread-open produces. try? await Task.sleep(nanoseconds: 3_000_000_000) saveScheduled = false flushNow() } } /// Write the current store out (also the test hook — persistence must /// be checkable without waiting out the debounce). static func flushNow() { let snap = Snapshot(stamp: binaryStamp, heights: store) let url = fileURL Task.detached(priority: .utility) { if let data = try? JSONEncoder().encode(snap) { try? data.write(to: url, options: .atomic) } } } }