ios: the native chat feed rebuilt — measured stock collection view, owned gestures, profiled-flat rows (#1246)
* ios: flipped UICollectionView chat feed — the anchor/settle machinery goes The native feed's scroll container is now a flipped UICollectionView (scaleX 1, y -1; cells counter-flipped; SwiftUI rows via UIHostingConfiguration) — the Telegram approach. Bottom-start, arrival pinning, keyboard lift, composer growth and scroll-back stability are structural properties of the container instead of hand-built pins, so the #1209/#1210/#1211 settle/anchor bug class dies at the root: - Opening lands settled at the newest message in the FIRST layout pass; no settle phase, no opacity hold, no estimated-height scrollTo. - Scroll-back prepends are data-source APPENDS past the far end — no offset compensation, no stranded viewport. - Arrivals while scrolled up hold the viewport via a real-layout anchor restore; your own send and composing-time growth still pin. - Keyboard and composer insets are ordinary contentInset changes, animated on the keyboard's own curve. Rows are unchanged SwiftUI (MessageBubble + context menu, swipe quick-reply, times peek, jump wave, Quick Look zoom anchors) hosted per cell; cross-row state moves to FeedInteraction, observed by rows only, so drags re-render visible cells and never the screen. Flipped-list taxes paid: VoiceOver reads visually (accessibilityElements override), the scroll indicator is hidden (it would run backwards), scrollsToTop is off. iOS-scoped by design — the Mac feed is its own container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: fix the flipped feed's launch-day trio — cell flip, layout recursion, inset timing - Upside-down rows: assigning a contentConfiguration REPLACES the cell's contentView, so the counter-flip set in the registration handler vanished on (re)configure. FlippedCell now enforces the flip in layoutSubviews, after every configuration swap. The mixed-orientation overlap was also the washed-out 'faded' rendering. - Crash (MetricKit payloads 19:25/19:26Z): performInitialAnchor ran scrollToItem + a second super.layoutSubviews INSIDE the layout pass — recursive collection-view layout, UIKit trap. It now only writes insets/offset (dirties the same commit, never forces a nested pass); the unread-cutoff open gets one exact correction after the pass. - contentSize KVO fired mid-batch-update; its inset write now defers a turn. - Reconfigure only rows whose content (or previous-neighbour boundary) actually changed, not every row every publish. - FeedLab.plainRows: a bisect switch rendering bare Text rows in the same container, to separate container cost from bubble cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: flipped feed round 3 — kill all batch animations and needless offset writes Every diffable apply is now non-animated, and offset writes happen only when strictly needed. UIKit batch animations in the transformed collection view were behind the remaining field reports: - 'faded bubbles' = interrupted insert/reconfigure alpha animations stranding cells half-transparent, - 'arriving from the top' = the default insert animation mirrored through the flip, - 'locked'/janky scrolling = anchor-restore + layoutIfNeeded on every publish writing contentOffset mid-gesture, killing deceleration. Applies now classify the diff: far-end history appends and reconfigure-only passes touch nothing (position holds by construction); at-bottom arrivals hold structurally with NO write; only scrolled-up-with-content-shifts runs the anchor restore, and never while tracking/decelerating. Sinks removeDuplicates so unchanged publishes stop re-hosting cells. Arrival-entrance delight will return as our own cell animation, never performBatchUpdates'. Also: /plainrows (Debug) toggles FeedLab bare-text rows live — the strip-and-layer bisect, same container, to split container cost from bubble cost on the perf ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: flipped feed round 4 — reuse identity, keyboard belt, stuck-offset clamp, feed-dump - Hosted row identity pins to the message id: cell reuse was UPDATING one message's SwiftUI tree into another's and animating the layout diff — bubbles visibly morphing mid-scroll. - The representable now ignores the keyboard safe area directly too, so SwiftUI can never double-compensate on top of the container's own keyboard inset (the feed-floating-over-an-empty-band geometry in Paul's screenshot). - updateInsets clamps a negative gap: a shrinking top inset could leave the offset beyond the rest position, stuck high, and UIKit never self-corrects that. - DEBUG feed-dump: every 4s while the feed is on screen, write the UIKit truth (cv metrics, visible cells' frames/alpha/transforms, all ancestors+siblings to the window) to Library/Logs/feed-dump.txt — pullable over devicectl, so the remaining 'faded' report becomes numbers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: feed-lab ladder — climb from bare rows to full bubbles at runtime /plainrows proved the flipped container smooth with trivial rows, so the strip-and-layer method becomes a runtime ladder over the SAME container: /feedlab 1 bare text rows /feedlab 2 bubble shells (fills + alignment, no MessageBubble) /feedlab 3 real MessageBubble, inert (no gestures/separators/actions/blobs) /feedlab 4 everything except the long-press menu /feedlab 0 the full experience Rungs 1-3 are plain structs that don't observe FeedInteraction, so each rung adds exactly one layer's cost; the rung where lag or fade first appears names the culprit on device, with the perf ledger underneath. Debug builds only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: /freshpane — the from-scratch stock pane over the same model Paul's call: a completely fresh pane. FreshChatPane (Debug) is a stock, UNFLIPPED UICollectionView over the same ChatFeedModel with none of the chat screen's clothes — no header/composer glass, no ignoresSafeArea, no transforms, no gestures, no custom insets. The UIKit feed-dump proved every cell in the real feed renders at alpha 1.0 with no translucent overlay, so the wash is composited inside SwiftUI: if this pane is crisp, the screen's difference list is the suspect pool (added back one at a time); if it's washed too, the cause sits below both. Rows follow the /feedlab ladder (rung 3 shown at level 0 so real bubbles render). Opened with /freshpane from any chat; read-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: fresh pane climbs — full interactive rows in the stock container /feedlab 0 (and 4) in the fresh pane now hosts the real FeedMessageRow — gestures, overlays, interaction observation, image loading, and at 0 the long-press menu — inside the stock unflipped list. The final bisect: smooth here = rows exonerated, the old screen's dressing is the cost; laggy here = the row layers are, and 3→4→0 names which. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: StockChatFeed is the default feed — the fresh pane, promoted Every chat now opens on the stock, unflipped container the /freshpane experiment proved silky on device, grown into a full feed: - Opens at the bottom on the first real layout (offset-only writes inside layoutSubviews — the no-forced-layout contract). - Arrivals reveal via a scroll only when at the tail / own send / composing; reading history never yanks (appends can't move an unflipped viewport). - Scroll-back prepends compensate by the REAL laid-out height delta, and pages buffer while a fling is live, flushing when the scroll settles — the compensation write never kills deceleration. - Keyboard/composer ride standard SwiftUI safe areas; height changes keep the bottom anchored iff the reader was on it. - Rows are the same FeedMessageRow ladder; /feedlab and /freshpane remain as Debug diagnostics, no longer needed to get the good path. The flipped container stays in-tree, dormant, pending the cleanup pass; FeedScrollController now speaks to either through FeedScrolling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: bring-back step 1 — content scrolls under the composer glass again One layout change on the stock feed: ignore the container's bottom safe area (keyboard stays live), letting messages slide beneath the composer glass and home bar with UIKit's automatic adjustment providing the rest inset. The first fade suspect, reintroduced in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: bring-back step 2 (under-header glass) + the stick-to-bottom latch - Content now scrolls under the header pill too (container top edge ignored; automatic adjustment keeps the rest position below it). - Opens land at the bottom FOR REAL: a stick-to-bottom latch re-lands the tail on every layout pass from open until the reader scrolls — estimated row heights settle to real ones over several passes (worst with images), and the one-shot pin drifted out from under them. Released on drag, re-armed when a scroll settles on the tail, never fights an animated pin (iMessage-sticky bottom). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: rest insets — content rests between the header and composer glass The screen measures both glass overlays (probes on the header VStack and the composer cluster) and the container turns them into deterministic content insets, plus the window's status/home bars — the home-bar term handing off to the keyboard when it's up. Content scrolls under both glasses but RESTS clear of them; the stick-to-bottom latch keeps the tail landed through inset changes (composer growth included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: cell placement never inherits ambient animation transactions The rest-inset round's measurement probes write state during SwiftUI's animated updates; a collection layout pass reached inside such a transaction animates cell placement — materialising bubbles bounced into place mid-scroll. layoutSubviews now runs inside UIView.performWithoutAnimation: layout is instant always, deliberate motion stays scroll animation only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: measured layout — no estimated self-sizing, nothing left to correct Every scroll symptom of the night (bubbles bouncing in, flying from the top, snap-jank, opens drifting off the bottom) was UIKit's estimated self-sizing correcting materialised cells, surfacing through whatever animation context it inherited. FeedMeasuredLayout removes the disease: rows are measured up front (one offscreen sizing cell running the SAME UIHostingConfiguration the live cell gets, cached per row, dropped exactly when a row's content or the width/theme/type-size changes), and the layout only ever places exact frames — a running sum + binary search. Late unfurl growth re-measures its row via a wrapped hook, with model.cachedUnfurl making the re-measure see the resolved card (the peekUnfurl seam MessageBubble carried for precisely this). Also: the overlay-probe deadband (sub-point jitter was invalidating the whole layout mid-scroll every render). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: measure each row on a fresh content view — heights can't be borrowed The persistent sizing cell reconfigured across rows swaps its hosted tree asynchronously (the .id identity change), so the fitting pass could answer with the PREVIOUS row's height — tall markdown bubbles rendered truncated mid-sentence inside frames borrowed from shorter neighbours, reading as bubbles shrinking/growing with scroll direction. Each measurement now builds a fresh content view with exactly one identity to size, plus a degenerate-height clamp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: no structural applies inside the display pass; prepends reload clean Overlapping rows at the top edge: a buffered history page was flushed from INSIDE willDisplay — a structural batch update mid-display leaves visible cells wearing pre-update frames. Flushes now defer a turn, and structural changes that shift existing rows (prepends, deletions) apply via applySnapshotUsingReloadData instead of batch-update diffing — the visible handful of cells rebuild cleanly, off the hot path (pages buffer to scroll-settle; buffering now covers a tracking finger too). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: measure ideal heights (sizeThatFits) + flush pages at rest only Two stubborn top-edge defects, two exact causes: - systemLayoutSizeFitting finds the SMALLEST satisfying height and SwiftUI Text satisfies compression by TRUNCATING — tall bubbles measured short and rendered cut off. sizeThatFits(width, unbounded) asks for the ideal height instead. - The deferred page flush still ran mid-fling; a structural reload racing the deceleration animator composites cells from two offsets (the ghost-doubled rows). flushPending now refuses to run unless the scroll is fully at rest; scrollSettled re-fires it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: opt out of the system scroll edge effects on the feed The liquid-glass edge treatment compresses and fades content passing under bars; with a full-bleed feed and manual insets it infers its regions wrong — bubbles visibly squashed and stacked as they left the top of the screen, and a mis-sized region reads as a washed-out feed. topEdgeEffect/bottomEdgeEffect hidden: rows scroll off unmolested, the glass overlays do their own work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: /bareedges — the glass-vs-list bisect Every remaining top-edge artifact sits exactly where bubbles pass under the header glass. /bareedges (Debug) renders the feed BETWEEN the bars with zero insets — nothing ever under the glass — so one look separates 'the list corrupts rows at its edge' from 'the glass distorts what passes beneath it'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the header pill stops lensing the feed — scrollSafeGlass The /bareedges bisect convicted the glass: every ghosted/stacked/ compressed bubble sat in the band passing under the header pill, and the list was clean once nothing rendered beneath it. The liquid-glass lens refracts and duplicates content moving under it. New scrollSafeGlass modifier (the material fallback, on every OS) for glass that floats over the scroll path; the header pill adopts it. The lens stays everywhere it floats over static chrome. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: enforce the edge-effect opt-out every layout pass + strip bar interactions The pill swap changed nothing and /bareedges stayed clean — the corruption engages with FULL-BLEED itself: the nav-bar integration attaches its under-bar edge treatment AFTER build time, overriding the one-shot opt-out. The collection view now re-asserts hidden edge effects on every layout pass and removes any scroll-edge interaction the bar machinery hangs on it or its ancestors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: cede the top band to the system — bottom-only bleed Paul's Messages comparison + 'bubbles disappear halfway up the island' pinned the last corruption: SwiftUI composites the under-bar squash/ fade in the BAR's own layer, sampling the feed from a sibling branch no view-side opt-out reaches (edge-effect hiding, interaction stripping and the pill's material swap all changed nothing). The feed now starts below the header (the /bareedges top, proven clean) and keeps the composer under-scroll (clean all night). headerHeight stays measured for the day a real opt-out exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: full-bleed the Messages way — real safe areas, system fade allowed Paul's read was right: Messages is a plain UIKit list whose content runs to the top with the standard subtle fade — because its scroll view has honest safe-area geometry. Ours squashed rows over half the screen because .never + hand insets + no UIKit-visible safe areas fed the edge treatment garbage. The feed is now hosted by a view controller declaring the measured glass overlays as additionalSafeAreaInsets with automatic adjustment; the edge-effect suppression and interaction stripping are gone — the system fade is WANTED, it just needed real geometry to degrade to the subtle Messages-grade band. Full-bleed top restored (bottom-only bleed rejected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: tighten the rest gap above the composer (10pt trim) The measured composer cluster carries its own breathing room, so the newest message rested doubly padded above the glass. A 10pt trim on the rest inset, both keyboard states. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: layering step 1 — pill liquid glass only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: layering step 2 — jump caret fix + own pin curve Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: layering step 2b — the caret becomes a UIKit control A SwiftUI overlay button loses touch arbitration while the list moves — taps stopped the scroll instead of firing. The caret is now a UIButton in the host controller's container view (blur circle, 44pt, floating above the composer via the safe-area guide in both keyboard states), always tappable mid-fling; a tap kills live deceleration first, then pins. The onJumpVisible plumbing to the screen goes — the container owns the caret's whole life now, so scrolling never re-renders SwiftUI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: caret polish — 36pt liquid glass, 20pt lower, flush with the composer edge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: layering step 3 — arrival entrance beat (own cell animation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: equal message-to-composer gap in both keyboard states The resting and keyboard-up states reach the bottom inset through different machinery (home-bar handoff vs keyboard avoidance), so one trim constant read ~3pt at rest and ~20pt with the keyboard up. Per-state trims, both dialled to ~10pt above the glass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: caret 10pt back up after the trim recalibration moved its anchor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: DEBUG height dump for the frozen-chat triage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: every structural apply is a clean reload — batch updates carry nothing The short-chat stacking report pinned the last stale-frame breeder: tail changes (arrivals, and above all the typing row's constant churn in agent chats) still rode performBatchUpdates, whose mid-update window computes frames against a half-transitioned item list on a custom layout. Structural applies of every shape now reload cleanly; the batch path survives only for reconfigure-only applies, which move nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: permanent typing row + the height dump goes quiet The typing row is now always the tail item, measured at zero height when nobody's typing — typing beats never cause a structural apply again (the churn was a reload or batch every few seconds in agent chats: stacked cells before, scroll delay after). Appearing at the tail still pins for a reader at the bottom or composing. The triage height dump now writes at first layout only — it was walking every row and writing a file on the main thread per publish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the pill lens comes off — refraction cost scales with the backdrop The lab-level discovery invalidated the morning's rung verdicts: rung 1 (pill liquid glass) was only ever scroll-tested over plain lab rows. Real bubbles under the lens = the felt delay, on both days it was tried; material blurs at fixed cost and was silky under real bubbles last night. Lens off, everything else in the stack unchanged — the single-variable test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert "ios: the pill lens comes off — refraction cost scales with the backdrop" This reverts commit 8d8df545d70a3e79ce9bef0738d8c4f8299ade52. * ios: short-chat bunching — hosted cells escape the safe area; honest insets Short chats rendered truncated, overlapping, mid-screen-floating rows and looked frozen. Two root causes, both reproduced by the new in-process LChatFeedTests harness (real StockChatFeed in a window, fake model): - UIHostingConfiguration cell content PARTICIPATES in the window's safe areas: cells intersecting the top band had their SwiftUI content evicted downward and compressed to fewer lines, permanently. Long chats never showed it (visible cells sit at the bottom; wedged top cells heal on recycle). Fix: cells own a UIHostingController with safeAreaRegions = [] (no UIHostingConfiguration switch exists; .ignoresSafeArea() inside it only half-pushes back). Reuse now swaps rootView in place. - Inset geometry: .automatic stops adjusting when content fits on screen (alwaysBounceVertical keeps it honest + chat feeds should bounce), and updateInsets was adding the window safe areas AGAIN on top of the host controller's inherited ones — the mid-screen float, and the reason the resting trim had to be dialled to 38 (= double-counted 34 + 4). Trims re-derived to 4/0; long-chat geometry unchanged by construction. Harness: new LChatFeedTests unit-test target (TEST_HOST = LChat) + ChatFeedModel._testInject (DEBUG-only publisher injection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the long-press menu becomes the collection view's context menu A SwiftUI .contextMenu inside UIKit-hosted cells never fired (the dead long-press report). The menu now lives on the UIKit list itself via the context-menu delegate — Messages-grade preview and scroll arbitration — mirroring MessageBubble's menuItems exactly: reaction palette, Reply/ Copy/Edit, Mute/Report, mine-only Delete. The hosted bubble passes actions: nil in the feed; the reply-thread overlay (plain SwiftUI) keeps the SwiftUI menu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the long-press menu becomes ours — FeedMenuOverlay + a real-touch sim rig Every system context-menu route proved broken for bubbles on device (2026-07-21): the collection item pipeline platters the whole CELL (the 'aura', immune to every preview API — targeted previews, visiblePath, shadowPath, clear backgrounds, hover styles), a manual interaction on the list is starved by the built-in one, per-cell interactions never fire under the hosted SwiftUI gesture graph, and the SwiftUI row long-press is starved by scroll arbitration (real synthesized presses never fired it; taps flow fine). Dermot's call: we don't need the preview. Paul's call: the press belongs to the bubble. So the menu is drawn by US (FeedMenuOverlay): dim the feed, float the bubble's own pixels in place (rect computed the same deterministic way row heights are), reaction palette above, labelled card below — the web app's long-press menu. The trigger is a plain UIKit long-press recognizer on the list (the one layer that reliably sees held touches), row resolved by location; whole-row presses answer as the bubble's. Verified UNATTENDED with real synthesized touches: new FeedMenuUITests drives the actual app in the simulator against the local dev server — open menu, react, dismiss, reopen, outside-tap dismiss — via a new DEBUG-only LCHAT_DEV_JOIN launch-env door (AppState seeds a space natively from a dev join link; empty installs only). The rig reproduced the device's dead press before the fix and passes after it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the feed lab is stripped — the ladder's job is done, its footgun isn't FeedLab (the strip-and-layer diagnostic ladder), FeedLabRow, FreshChatPane, and the /feedlab /plainrows /freshpane /bareedges debug commands are gone. The level persisted in UserDefaults across builds and twice wedged a real phone into the diagnostic renderer — raw markdown, no bubble rects, a dead long-press menu — while looking close enough to real bubbles to burn hours (2026-07-20 and again today, found by the menu breadcrumbs: lab=1). The LChatFeedTests harness and the FeedMenuUITests real-touch rig now cover everything the ladder was for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: the open-freeze — no double measure, and heights survive the screen The 2026-07-21 evening freezes, by the ledger: feed.mount 3.1s for 201 messages, feed.openThread spans to 11s, main-thread hangs to 884ms. The harness put a number on it — a 200-row publish blocked the main thread 278ms in the SIM (a multiple of that on a Debug device build). Two causes, both ours: - Every open measured every row TWICE: makeUIViewController left the ConfigKey dummy, so the first updateUIViewController 'changed' it and reconfigured the world. Seeded at make now. - Heights died with the coordinator, so every RE-open of a parked chat re-measured all rows with fresh UIHostingControllers. FeedHeightCache is the cross-open answer — keyed by content + relation + width + type settings (deterministic), refreshed by rowGrewLate when an unfurl lands, bounded by a dumb reset. Harness numbers: cold open 278ms → ~200ms; re-open 241ms → 8-11ms with byte-identical content height. testTwoHundredMessageMountCost pins both paths (re-open must beat half the cold cost; heights must match exactly). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: row-content ladder + the .id remount fix — 28% off per-row scroll cost The container ladder (RowLadderTests, test-only row override) bisected the scroll jank per materialised row: plain 4.9ms -> +3.7 bubble chrome -> +5.7 MessageBubble internals -> +2.2 row overlays -> +6.8 for the per-message .id remount. Fixes: - Drop .id(m.id) on feed rows; recycle swaps rootView in a no-animation transaction instead, and attachment/link views reset their loaded state on identity reuse (onChange guards) so a recycled cell can never show another message's photo/poster/card. - Gate the touch .contextMenu on actions existing — the feed passes nil (UIKit list owns the long-press menu) and paid ~1ms/recycle for a menu that could never show. - .textSelection(.enabled) is Mac-only now: on touch the long-press menu owns Copy (matching the web convention), and the selection machinery cost ~0.6ms/recycle. prod rung: 23.3 -> 16.7ms/row, all correctness tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: persist the feed height cache — first opens stop paying the measure pass Paul's device ledger showed the enter-a-chat lag directly: feed.mount 3.1s for 201 messages — ~200 fresh SwiftUI measurements on first open, paid again for every chat on every launch because the cache was process-local. FeedHeightCache now persists to Library/Caches (loaded async at boot via warm(), debounced saves), stamped with the binary's mtime so a new install can never serve heights measured by different layout code. Harness: relaunch first open 205ms -> 18ms, heights identical; all correctness tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: the Contributors fast-scroll freeze — row swipe moves to a direction-gated UIKit pan Scroll forensics (DEBUG crumb file, pulled off the device) caught the freeze in the act: 575pt of vertical finger travel consumed by the rows' SwiftUI rowSwipe DragGesture with the scroll pan never reaching .began. The DragGesture 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, fast flicks died entirely — repeatedly on consecutive touches. Same migration as the long-press menu: the swipe is now a UIKit pan on the collection view whose gestureRecognizerShouldBegin requires horizontally-dominant velocity, so vertical motion structurally cannot be claimed away from the scroll pan. Rows keep rendering replyDrag/ timesReveal; the crumb instrumentation stays (DEBUG-only) for the next field pull. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: times-peek gutter fits the timestamp; typing dots stop painting through the collapsed row Two field findings from Paul's device pass (2026-07-21): - The times peek shifted bubbles a fixed 64pt while the timestamp needs ~35 — a dead ~30pt channel between bubble edge and time (screenshot vs Messages). The gutter now measures the widest localized time (20:55 sample covers 24h and AM/PM locales) plus a Messages-tight gap; time font 11 -> 12. - The typing row hides by collapsing to zero height, but its hosted SwiftUI view never clipped, so the dots painted straight through the collapsed cell — 'typing seems stuck and always visible'. The typing cell's hosted view now clips (message rows self-clip already); pinned in testTypingChurnKeepsCellsDisjoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: the typing-to-message handoff — dots fade out, the arrival actually rises in The arrival entrance animation targeted the LAST item — which is the permanent typing row, a zero-height cell — so it animated nothing and every arrival snapped into place. It now targets the newest message (count-2). And when the typing indicator clears, the dots fade out over 120ms before their row collapses instead of blinking off next to the rise-in. Both respect Reduce Motion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: a typer's arriving message retires their dots in the same apply The handoff played as two motions — the arrival grew the pane (animated pin + entrance), then the separate typing-off event collapsed the dots row a beat later: the jump Paul saw. The coordinator now clears the sender's local typing state inside applyMessages, so items and heights change once and pin + rise + dot removal read as one replacement (the Messages feel). The fade-then-collapse remains for stopped-without- sending. Pinned in testArrivalReplacesTypersDots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: flatten the row layout tree — 35% off per-row scroll cost today The sample-tool profile of a live container sweep (testProfilePadProdSweep + the marker-file sampler) showed ~95% of scroll-path time inside SwiftUICore StackLayout.sizeThatFits recursion — layout NODES are the cost, and release SwiftUICore means device Release pays it too. Cuts: - Rows align the bubble with padding + frame alignment instead of HStack + Spacers (two unary nodes vs the stack algorithm); same geometry, bubbleRect math unchanged. - MessageBubble: a simple-text fast path (no attachments/reply/ reactions/tags/card/link) skips the wrapper VStack and tag scaffolding — most feed rows. Actions chrome unified into one ActionsChrome modifier that adds NO nodes when actions are nil. - FeedMessageRow only wraps in a VStack when a separator exists. - Plain single-run paragraphs render as plain Text instead of building an AttributedString — most messages; also cut the measure pass (cold 200-row mount 214 -> 152ms). prod rung: 16.7 -> 14.3ms/row (22.0 at the day's start); heights byte-identical (contentH pinned by the harness). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: height-audit crumbs + stable persisted-cache hash + separator repro pins Paul's App-test-group screenshot shows date separators painting over neighbouring rows — a cell measured without content it renders. Clean harness sequences (group separators, boundary-changing prepends) don't reproduce it, so the DEBUG build now audits on every scroll settle: any visible cell whose laid-out height disagrees with its content's ideal gets crumbed with its cache key — one device pull names the rows. Also found en route: heightCacheKey used String.hashValue, which is seeded per process — persisted heights silently never matched across real launches. Now FNV-1a, so the disk cache actually works on device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: unfurl card image slot is a fixed 280x150 window — measured height is load-independent The device height audit named the overlap row exactly: cell 227 vs ideal 366 — the 139pt delta the unfurl card's image. AsyncImage's placeholder phase contributed no height in the measure pass (which never loads images), so image-bearing cards measured short and painted over the rows below once the image landed, with nothing re-measuring on load. The slot is now a fixed frame whenever imageURL exists — identical before and after load. Pinned: testUnfurlCardMeasuresImageSlotBeforeLoad (bare 160 / with-image 310). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+4174
−702
ios/LChat/LChatApp.swift+23 −1ios/LChat/Views/ChatView.swift+12 −3ios/LChat/Views/FeedMenu.swift+211 −0ios/LChat/Views/FlippedChatFeed.swift+1001 −0ios/LChat/Views/NativeChatScreen.swift+174 −678ios/LChat/Views/StockChatFeed.swift+1573 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+10 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+105 −20ios/LChatFeedTests/RowLadderTests.swift+435 −0ios/LChatFeedTests/ShortChatFeedTests.swift+538 −0ios/LChatUITests/FeedMenuUITests.swift+73 −0ios/project.yml+19 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index a2d4744..e33dcb5 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -14,6 +14,10 @@ struct LChatApp: App { |
| // Sockets heartbeat "active" while the app is frontmost (presence + |
| // push suppression) — wired per platform because LChatCore can't see UIKit. |
| ChatSocket.appIsActive = { UIApplication.shared.applicationState == .active } |
| + // Start the row-height cache's disk load now, off-main — merged long |
| + // before the first chat opens, so first opens skip the measure pass |
| + // that cost seconds on device (see FeedHeightCache). |
| + FeedHeightCache.warm() |
| } |
| var body: some Scene { |
| @@ -36,7 +40,25 @@ final class AppState: ObservableObject { |
| // resolve this launch (the resolver is a 600k-iteration PBKDF2 — once). |
| private var sealedAvatarAttempted: Set<String> = [] |
| - init() { instances = InstancesStore.load() } |
| + init() { |
| + instances = InstancesStore.load() |
| + #if DEBUG |
| + // The sim rig's door (LChatUITests, 2026-07-21): a dev-server join |
| + // link in the environment seeds the space natively — no accounts |
| + // flow, no hands. Debug-only, and only into an empty install. |
| + if instances.isEmpty, |
| + let link = ProcessInfo.processInfo.environment["LCHAT_DEV_JOIN"], |
| + !link.isEmpty { |
| + Task { @MainActor in |
| + guard self.instances.isEmpty, |
| + let inst = try? await SpaceClaim.claim( |
| + setupLink: link, screenName: "Sim") else { return } |
| + self.instances.append(inst) |
| + InstancesStore.save(self.instances) |
| + } |
| + } |
| + #endif |
| + } |
| /// Sign-out's second half: AppReset has already wiped the stores — drop |
| /// the in-memory mirror so the UI folds back to first-run without |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 7ce5b39..775437a 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -964,12 +964,21 @@ extension View { |
| if #available(iOS 26.0, *) { |
| self.glassEffect(.regular, in: shape) |
| } else { |
| - self.background(.ultraThinMaterial, in: shape) |
| - .overlay(shape.strokeBorder(Color(.separator).opacity(0.4), lineWidth: 0.5)) |
| + scrollSafeGlass(in: shape) |
| } |
| #else |
| + scrollSafeGlass(in: shape) |
| + #endif |
| + } |
| + |
| + // For glass that floats OVER SCROLLING CONTENT: the liquid-glass lens |
| + // refracts and duplicates whatever passes beneath it — over the chat |
| + // feed that rendered as ghosted, stacked, compressed bubbles sliding |
| + // under the header (the 2026-07-20 hunt; the /bareedges bisect convicted |
| + // the glass, not the list). A material blurs its backdrop without |
| + // lensing, so surfaces on the scroll path use this on every OS. |
| + func scrollSafeGlass<S: InsettableShape>(in shape: S) -> some View { |
| self.background(.ultraThinMaterial, in: shape) |
| .overlay(shape.strokeBorder(Color(.separator).opacity(0.4), lineWidth: 0.5)) |
| - #endif |
| } |
| } |
diff --git a/ios/LChat/Views/FeedMenu.swift b/ios/LChat/Views/FeedMenu.swift| new file mode 100644 |
| index 0000000..85f145c |
| --- /dev/null |
| +++ b/ios/LChat/Views/FeedMenu.swift |
| @@ -0,0 +1,211 @@ |
| +import UIKit |
| + |
| +// The long-press message menu, OWNED — no UIContextMenuInteraction anywhere. |
| +// The 2026-07-21 hunt proved every system route broken for chat bubbles on |
| +// device: the collection view's item pipeline platters the whole CELL (the |
| +// "aura", immune to every preview API), a manual interaction on the list is |
| +// starved by the built-in one, and per-cell interactions never fire under |
| +// the hosted SwiftUI gesture graph. Messages-grade behaviour needs the |
| +// bubble to be the star, so this draws the web app's menu itself: dim the |
| +// feed, float the bubble's own pixels where they already are, the reaction |
| +// palette above, a labelled menu card below. Tap anywhere else dismisses. |
| +@MainActor |
| +final class FeedMenuOverlay: UIView { |
| + struct Item { |
| + let title: String |
| + let icon: String |
| + var destructive = false |
| + let handler: () -> Void |
| + } |
| + |
| + private let onDismiss: () -> Void |
| + private var actionAfterDismiss: (() -> Void)? |
| + |
| + init(container: UIView, bubbleImage: UIImage, bubbleFrame: CGRect, |
| + mine: Bool, reactions: [String], onReact: ((String) -> Void)?, |
| + items: [Item], onDismiss: @escaping () -> Void) { |
| + self.onDismiss = onDismiss |
| + super.init(frame: container.bounds) |
| + autoresizingMask = [.flexibleWidth, .flexibleHeight] |
| + |
| + let dim = UIView(frame: bounds) |
| + dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] |
| + dim.backgroundColor = UIColor.black.withAlphaComponent(0.35) |
| + dim.alpha = 0 |
| + addSubview(dim) |
| + self.dim = dim |
| + |
| + let bubble = UIImageView(image: bubbleImage) |
| + bubble.frame = bubbleFrame |
| + bubble.layer.shadowColor = UIColor.black.cgColor |
| + bubble.layer.shadowOpacity = 0.25 |
| + bubble.layer.shadowRadius = 18 |
| + bubble.layer.shadowOffset = CGSize(width: 0, height: 6) |
| + addSubview(bubble) |
| + |
| + // The palette: the web's reaction row, a capsule of emoji above the |
| + // bubble, aligned to its speaking side. |
| + var palette: UIView? |
| + if let onReact, !reactions.isEmpty { |
| + let stack = UIStackView() |
| + stack.axis = .horizontal |
| + stack.spacing = 2 |
| + for emoji in reactions { |
| + // .custom, not .system: system buttons TINT their titles, |
| + // rendering emoji as monochrome boxes. |
| + let b = UIButton(type: .custom) |
| + b.setTitle(emoji, for: .normal) |
| + b.titleLabel?.font = .systemFont(ofSize: 26) |
| + b.accessibilityLabel = "React \(emoji)" |
| + b.addAction(UIAction { [weak self] _ in |
| + self?.actionAfterDismiss = { onReact(emoji) } |
| + self?.dismiss() |
| + }, for: .touchUpInside) |
| + b.widthAnchor.constraint(equalToConstant: 44).isActive = true |
| + b.heightAnchor.constraint(equalToConstant: 44).isActive = true |
| + stack.addArrangedSubview(b) |
| + } |
| + let card = Self.card() |
| + card.layer.cornerRadius = 25 |
| + stack.translatesAutoresizingMaskIntoConstraints = false |
| + card.contentView.addSubview(stack) |
| + NSLayoutConstraint.activate([ |
| + stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 3), |
| + stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -3), |
| + stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor, constant: 6), |
| + stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor, constant: -6), |
| + ]) |
| + addSubview(card) |
| + palette = card |
| + } |
| + |
| + // The menu card: labelled rows, destructive tinted, separators. |
| + var menuCard: UIView? |
| + if !items.isEmpty { |
| + let stack = UIStackView() |
| + stack.axis = .vertical |
| + stack.spacing = 0 |
| + for (i, item) in items.enumerated() { |
| + if i > 0 { |
| + let sep = UIView() |
| + sep.backgroundColor = .separator |
| + sep.heightAnchor.constraint(equalToConstant: 0.5).isActive = true |
| + stack.addArrangedSubview(sep) |
| + } |
| + var config = UIButton.Configuration.plain() |
| + config.title = item.title |
| + config.image = UIImage(systemName: item.icon) |
| + config.imagePlacement = .trailing |
| + config.baseForegroundColor = item.destructive ? .systemRed : .label |
| + config.contentInsets = NSDirectionalEdgeInsets( |
| + top: 12, leading: 16, bottom: 12, trailing: 16) |
| + let b = UIButton(configuration: config) |
| + b.contentHorizontalAlignment = .fill |
| + b.addAction(UIAction { [weak self] _ in |
| + self?.actionAfterDismiss = item.handler |
| + self?.dismiss() |
| + }, for: .touchUpInside) |
| + stack.addArrangedSubview(b) |
| + } |
| + let card = Self.card() |
| + card.layer.cornerRadius = 14 |
| + stack.translatesAutoresizingMaskIntoConstraints = false |
| + card.contentView.addSubview(stack) |
| + NSLayoutConstraint.activate([ |
| + stack.topAnchor.constraint(equalTo: card.contentView.topAnchor, constant: 4), |
| + stack.bottomAnchor.constraint(equalTo: card.contentView.bottomAnchor, constant: -4), |
| + stack.leadingAnchor.constraint(equalTo: card.contentView.leadingAnchor), |
| + stack.trailingAnchor.constraint(equalTo: card.contentView.trailingAnchor), |
| + card.widthAnchor.constraint(equalToConstant: 250), |
| + ]) |
| + addSubview(card) |
| + menuCard = card |
| + } |
| + |
| + // Place palette above / menu below the bubble on its speaking side, |
| + // clamped to the container (and nudging the bubble when a tall |
| + // bubble + menu can't otherwise fit). |
| + let pad: CGFloat = 8 |
| + let safe = container.safeAreaInsets |
| + let paletteSize = palette?.systemLayoutSizeFitting( |
| + UIView.layoutFittingCompressedSize) ?? .zero |
| + let menuSize = menuCard?.systemLayoutSizeFitting( |
| + UIView.layoutFittingCompressedSize) ?? .zero |
| + var bFrame = bubbleFrame |
| + let needAbove = paletteSize.height + pad |
| + let needBelow = menuSize.height + pad |
| + let topLimit = safe.top + 8 |
| + let bottomLimit = bounds.height - safe.bottom - 8 |
| + bFrame.origin.y = min(max(bFrame.origin.y, topLimit + needAbove), |
| + bottomLimit - needBelow - bFrame.height) |
| + bubble.frame = bFrame |
| + func xAligned(_ w: CGFloat) -> CGFloat { |
| + let x = mine ? bFrame.maxX - w : bFrame.minX |
| + return min(max(x, 8), bounds.width - w - 8) |
| + } |
| + palette?.frame = CGRect( |
| + x: xAligned(paletteSize.width), |
| + y: bFrame.minY - pad - paletteSize.height, |
| + width: paletteSize.width, height: paletteSize.height) |
| + menuCard?.frame = CGRect( |
| + x: xAligned(menuSize.width), |
| + y: bFrame.maxY + pad, |
| + width: menuSize.width, height: menuSize.height) |
| + |
| + // Entrance: dim fades, palette/menu grow in from the bubble. |
| + let reduceMotion = UIAccessibility.isReduceMotionEnabled |
| + for card in [palette, menuCard].compactMap({ $0 }) { |
| + card.alpha = 0 |
| + if !reduceMotion { |
| + card.transform = CGAffineTransform(scaleX: 0.6, y: 0.6) |
| + .translatedBy(x: 0, y: mine ? 0 : 0) |
| + } |
| + } |
| + UIView.animate(withDuration: reduceMotion ? 0.1 : 0.22, delay: 0, |
| + usingSpringWithDamping: 0.86, initialSpringVelocity: 0) { |
| + dim.alpha = 1 |
| + for card in [palette, menuCard].compactMap({ $0 }) { |
| + card.alpha = 1 |
| + card.transform = .identity |
| + } |
| + } |
| + |
| + let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped(_:))) |
| + tap.cancelsTouchesInView = false |
| + addGestureRecognizer(tap) |
| + |
| + isAccessibilityElement = false |
| + accessibilityViewIsModal = true |
| + UIAccessibility.post(notification: .screenChanged, argument: palette ?? menuCard) |
| + } |
| + |
| + required init?(coder: NSCoder) { fatalError("unused") } |
| + |
| + // A material card (palette capsule / menu list) — one look for both. |
| + private static func card() -> UIVisualEffectView { |
| + let v = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) |
| + v.clipsToBounds = true |
| + return v |
| + } |
| + |
| + private weak var dim: UIView? |
| + |
| + @objc private func backgroundTapped(_ g: UITapGestureRecognizer) { |
| + // Card taps are handled by their buttons; anywhere else dismisses. |
| + let p = g.location(in: self) |
| + for sub in subviews where sub !== dim && sub.frame.contains(p) { return } |
| + dismiss() |
| + } |
| + |
| + func dismiss() { |
| + let after = actionAfterDismiss |
| + actionAfterDismiss = nil |
| + UIView.animate(withDuration: 0.16, animations: { |
| + self.alpha = 0 |
| + }, completion: { _ in |
| + self.removeFromSuperview() |
| + self.onDismiss() |
| + after?() |
| + }) |
| + } |
| +} |
diff --git a/ios/LChat/Views/FlippedChatFeed.swift b/ios/LChat/Views/FlippedChatFeed.swift| new file mode 100644 |
| index 0000000..433da39 |
| --- /dev/null |
| +++ b/ios/LChat/Views/FlippedChatFeed.swift |
| @@ -0,0 +1,1001 @@ |
| +import Combine |
| +import LChatCore |
| +import SwiftUI |
| +import UIKit |
| + |
| +// The iOS feed container: a FLIPPED UICollectionView (the Telegram approach). |
| +// The collection view is scaled (1, -1) so its "top" is the visual bottom, and |
| +// every cell's contentView is scaled back, so rows render upright. The data |
| +// source runs newest→oldest (index 0 = newest message). What that buys, and |
| +// why the SwiftUI ScrollView + defaultScrollAnchor container this replaces |
| +// had to go (#1209/#1210/#1211 — the anchor/settle bug class): |
| +// |
| +// - Opening at the bottom is the resting state, not a pin: offset -inset.top |
| +// IS the newest message, from the first layout pass. No settle phase, no |
| +// opacity hold, no estimated-height scrollTo that could strand the viewport. |
| +// - Scroll-back "prepends" are APPENDS at the far end of the data source — |
| +// they never move existing content, so there is no offset compensation and |
| +// no stranded-viewport class at all. |
| +// - Growth at the bottom (a send, a typing bubble, an unfurl card resolving) |
| +// extends content upward from flipped y=0 while the offset holds — the |
| +// newest message stays glued to the composer with no re-pin machinery. |
| +// - The keyboard and composer are ordinary contentInset changes. |
| +// |
| +// Flipped-list taxes paid here: VoiceOver order (the collection view override |
| +// below), the scroll indicator (hidden — it would run backwards), and |
| +// scrollsToTop (disabled — the status-bar tap would jump to the NEWEST |
| +// message, not the oldest as everywhere else on iOS). |
| +// |
| +// Rows stay SwiftUI via UIHostingConfiguration, so MessageBubble and its whole |
| +// interaction surface (context menu, swipe-to-reply, Quick Look zoom anchors, |
| +// the jump wave) ride along unchanged. |
| + |
| +// The cross-row interaction state the old container held as screen @State. |
| +// It lives in ONE observable object that only the row views (and the reply |
| +// thread's bubbles) observe — the screen deliberately does NOT, so a 60Hz |
| +// swipe drag re-renders the visible cells and nothing else. |
| +@MainActor |
| +final class FeedInteraction: ObservableObject { |
| + // The times peek: dragging left slides every row's time in together. |
| + @Published var timesReveal: CGFloat = 0 |
| + // Swipe-right quick-reply: the dragged bubble's offset, by message id. |
| + @Published var replyDrag: [Int64: CGFloat] = [:] |
| + // The jump wave target (the Mac feed's Minion); tick retriggers same-id. |
| + @Published var danceID: Int64? |
| + @Published var danceTick = 0 |
| + // The attachment currently downloading + its 0…1 progress — drives the |
| + // video card's radial ring and the file chip's. One at a time is enforced |
| + // by the callers (a given attachment is either a video or a file). |
| + @Published var blobDownload: (id: String, progress: Double)? |
| + // Threshold haptic latch for the reply swipe — no render effect. |
| + var replyArmed = false |
| +} |
| + |
| +// Everything a row needs from the screen, resolved at configure time. Plain |
| +// values + closures; rows never observe the model (that per-publish whole-feed |
| +// re-render was the 34-88ms stall the old container's comments document). |
| +struct FeedHooks { |
| + var isGroup: Bool |
| + var themeName: String |
| + var chatFontSize: CGFloat |
| + var senderName: (ChatFeedModel.Message) -> String |
| + var foldsAsMuted: (ChatFeedModel.Message) -> Bool |
| + var revealMuted: (Int64) -> Void |
| + var actions: (ChatFeedModel.Message) -> MessageActions |
| + var onJump: (Int64) -> Void |
| + var onOpenThread: (Int64) -> Void |
| + var blobSource: (Attachment) -> AvatarSource? |
| + var onImageTap: (Attachment, PlatformImage, CGRect) -> Void |
| + var onFileTap: (Attachment) -> Void |
| + var onVideoTap: (Attachment, CGRect) -> Void |
| + var unfurl: (URL) async -> LinkPreview? |
| + // Synchronous unfurl-cache peek — height pre-measurement must see an |
| + // already-resolved card (.some(nil) = known-empty; nil = not fetched). |
| + var peekUnfurl: (URL) -> LinkPreview?? = { _ in nil } |
| + var startReply: (ChatFeedModel.Message) -> Void |
| +} |
| + |
| +// What a feed container owes the screen: the two scroll verbs. |
| +@MainActor |
| +protocol FeedScrolling: AnyObject { |
| + func pinToBottom(animated: Bool) |
| + func scrollTo(id: Int64) |
| +} |
| + |
| +// The screen's handle on the scroll position — jump-to-latest, quote jumps, |
| +// the search-hit jump. A thin command surface; the container owns the math. |
| +@MainActor |
| +final class FeedScrollController { |
| + weak var target: (any FeedScrolling)? |
| + func scrollToBottom(animated: Bool) { target?.pinToBottom(animated: animated) } |
| + func scrollTo(id: Int64) { target?.scrollTo(id: id) } |
| +} |
| + |
| +struct FlippedChatFeed: UIViewRepresentable { |
| + let model: ChatFeedModel |
| + let interaction: FeedInteraction |
| + let controller: FeedScrollController |
| + let hooks: FeedHooks |
| + // The header / composer regions the feed extends under (it ignores the |
| + // container safe areas and gets them back as content insets, so messages |
| + // scroll beneath the glass exactly as the ScrollView's did). |
| + let topOverlay: CGFloat |
| + let bottomOverlay: CGFloat |
| + let composerFocused: Bool |
| + // Notification-tap open with unreads: land the first unread at the TOP. |
| + let unreadCutoff: Int64? |
| + let onJumpVisible: (Bool) -> Void |
| + // Fires once, after the first content layout lands — the feed.mount end. |
| + let onFirstLayout: (Int) -> Void |
| + |
| + func makeCoordinator() -> Coordinator { |
| + Coordinator(model: model, interaction: interaction, hooks: hooks, |
| + unreadCutoff: unreadCutoff) |
| + } |
| + |
| + func makeUIView(context: Context) -> UICollectionView { |
| + let coordinator = context.coordinator |
| + coordinator.config = Config(topOverlay: topOverlay, bottomOverlay: bottomOverlay, |
| + composerFocused: composerFocused, |
| + isGroup: hooks.isGroup, themeName: hooks.themeName, |
| + fontSize: hooks.chatFontSize) |
| + coordinator.onJumpVisible = onJumpVisible |
| + coordinator.onFirstLayout = onFirstLayout |
| + let cv = coordinator.buildCollectionView() |
| + controller.target = coordinator |
| + coordinator.subscribe() |
| + return cv |
| + } |
| + |
| + func updateUIView(_ cv: UICollectionView, context: Context) { |
| + let coordinator = context.coordinator |
| + coordinator.hooks = hooks |
| + coordinator.onJumpVisible = onJumpVisible |
| + coordinator.onFirstLayout = onFirstLayout |
| + coordinator.configUpdated(Config(topOverlay: topOverlay, bottomOverlay: bottomOverlay, |
| + composerFocused: composerFocused, |
| + isGroup: hooks.isGroup, themeName: hooks.themeName, |
| + fontSize: hooks.chatFontSize)) |
| + } |
| + |
| + // The config the coordinator diffs on updateUIView: overlay insets adjust |
| + // in place; theme/type-size changes reconfigure the visible rows. |
| + struct Config: Equatable { |
| + var topOverlay: CGFloat = 0 |
| + var bottomOverlay: CGFloat = 0 |
| + var composerFocused = false |
| + var isGroup = false |
| + var themeName = "classic" |
| + var fontSize: CGFloat = 17 |
| + } |
| + |
| + enum FeedItem: Hashable { |
| + case message(Int64) |
| + case typing |
| + } |
| + |
| + @MainActor |
| + final class Coordinator: NSObject, UICollectionViewDelegate, FeedScrolling { |
| + let model: ChatFeedModel |
| + let interaction: FeedInteraction |
| + var hooks: FeedHooks |
| + var config = Config() |
| + var onJumpVisible: ((Bool) -> Void)? |
| + var onFirstLayout: ((Int) -> Void)? |
| + |
| + private(set) var collectionView: FlippedCollectionView! |
| + private var dataSource: UICollectionViewDiffableDataSource<Int, FeedItem>! |
| + // The messages as last applied, ascending by id (the model's order). |
| + private var current: [ChatFeedModel.Message] = [] |
| + private var typingNames: [String] = [] |
| + private var cancellables = Set<AnyCancellable>() |
| + private var contentSizeObservation: NSKeyValueObservation? |
| + private var keyboardOverlap: CGFloat = 0 |
| + private var firstApplyDone = false |
| + // Consumed by the first anchor pass; nil = open at the bottom. |
| + private var unreadCutoff: Int64? |
| + // Set by the first apply; the collection view calls back from the |
| + // first REAL layout (makeUIView-time bounds are zero, so anchoring |
| + // must wait for layoutSubviews). |
| + fileprivate var needsInitialAnchor = false |
| + private var jumpVisible = false |
| + // The last scroll-time gap from the bottom — the pin decisions read |
| + // this rather than recomputing against an inset mid-mutation. |
| + private var lastGap: CGFloat = 0 |
| + |
| + init(model: ChatFeedModel, interaction: FeedInteraction, hooks: FeedHooks, |
| + unreadCutoff: Int64?) { |
| + self.model = model |
| + self.interaction = interaction |
| + self.hooks = hooks |
| + self.unreadCutoff = unreadCutoff |
| + } |
| + |
| + deinit { |
| + NotificationCenter.default.removeObserver(self) |
| + } |
| + |
| + // ---- construction --------------------------------------------------- |
| + |
| + func buildCollectionView() -> UICollectionView { |
| + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), |
| + heightDimension: .estimated(60)) |
| + let item = NSCollectionLayoutItem(layoutSize: size) |
| + let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) |
| + let layout = UICollectionViewCompositionalLayout(section: NSCollectionLayoutSection(group: group)) |
| + |
| + let cv = FlippedCollectionView(frame: .zero, collectionViewLayout: layout) |
| + cv.coordinator = self |
| + cv.transform = CGAffineTransform(scaleX: 1, y: -1) |
| + cv.backgroundColor = .clear |
| + cv.allowsSelection = false |
| + cv.keyboardDismissMode = .interactive |
| + // We own every inset (header, composer, keyboard, the short-chat |
| + // shortfall); UIKit's automatic adjustment doesn't understand the |
| + // flip and would fight all of them. |
| + cv.contentInsetAdjustmentBehavior = .never |
| + // Flipped-list taxes: the indicator would travel backwards, and |
| + // the status-bar tap would jump to the NEWEST message. |
| + cv.showsVerticalScrollIndicator = false |
| + cv.scrollsToTop = false |
| + cv.delegate = self |
| + collectionView = cv |
| + |
| + let registration = UICollectionView.CellRegistration<FlippedCell, FeedItem> { |
| + [weak self] cell, _, item in |
| + guard let self else { return } |
| + switch item { |
| + case .typing: |
| + let label = self.config.isGroup ? typingSummary(self.typingNames) : nil |
| + cell.contentConfiguration = UIHostingConfiguration { |
| + FeedTypingRow(label: label, themeName: self.config.themeName, |
| + chatFontSize: self.config.fontSize) |
| + }.margins(.all, 0) |
| + case .message(let id): |
| + // A dequeue can race an apply; never leave a reused cell |
| + // showing another row's stale content. |
| + guard let idx = self.index(of: id) else { |
| + cell.contentConfiguration = nil |
| + return |
| + } |
| + let m = self.current[idx] |
| + let prev = idx > 0 ? self.current[idx - 1] : nil |
| + cell.contentConfiguration = UIHostingConfiguration { |
| + FeedMessageRow(message: m, previous: prev, |
| + hooks: self.hooks, interaction: self.interaction) |
| + // Hosted identity pins to the MESSAGE, not the |
| + // cell: on reuse the hosted tree would otherwise |
| + // UPDATE from one message to another and animate |
| + // the layout diff — bubbles visibly morphing |
| + // mid-scroll. A new identity mounts fresh instead |
| + // (cheap: the parse/layout memos are global). |
| + .id(m.id) |
| + }.margins(.all, 0) |
| + } |
| + } |
| + dataSource = UICollectionViewDiffableDataSource<Int, FeedItem>(collectionView: cv) { |
| + cv, indexPath, item in |
| + cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) |
| + } |
| + |
| + NotificationCenter.default.addObserver( |
| + self, selector: #selector(keyboardWillChange(_:)), |
| + name: UIResponder.keyboardWillChangeFrameNotification, object: nil) |
| + // Late height changes (unfurl cards, reaction chips) move |
| + // contentSize — the short-chat shortfall inset must track it. |
| + // DEFERRED a turn: this KVO fires mid-layout and mid-batch-update, |
| + // where mutating contentInset is undefined behaviour (UIKit |
| + // internal-consistency crashes). |
| + contentSizeObservation = cv.observe(\.contentSize) { [weak self] _, _ in |
| + MainActor.assumeIsolated { self?.queueInsetUpdate() } |
| + } |
| + updateInsets() |
| + return cv |
| + } |
| + |
| + func subscribe() { |
| + // @Published emits the NEW value synchronously (and once on |
| + // subscribe — which delivers a parked model's messages right here, |
| + // before the first layout). Always use the emitted value: the |
| + // property itself still holds the old one mid-publish. |
| + 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) |
| + // Render-level inputs the rows resolve at configure time: a |
| + // rename, a mute, a reveal — refresh the visible rows. |
| + // removeDuplicates matters: a re-assignment of an unchanged value |
| + // must not re-host every visible cell. |
| + model.$senderNames.removeDuplicates().map { _ in () } |
| + .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) |
| + .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) |
| + .dropFirst(3) |
| + .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? { |
| + // Binary search — every fold path publishes sorted by id. |
| + 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 setMessages(_ new: [ChatFeedModel.Message]) { |
| + let oldNewest = current.last?.id |
| + current = new |
| + apply(newestChanged: new.last?.id != oldNewest, |
| + newestIsMine: new.last?.mine == true) |
| + } |
| + |
| + private func setTyping(_ names: [String]) { |
| + let appeared = typingNames.isEmpty && !names.isEmpty |
| + let changed = typingNames.isEmpty != names.isEmpty |
| + typingNames = names |
| + if changed { |
| + // The bubble appearing counts as bottom growth (pin while |
| + // composing, like an arrival); a label-only change (who's |
| + // typing) just refreshes the row. |
| + apply(newestChanged: appeared, newestIsMine: false) |
| + } else if !names.isEmpty { |
| + var snap = dataSource.snapshot() |
| + if snap.itemIdentifiers.contains(.typing) { |
| + snap.reconfigureItems([.typing]) |
| + dataSource.apply(snap, animatingDifferences: false) |
| + } |
| + } |
| + } |
| + |
| + private func items() -> [FeedItem] { |
| + var list: [FeedItem] = typingNames.isEmpty ? [] : [.typing] |
| + list.reserveCapacity(current.count + 1) |
| + for m in current.reversed() { list.append(.message(m.id)) } |
| + return list |
| + } |
| + |
| + // What each row rendered from last time, so an apply only reconfigures |
| + // rows whose content really changed: the message itself (edits, |
| + // reactions, reply counts) or its PREVIOUS neighbour (the separator / |
| + // sender-run boundary shifts when a page lands above it). |
| + private var lastPairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] |
| + |
| + // Every apply is NON-ANIMATED, deliberately. UIKit batch-update |
| + // animations in a transformed collection view were the launch-day |
| + // grief in one costume after another: the default insert animation |
| + // mirrors through the flip (bubbles "arriving from the top"), and |
| + // frequent applies interrupt in-flight alpha animations, stranding |
| + // cells half-transparent (the "faded" feed). Non-animated applies are |
| + // also what makes the structural guarantees real: at the bottom an |
| + // arrival shows in place with NO offset write at all, and a far-end |
| + // history append moves nothing. An arrival-entrance animation can |
| + // come back later as OUR OWN cell animation — never performBatchUpdates'. |
| + private func apply(newestChanged: Bool, newestIsMine: Bool) { |
| + guard dataSource != nil else { return } |
| + let old = dataSource.snapshot().itemIdentifiers |
| + var snap = NSDiffableDataSourceSnapshot<Int, FeedItem>() |
| + snap.appendSections([0]) |
| + let new = items() |
| + snap.appendItems(new) |
| + let oldSet = Set(old) |
| + let newSet = Set(new) |
| + 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)) |
| + } |
| + } |
| + // Oldest previously-applied message id — the append-only test. |
| + let lastOldest = lastPairs.keys.min() |
| + lastPairs = pairs |
| + snap.reconfigureItems(changed) |
| + |
| + guard firstApplyDone else { |
| + dataSource.apply(snap, animatingDifferences: false) |
| + if !current.isEmpty { |
| + firstApplyDone = true |
| + needsInitialAnchor = true |
| + collectionView.setNeedsLayout() |
| + } |
| + return |
| + } |
| + |
| + let inserted = new.filter { !oldSet.contains($0) } |
| + let removed = old.filter { !newSet.contains($0) } |
| + // Scroll-back pages append past the far end of the flipped layout: |
| + // nothing between the viewport and flipped-zero moves, so the |
| + // apply needs no forced layout, no anchor, no offset write. This |
| + // fast path is most of what makes scroll-back feel native — the |
| + // earlier restore-on-every-apply wrote contentOffset mid-gesture, |
| + // which kills deceleration dead (the "locked" scroll feel). |
| + let appendOnly = removed.isEmpty && inserted.allSatisfy { item in |
| + if case .message(let id) = item { return id < (lastOldest ?? Int64.min) } |
| + return false |
| + } |
| + let wasAtBottom = gapFromBottom() < 44 |
| + |
| + if appendOnly || (inserted.isEmpty && removed.isEmpty) { |
| + // Appends and reconfigure-only passes hold position by |
| + // construction (at the bottom, growth of the newest row is |
| + // structural too — flipped y=0 stays glued to the composer). |
| + dataSource.apply(snap, animatingDifferences: false) |
| + return |
| + } |
| + if wasAtBottom { |
| + // Arrivals at the bottom: the insert at index 0 shifts content |
| + // up under a constant offset — the new message simply IS the |
| + // bottom now. No write, nothing to fight the finger. |
| + dataSource.apply(snap, animatingDifferences: false) |
| + return |
| + } |
| + // Scrolled up with content changing at or below the viewport |
| + // (arrivals, deletes, NSE catch-up merges): hold the viewport by |
| + // anchor, against REAL post-apply layout. |
| + let anchor = captureAnchor() |
| + dataSource.apply(snap, animatingDifferences: false) |
| + collectionView.layoutIfNeeded() |
| + if newestChanged, newestIsMine || config.composerFocused { |
| + // Your own send always pins; so does any bottom growth while |
| + // composing (you're at the conversation's tail). |
| + pinToBottom(animated: !UIAccessibility.isReduceMotionEnabled) |
| + } else if !collectionView.isTracking, !collectionView.isDecelerating { |
| + restore(anchor) |
| + } |
| + } |
| + |
| + private func reconfigureAll() { |
| + guard dataSource != nil else { return } |
| + var snap = dataSource.snapshot() |
| + snap.reconfigureItems(snap.itemIdentifiers) |
| + dataSource.apply(snap, animatingDifferences: false) |
| + } |
| + |
| + // ---- viewport anchoring -------------------------------------------- |
| + |
| + private struct Anchor { |
| + let id: Int64 |
| + let offsetInViewport: CGFloat |
| + } |
| + |
| + private func captureAnchor() -> Anchor? { |
| + // The newest-most visible MESSAGE row (lowest flipped y) and where |
| + // it sits relative to the viewport. |
| + let visible = collectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item } |
| + for ip in visible { |
| + guard case .message(let id) = dataSource.itemIdentifier(for: ip), |
| + let attr = collectionView.layoutAttributesForItem(at: ip) else { continue } |
| + return Anchor(id: id, offsetInViewport: attr.frame.minY - collectionView.contentOffset.y) |
| + } |
| + return nil |
| + } |
| + |
| + private func restore(_ anchor: Anchor?) { |
| + guard let anchor, |
| + let ip = dataSource.indexPath(for: .message(anchor.id)), |
| + let attr = collectionView.layoutAttributesForItem(at: ip) else { return } |
| + let target = attr.frame.minY - anchor.offsetInViewport |
| + let minY = -collectionView.contentInset.top |
| + let maxY = max(minY, collectionView.contentSize.height |
| + + collectionView.contentInset.bottom - collectionView.bounds.height) |
| + collectionView.contentOffset.y = min(max(target, minY), maxY) |
| + } |
| + |
| + // ---- scrolling ------------------------------------------------------ |
| + |
| + private func gapFromBottom() -> CGFloat { |
| + collectionView.contentOffset.y + collectionView.contentInset.top |
| + } |
| + |
| + func pinToBottom(animated: Bool) { |
| + collectionView.setContentOffset( |
| + CGPoint(x: 0, y: -collectionView.contentInset.top), animated: animated) |
| + } |
| + |
| + func scrollTo(id: Int64) { |
| + guard let ip = dataSource.indexPath(for: .message(id)) else { return } |
| + // Animated only for NEAR jumps (the old container's rule): an |
| + // animated scroll across hundreds of estimated rows recalibrates |
| + // heights on the way and can land rough; far targets snap. |
| + collectionView.scrollToItem(at: ip, at: .centeredVertically, |
| + animated: ip.item <= 60) |
| + } |
| + |
| + func scrollViewDidScroll(_ scrollView: UIScrollView) { |
| + lastGap = gapFromBottom() |
| + // Jump-to-latest after a MODERATE scroll up (much wider than the |
| + // pin threshold, so it doesn't appear for reading the newest |
| + // couple of messages). Only threshold crossings leave UIKit. |
| + let show = lastGap > 400 |
| + if show != jumpVisible { |
| + jumpVisible = show |
| + let cb = onJumpVisible |
| + DispatchQueue.main.async { cb?(show) } |
| + } |
| + } |
| + |
| + // Scroll-back: nearing the oldest loaded rows pages more in. The far |
| + // end of a flipped list — cells only materialise when genuinely close, |
| + // so there is no transient-layout storm to gate against (the model's |
| + // own cooldown paces repeat fires). |
| + func collectionView(_ collectionView: UICollectionView, |
| + willDisplay cell: UICollectionViewCell, |
| + forItemAt indexPath: IndexPath) { |
| + guard firstApplyDone, |
| + indexPath.item >= collectionView.numberOfItems(inSection: 0) - 8 else { return } |
| + let model = model |
| + Task { await model.loadOlder() } |
| + } |
| + |
| + // ---- layout: insets, keyboard, first anchor ------------------------- |
| + |
| + func configUpdated(_ new: Config) { |
| + let old = config |
| + config = new |
| + if old.themeName != new.themeName || old.fontSize != new.fontSize |
| + || old.isGroup != new.isGroup { |
| + reconfigureAll() |
| + } |
| + if old.topOverlay != new.topOverlay || old.bottomOverlay != new.bottomOverlay { |
| + // Composer growth (reply banner, multiline) while at the |
| + // bottom keeps the last message visible; a reader up in |
| + // history just gets the inset and stays put. |
| + let wasAtBottom = lastGap < 44 |
| + updateInsets() |
| + if wasAtBottom, !collectionView.isTracking { |
| + pinToBottom(animated: false) |
| + } |
| + } |
| + } |
| + |
| + private var insetUpdateQueued = false |
| + |
| + private func queueInsetUpdate() { |
| + guard !insetUpdateQueued else { return } |
| + insetUpdateQueued = true |
| + DispatchQueue.main.async { |
| + self.insetUpdateQueued = false |
| + self.updateInsets() |
| + } |
| + } |
| + |
| + private func updateInsets() { |
| + guard let cv = collectionView else { return } |
| + // bottomOverlay = composer + home bar (keyboard excluded — the |
| + // feed ignores the keyboard safe area; the overlap arrives via |
| + // the notifications below, and subsumes the home bar when up). |
| + let homeBar = cv.window?.safeAreaInsets.bottom ?? 0 |
| + let occlusion = config.bottomOverlay + max(0, keyboardOverlap - homeBar) |
| + // Short chats read from the TOP: pad the flipped top (the visual |
| + // bottom) with the shortfall so content aligns under the header. |
| + let avail = cv.bounds.height - occlusion - config.topOverlay |
| + let shortfall = max(0, avail - cv.contentSize.height) |
| + let inset = UIEdgeInsets(top: occlusion + shortfall, left: 0, |
| + bottom: config.topOverlay, right: 0) |
| + guard inset != cv.contentInset else { return } |
| + let wasAtBottom = lastGap < 44 |
| + cv.contentInset = inset |
| + if !cv.isTracking, !needsInitialAnchor, |
| + wasAtBottom || gapFromBottom() < 0 { |
| + // Re-pin when we were at the bottom — and ALSO when a |
| + // shrinking top inset left the offset beyond the new rest |
| + // position (negative gap): UIKit never corrects that on its |
| + // own, and the feed sits stuck high with an empty band above |
| + // the composer (the keyboard-up screenshot's shape). |
| + cv.contentOffset.y = -inset.top |
| + } |
| + } |
| + |
| + @objc private func keyboardWillChange(_ note: Notification) { |
| + guard let cv = collectionView, let window = cv.window, |
| + let end = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect |
| + else { return } |
| + let endInWindow = window.convert(end, from: nil) |
| + let overlap = max(0, window.bounds.maxY - endInWindow.minY) |
| + guard overlap != keyboardOverlap else { return } |
| + let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] |
| + as? Double ?? 0.25 |
| + let curve = note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] |
| + as? Int ?? 7 |
| + let wasAtBottom = lastGap < 44 |
| + keyboardOverlap = overlap |
| + // Ride the keyboard's own curve; pinned readers stay pinned (the |
| + // feed lifts with the keyboard), history readers stay put. |
| + UIView.animate(withDuration: duration, delay: 0, |
| + options: [UIView.AnimationOptions(rawValue: UInt(curve << 16)), |
| + .beginFromCurrentState]) { |
| + self.updateInsets() |
| + if wasAtBottom, !cv.isTracking { |
| + cv.contentOffset.y = -cv.contentInset.top |
| + } |
| + } |
| + } |
| + |
| + // Called by the collection view from layoutSubviews once bounds are |
| + // real: the first anchor must run against actual geometry, and BEFORE |
| + // first display — the reader finds the chat already landed, first |
| + // frame, no settle pass to hide. |
| + // |
| + // ⚠️ This runs INSIDE a layout pass, so it must not re-enter layout: |
| + // no scrollToItem, no layoutIfNeeded, no second super.layoutSubviews. |
| + // The first cut did exactly that and UIKit trapped on recursive |
| + // collection-view layout (the launch-day crash — MetricKit payloads |
| + // 2026-07-20 19:25/19:26Z, six nested layout frames). Writing |
| + // contentInset/contentOffset directly only DIRTIES layout for the |
| + // same commit — bounded, the mechanism working as designed. |
| + fileprivate func performInitialAnchor() { |
| + needsInitialAnchor = false |
| + updateInsets() |
| + 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 (just under the title pill), so |
| + // reading continues where it left off — Messages-style. |
| + // Flipped: the visual top is the item's frame END. The frame |
| + // here can still be an estimate for a deep target, so one |
| + // exact correction runs after the pass, outside layout. |
| + if let attr = collectionView.layoutAttributesForItem(at: ip) { |
| + setOffsetShowing(flippedMaxY: attr.frame.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: .bottom, animated: false) |
| + self.lastGap = self.gapFromBottom() |
| + } |
| + } else { |
| + collectionView.contentOffset.y = -collectionView.contentInset.top |
| + } |
| + unreadCutoff = nil |
| + lastGap = gapFromBottom() |
| + let count = current.count |
| + let cb = onFirstLayout |
| + // Deferred a turn so the screen's onAppear (which starts the |
| + // mount span) has run whichever order SwiftUI commits them. |
| + DispatchQueue.main.async { cb?(count) } |
| + } |
| + |
| + // Position the viewport so the given flipped-space y (an item's frame |
| + // end = its visual top edge) sits at the visual top of the feed. |
| + private func setOffsetShowing(flippedMaxY: CGFloat) { |
| + let cv = collectionView! |
| + let target = flippedMaxY - cv.bounds.height + cv.contentInset.bottom |
| + let minY = -cv.contentInset.top |
| + cv.contentOffset.y = max(target, minY) |
| + } |
| + |
| + #if DEBUG |
| + // ---- feed-dump (diagnostic) ---------------------------------------- |
| + // Writes the live UIKit truth — the collection view's metrics, every |
| + // visible cell's frame/alpha/transform, and every ancestor + sibling |
| + // up to the window — to Library/Logs/feed-dump.txt every few seconds |
| + // while the feed is on screen. Pullable over devicectl, so a "why |
| + // does it LOOK like that" report becomes numbers instead of theories. |
| + private var dumpTimer: Timer? |
| + |
| + fileprivate func setDumping(_ on: Bool) { |
| + dumpTimer?.invalidate() |
| + dumpTimer = nil |
| + guard on else { return } |
| + let t = Timer(timeInterval: 4, repeats: true) { [weak self] _ in |
| + MainActor.assumeIsolated { self?.writeDump() } |
| + } |
| + RunLoop.main.add(t, forMode: .common) |
| + dumpTimer = t |
| + } |
| + |
| + private func viewLine(_ v: UIView, marker: Bool = false) -> String { |
| + let f = v.frame |
| + let bg = v.backgroundColor.map { bg in |
| + var w: CGFloat = 0, a: CGFloat = 0 |
| + bg.getWhite(&w, alpha: &a) |
| + return String(format: "bg(w%.2f,a%.2f)", w, a) |
| + } ?? "bg(nil)" |
| + return String(format: "%@%@ %.0f,%.0f %.0fx%.0f alpha=%.2f opac=%.2f %@%@%@ ty.d=%.0f", |
| + marker ? "→ " : " ", String(describing: type(of: v)), |
| + f.origin.x, f.origin.y, f.width, f.height, |
| + v.alpha, v.layer.opacity, bg, |
| + v.isHidden ? " HIDDEN" : "", |
| + v.clipsToBounds ? " clips" : "", |
| + v.transform.d) |
| + } |
| + |
| + private func writeDump() { |
| + guard let cv = collectionView, let window = cv.window else { return } |
| + var out: [String] = ["== feed dump \(Date()) =="] |
| + out.append("window \(window.bounds.size) safeArea=\(window.safeAreaInsets)") |
| + out.append("cv frame(win)=\(cv.convert(cv.bounds, to: window)) inset=\(cv.contentInset) " |
| + + "offset=\(cv.contentOffset.y) contentH=\(cv.contentSize.height) " |
| + + "gap=\(gapFromBottom()) lastGap=\(lastGap)") |
| + out.append("config top=\(config.topOverlay) bottom=\(config.bottomOverlay) " |
| + + "kb=\(keyboardOverlap) focused=\(config.composerFocused)") |
| + out.append("-- visible cells (item: frame-in-cv alpha/opacity) --") |
| + for ip in cv.indexPathsForVisibleItems.sorted(by: { $0.item < $1.item }) { |
| + guard let cell = cv.cellForItem(at: ip) else { continue } |
| + let host = cell.contentView.subviews.first |
| + out.append(String(format: " #%d y=%.0f h=%.0f cell(a=%.2f o=%.2f) content(ty.d=%.0f a=%.2f) host=%@ hostA=%.2f", |
| + ip.item, cell.frame.origin.y, cell.frame.height, |
| + cell.alpha, cell.layer.opacity, |
| + cell.contentView.transform.d, cell.contentView.alpha, |
| + host.map { String(describing: type(of: $0)) } ?? "none", |
| + host?.alpha ?? -1)) |
| + } |
| + out.append("-- ancestors + siblings (window-ward) --") |
| + var v: UIView = cv |
| + while let sup = v.superview { |
| + for sib in sup.subviews { |
| + out.append(viewLine(sib, marker: sib === v)) |
| + } |
| + out.append(" ^ parent " + viewLine(sup)) |
| + v = sup |
| + } |
| + 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-dump.txt"), |
| + atomically: true, encoding: .utf8) |
| + } |
| + #endif |
| + |
| + fileprivate func boundsChanged() { |
| + // Called from layoutSubviews — the inset write waits a turn (see |
| + // the contentSize KVO note). A pure height change needs no re-pin: |
| + // the flipped origin keeps the newest message glued to the bottom |
| + // edge through resizes on its own. |
| + queueInsetUpdate() |
| + } |
| + } |
| + |
| + // The counter-flip lives on the CELL, enforced after every layout pass: |
| + // assigning a contentConfiguration REPLACES the cell's contentView, so a |
| + // transform set in the registration handler silently vanishes on the next |
| + // (re)configure — rows then render upside down, and the mixed-orientation |
| + // overlap reads as a ghosted, washed-out feed. layoutSubviews runs after |
| + // every configuration swap, so the flip always lands on the CURRENT |
| + // content view. (The cell's own transform can't be used instead: the |
| + // layout stamps attribute transforms over it.) |
| + final class FlippedCell: UICollectionViewCell { |
| + override func layoutSubviews() { |
| + super.layoutSubviews() |
| + if contentView.transform.d != -1 { |
| + contentView.transform = CGAffineTransform(scaleX: 1, y: -1) |
| + } |
| + } |
| + } |
| + |
| + // The flipped collection view itself: initial-anchor timing + the |
| + // VoiceOver ordering tax (index order is newest→oldest; visual order is |
| + // oldest→newest, and VoiceOver must read visually). |
| + final class FlippedCollectionView: UICollectionView { |
| + weak var coordinator: Coordinator? |
| + private var lastSize: CGSize = .zero |
| + |
| + override func didMoveToWindow() { |
| + super.didMoveToWindow() |
| + #if DEBUG |
| + coordinator?.setDumping(window != nil) |
| + #endif |
| + } |
| + |
| + override func layoutSubviews() { |
| + super.layoutSubviews() |
| + if bounds.size != lastSize { |
| + let hadSize = lastSize != .zero |
| + lastSize = bounds.size |
| + if hadSize { coordinator?.boundsChanged() } |
| + } |
| + // The initial anchor is non-reentrant BY CONTRACT (see its ⚠️): |
| + // it only writes insets/offset, which dirty layout for this same |
| + // commit — never a forced nested pass. |
| + if bounds.height > 0, coordinator?.needsInitialAnchor == true { |
| + coordinator?.performInitialAnchor() |
| + } |
| + } |
| + |
| + // Visible cells, oldest first (higher item index = older = visually |
| + // higher), so VoiceOver reads top-to-bottom like every other feed. |
| + override var accessibilityElements: [Any]? { |
| + get { |
| + indexPathsForVisibleItems |
| + .sorted { $0.item > $1.item } |
| + .compactMap { cellForItem(at: $0) } |
| + } |
| + set {} |
| + } |
| + } |
| +} |
| + |
| +// ---- rows (the SwiftUI content hosted in each cell) ------------------------- |
| + |
| + |
| +struct FeedMessageRow: View { |
| + let message: ChatFeedModel.Message |
| + let previous: ChatFeedModel.Message? |
| + let hooks: FeedHooks |
| + @ObservedObject var interaction: FeedInteraction |
| + // Ladder rung 4 drops the long-press menu/actions; 0 (full) includes it. |
| + |
| + // The times-peek shift: just past the WIDEST localized timestamp plus a |
| + // Messages-tight gap — a fixed 64 left a ~30pt dead channel between the |
| + // bubble edge and the time (Paul's 2026-07-21 screenshot comparison). |
| + // Measured from a 20:55 sample so both "20:55" and "8:55 PM" locales fit. |
| + static let timesGutter: CGFloat = { |
| + var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) |
| + c.hour = 20; c.minute = 55 |
| + let d = Calendar.current.date(from: c) ?? Date() |
| + let s = chatTime(Int64(d.timeIntervalSince1970)) |
| + let w = (s as NSString).size(withAttributes: |
| + [.font: UIFont.systemFont(ofSize: 12)]).width |
| + return ceil(max(w, 28)) + 8 |
| + }() |
| + |
| + var body: some View { |
| + let sep = timeSeparator(at: message.at, previous: previous?.at) |
| + let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, message) |
| + // The wrapper VStack exists only when a separator does — a one-child |
| + // stack still runs the whole StackLayout pass per recycle (the |
| + // profile pad's hot path), and most rows carry no separator. |
| + if let sep { |
| + VStack(spacing: 0) { |
| + separator(sep, at: message.at) |
| + rowBody(startsRun: startsRun) |
| + } |
| + } else { |
| + rowBody(startsRun: startsRun) |
| + } |
| + } |
| + |
| + @ViewBuilder private func rowBody(startsRun: Bool) -> some View { |
| + if message.action { |
| + Text(message.text) |
| + .font(.system(size: 13)) |
| + .foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + .frame(maxWidth: .infinity) |
| + .padding(.vertical, 3) |
| + .padding(.horizontal, 14) |
| + } else if hooks.foldsAsMuted(message) { |
| + // A muted member's message (#578): the quiet stub, revealed |
| + // per-message for the session. Always a "them" row. |
| + MutedMessageStub { hooks.revealMuted(message.id) } |
| + .padding(.trailing, 40) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| + bottom: 3, trailing: 12)) |
| + } else { |
| + bubbleRow(startsRun: startsRun) |
| + } |
| + } |
| + |
| + private func bubbleRow(startsRun: Bool) -> some View { |
| + MessageBubble(message: message, showSender: hooks.isGroup && startsRun, |
| + senderName: hooks.senderName(message), |
| + theme: .named(hooks.themeName), |
| + onJump: hooks.onJump, |
| + onOpenThread: hooks.onOpenThread, |
| + blobSource: hooks.blobSource, |
| + onImageTap: hooks.onImageTap, |
| + onFileTap: hooks.onFileTap, |
| + onVideoTap: hooks.onVideoTap, |
| + downloading: interaction.blobDownload, |
| + // The UIKit list owns the long-press menu (the |
| + // coordinator's context-menu delegate); a SwiftUI |
| + // .contextMenu in hosted cells never fired. |
| + actions: nil, |
| + unfurl: hooks.unfurl, |
| + peekUnfurl: hooks.peekUnfurl) |
| + .environment(\.chatFontSize, hooks.chatFontSize) |
| + // The far-side gap + speaking-side alignment as two unary layout |
| + // nodes instead of HStack + Spacers: the profile pad's hot stacks |
| + // were StackLayout.sizeThatFits recursion — every stack/spacer is |
| + // a level, and this runs on every cell recycle (2026-07-21). |
| + .padding(message.mine ? .leading : .trailing, 40) |
| + .frame(maxWidth: .infinity, |
| + alignment: message.mine ? .trailing : .leading) |
| + // Web-parity breathing room: a touch more air between bubbles, |
| + // more still at run starts. |
| + .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| + bottom: 3, trailing: 12)) |
| + // Swipe-right quick-reply: the bubble follows the finger, a |
| + // reply arrow fades in behind it, and a past-threshold release |
| + // starts the reply (the web's touch gesture). Mine-side |
| + // bubbles also shift left with the times peek. |
| + .offset(x: (interaction.replyDrag[message.id] ?? 0) |
| + - (message.mine ? interaction.timesReveal : 0)) |
| + .overlay(alignment: .leading) { |
| + let drag = interaction.replyDrag[message.id] ?? 0 |
| + if drag > 0 { |
| + // REVEALED by the swipe: the arrow rides in from behind the |
| + // bubble's edge, tracking the finger — not parked at the |
| + // margin waiting. |
| + Image(systemName: "arrowshape.turn.up.left.fill") |
| + .font(.system(size: 16)) |
| + .foregroundStyle(drag >= 40 ? AnyShapeStyle(.tint) |
| + : AnyShapeStyle(.tertiary)) |
| + .opacity(Double(min(1, drag / 44))) |
| + .scaleEffect(drag >= 40 ? 1 : 0.7 + 0.3 * min(1, drag / 40)) |
| + .offset(x: drag - 40) |
| + .allowsHitTesting(false) |
| + } |
| + } |
| + // The times peek (Messages): dragging left slides a time in at |
| + // the trailing edge — landing INSIDE the viewport with a |
| + // margin — springing back on release. Built only while a drag |
| + // is revealing it: chatTime's ICU formatting for every row on |
| + // every body eval is pure waste the other 99% of the time. |
| + .overlay(alignment: .trailing) { |
| + if interaction.timesReveal > 0 { |
| + Text(chatTime(message.at)) |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + .fixedSize() |
| + .padding(.trailing, 8) |
| + .offset(x: max(0, Self.timesGutter - interaction.timesReveal)) |
| + .opacity(interaction.timesReveal / Self.timesGutter) |
| + .allowsHitTesting(false) |
| + // Removal rides the release spring, so letting go |
| + // still slides the times away instead of blinking. |
| + .transition(.move(edge: .trailing).combined(with: .opacity)) |
| + } |
| + } |
| + // The swipe that drives replyDrag/timesReveal is a UIKit pan on the |
| + // collection view (StockChatFeed.Coordinator.rowSwiped) — a SwiftUI |
| + // DragGesture here claimed vertical flicks away from the scroll pan |
| + // (the 2026-07-21 Contributors freeze). Rows just render the state. |
| + .clipped() |
| + // The jump wave, applied AFTER .clipped() so the wag never |
| + // beheads at the row edge (shared keyframes: JumpWave in core). |
| + .jumpWave(on: message.id == interaction.danceID, tick: interaction.danceTick) |
| + } |
| + |
| + private func separator(_ sep: TimeSeparator, at seconds: Int64) -> some View { |
| + Group { |
| + if sep == .dated { |
| + Text(chatDayLabel(seconds)).bold() + Text(" \(chatTime(seconds))") |
| + } else { |
| + Text(chatTime(seconds)) |
| + } |
| + } |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + .frame(maxWidth: .infinity) |
| + // More air above a separator than below — it belongs to the messages |
| + // that follow it, not the ones before. |
| + .padding(.top, 18) |
| + .padding(.bottom, 8) |
| + } |
| +} |
| + |
| +// Observes the shared interaction state on behalf of a subtree whose owner |
| +// deliberately doesn't (NativeChatScreen holds FeedInteraction as plain |
| +// @State so drags never re-render the whole screen; the reply-thread |
| +// overlay's bubbles still need live download progress). |
| +struct FeedInteractionReader<Content: View>: View { |
| + @ObservedObject var interaction: FeedInteraction |
| + @ViewBuilder let content: (FeedInteraction) -> Content |
| + var body: some View { content(interaction) } |
| +} |
| + |
| +// The web's typing bubble: bouncing dots in the feed, them-side. |
| +struct FeedTypingRow: View { |
| + let label: String? |
| + let themeName: String |
| + let chatFontSize: CGFloat |
| + |
| + var body: some View { |
| + HStack(spacing: 0) { |
| + TypingBubble(theme: .named(themeName), label: label) |
| + // The bubble sizes itself as one MESSAGE line — without the |
| + // feed's size it fell back to the default and outgrew small |
| + // text settings. |
| + .environment(\.chatFontSize, chatFontSize) |
| + Spacer(minLength: 40) |
| + } |
| + .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12)) |
| + } |
| +} |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index ff04366..6ffd2bc 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -51,10 +51,6 @@ struct NativeChatScreen: View { |
| // The pre-tap last-seen time; non-nil = this open should anchor unreads. |
| @State private var unreadCutoff: Int64? |
| - // The jumped-to message does the little wave (the Mac feed's Minion) — |
| - // danceTick retriggers it even for the same target. |
| - @State private var danceID: Int64? |
| - @State private var danceTick = 0 |
| // A search hit's "open the chat ON this message" request, set by the |
| // chat list before pushing this screen. |
| static var pendingJump: (thread: String, message: Int64)? |
| @@ -167,19 +163,9 @@ struct NativeChatScreen: View { |
| let evidence: ReportEvidence |
| var id: Int64 { evidence.sid } |
| } |
| - // Whether the bottom sentinel is on screen — gates the scroll pins. |
| - @State private var bottomVisible = false |
| - // Jump-to-latest: true once the reader has scrolled a MODERATE distance |
| - // up (see jumpButtonGap) — much wider than the pin logic's near-bottom |
| - // checks above, so the button doesn't appear for merely reading the |
| - // newest couple of messages. Set only at the threshold crossing (see the |
| - // sentinel's geometry probe in `feed`), so scrolling itself doesn't |
| - // trigger extra re-renders. |
| - @State private var jumpButtonVisible = false |
| - static let jumpButtonGap: CGFloat = 400 |
| - // The composer inset's measured height — its growth re-pins the feed (see |
| - // the onChange in `feed`): the inset resolves a frame or two AFTER the |
| - // opening pin, which left the newest message hiding under the composer. |
| + // The header/composer overlay heights (probes above) — the feed extends |
| + // under both and rests content between them via content insets. |
| + @State private var headerHeight: CGFloat = 0 |
| @State private var composerHeight: CGFloat = 0 |
| @State private var showMembers = false |
| @@ -298,6 +284,9 @@ struct NativeChatScreen: View { |
| } |
| .padding(.horizontal, 12) |
| .padding(.vertical, 4) |
| + // Layering step 1 (2026-07-21): liquid glass back on the pill — the |
| + // suspect being tested is the lens's per-frame backdrop sampling |
| + // over a scrolling feed. |
| .floatingGlass(in: Capsule()) |
| } |
| @@ -353,6 +342,13 @@ struct NativeChatScreen: View { |
| // (#577): the keep/adopt choice, inline under the header. |
| RenameBanner(store: store, thread: thread) |
| } |
| + // Measured for the feed's rest inset (the feed extends under |
| + // the header; the probe never shapes layout). |
| + .background(GeometryReader { g in |
| + Color.clear |
| + .onAppear { headerHeight = g.size.height } |
| + .onChange(of: g.size.height) { _, h in headerHeight = h } |
| + }) |
| } |
| .sheet(item: $headerProfile) { target in |
| MemberProfileSheet(store: store, uid: target.id) { e in |
| @@ -375,18 +371,20 @@ struct NativeChatScreen: View { |
| // An ended DM (#578): the server refuses new messages, so |
| // the composer gives way to the closed banner — the one |
| // honest action left is Reopen. |
| - if model.dmClosed { |
| - dmClosedBanner |
| - } else { |
| - composerCluster |
| - .background(GeometryReader { g in |
| - // Measured for the feed's inset-growth re-pin — |
| - // a background probe, so it never shapes layout. |
| - Color.clear |
| - .onAppear { composerHeight = g.size.height } |
| - .onChange(of: g.size.height) { _, h in composerHeight = h } |
| - }) |
| + Group { |
| + if model.dmClosed { |
| + dmClosedBanner |
| + } else { |
| + composerCluster |
| + } |
| } |
| + // Measured for the feed's rest inset — the feed extends |
| + // under the glass; content must rest above it. |
| + .background(GeometryReader { g in |
| + Color.clear |
| + .onAppear { composerHeight = g.size.height } |
| + .onChange(of: g.size.height) { _, h in composerHeight = h } |
| + }) |
| } |
| } |
| .toolbar { |
| @@ -567,14 +565,14 @@ struct NativeChatScreen: View { |
| // temp-file staging + cleanup the file preview uses (a photo and a file tap |
| // are mutually exclusive, so they share previewCleanupDir safely). |
| private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) { |
| - guard blobDownload == nil else { return } |
| - blobDownload = (att.id, 0) |
| + guard interaction.blobDownload == nil else { return } |
| + interaction.blobDownload = (att.id, 0) |
| let started = Date() |
| Task { |
| let data = await model.blobData(att) { p in |
| - if Date().timeIntervalSince(started) > 0.15 { blobDownload = (att.id, p) } |
| + if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) } |
| } |
| - blobDownload = nil |
| + interaction.blobDownload = nil |
| guard let data, |
| let url = stagePreviewFile( |
| data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) |
| @@ -582,9 +580,9 @@ struct NativeChatScreen: View { |
| setRouteNotice("Couldn’t open that photo.") |
| return |
| } |
| - // Dismissing Quick Look re-fires the feed's onAppear; don't let it |
| - // re-pin to the bottom and strand this photo off-screen. |
| - suppressSettleOnReturn = true |
| + // Returning from Quick Look re-fires the feed's onAppear, but |
| + // nothing re-pins on appear any more — the scroll position simply |
| + // survives the modal, so the viewed photo stays on screen. |
| quickLookItem = QuickLookItem(id: att.id, url: url, |
| thumbnail: thumbnail, sourceFrame: sourceFrame) |
| } |
| @@ -592,525 +590,131 @@ struct NativeChatScreen: View { |
| // ---- feed -------------------------------------------------------------- |
| - // Once the feed has landed, arriving messages spring up from beneath the |
| - // composer; gated so opening a chat never animates the whole history in. |
| - @State private var landed = false |
| - // The feed stays invisible until the pin sequence has run. |
| - @State private var settled = false |
| - // True while the OPENING bottom pin is in flight (never for the jump / |
| - // unread-cutoff opens). The sentinel's onAppear uses it to tell "the |
| - // opening pin needs me" from an ordinary later scroll to the bottom. |
| - @State private var pinningToBottom = false |
| - // Quick Look is a full-screen modal, so dismissing it re-fires the feed's |
| - // onAppear — which would re-run settle() and yank a scrolled-up reader back |
| - // to the bottom, stranding the photo they just viewed off-screen. Set when |
| - // we open the viewer, it makes that one return-appear skip the pin. |
| - @State private var suppressSettleOnReturn = false |
| + // The cross-row interaction state (swipe drags, times peek, jump wave, |
| + // download progress). Plain @State, NOT @StateObject — only the row views |
| + // observe it, so a 60Hz drag re-renders the visible cells and never this |
| + // whole screen. |
| + @State private var interaction = FeedInteraction() |
| + // The screen's handle on the feed's scroll position (jumps, the button). |
| + @State private var feedController = FeedScrollController() |
| // The open's "tap → usable feed" measurement (#1210): begun when the feed |
| - // first appears, ended one turn AFTER `settled` flips so the reveal |
| - // frame's layout is inside it — not the old next-runloop approximation, |
| - // which timed the first render pass and called 1–3s opens "fast". |
| + // first appears, ended by the container's first-layout callback — the |
| + // frame the reader actually sees, cold fetch included. |
| @State private var mountSpan: Perf.Span? |
| - |
| - // `hot` = the model was ready BEFORE the feed's first layout (a parked or |
| - // store-hydrated open — the overwhelmingly common case). |
| - private func settle(_ proxy: ScrollViewProxy, hot: Bool = false) { |
| - guard !model.messages.isEmpty else { |
| - settled = true // empty chat — nothing to pin |
| - return |
| - } |
| - // A search hit lands ON its message: page history until the target is |
| - // loaded, then jump-and-wave (overrides the bottom pin for this open). |
| - if let j = Self.pendingJump, j.thread == thread.id { |
| - Self.pendingJump = nil |
| - settled = true |
| - Task { |
| - // Bounded: a hit deeper than ~8 pages stays un-jumped rather |
| - // than risking a watchdog-length layout storm. |
| - var tries = 0 |
| - while !model.messages.contains(where: { $0.id == j.message }), |
| - !model.exhausted, tries < 8 { |
| - tries += 1 |
| - await model.loadOlder() |
| - try? await Task.sleep(nanoseconds: 550_000_000) |
| - } |
| - guard model.messages.contains(where: { $0.id == j.message }) else { return } |
| - try? await Task.sleep(nanoseconds: 150_000_000) |
| - jumpAndWave(j.message, proxy: proxy) |
| - } |
| - return |
| - } |
| - // Notification-tap open with unreads: land the first unread at the |
| - // TOP (just under the title pill) so reading continues from where it |
| - // left off — Messages-style — instead of jumping past everything. |
| - if let cutoff = unreadCutoff { |
| - unreadCutoff = nil |
| - if let target = model.messages.first(where: { $0.at > cutoff && !$0.mine }) { |
| - proxy.scrollTo(target.id, anchor: .top) |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| - proxy.scrollTo(target.id, anchor: .top) |
| - } |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { settled = true } |
| - return |
| - } |
| - } |
| - // A hot open needs NO pin at all: defaultScrollAnchor(.bottom) already |
| - // landed the first layout at the bottom against REAL row heights (the |
| - // content was folded before layout ran), so there is nothing to |
| - // correct and nothing to hide behind an opacity hold. Reveal in the |
| - // first frame. The estimated-height scrollTo this skips was itself |
| - // the #1209 stranded-viewport source, and the held reveal — its |
| - // timers queued behind the open's layout churn — was the felt 1–3s |
| - // open (#1210). The composer-inset re-pin (onChange(composerHeight)) |
| - // still covers the one genuinely late measurement. |
| - if hot { |
| - settled = true |
| - return |
| - } |
| - // COLD loads only, from here down: content published after first |
| - // layout. The immediate jump runs against ESTIMATED row heights — the |
| - // LazyVStack hasn't materialised anything yet, and attachment-heavy |
| - // threads (320pt image rows vs ~40pt text rows) estimate so far off |
| - // that the computed offset can strand the viewport BEYOND the real |
| - // content: a blank feed with nothing on screen to re-anchor it. So the |
| - // pin isn't a timed beat, it's a phase that ends when the bottom |
| - // sentinel GENUINELY materialises (its onAppear re-pins against real |
| - // layout and reveals — see the sentinel below). Everything here is |
| - // pre-reveal, where a scrollTo stalls nobody; the settled feed still |
| - // gets no redundant pins. |
| - pinningToBottom = true |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - // One correction after first layout, for when the jump landed in |
| - // empty space and the sentinel never appeared to finish the pin. The |
| - // pin is deliberately NOT skipped on bottomVisible: the flag can be |
| - // stale-true from the transient estimated pass (the sentinel flashed |
| - // through the viewport, then the reflow stranded it with no layout |
| - // pass left to deliver onDisappear) — trust the pin, not the flag. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| - guard !settled else { return } |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - if bottomVisible { finishBottomPin() } |
| - } |
| - // Bounded fallback: the reveal must never deadlock. Pin once more |
| - // UNCONDITIONALLY — bottomVisible can be stale-true here (the |
| - // estimated pass flashed the sentinel through the viewport), and |
| - // skipping the pin on that lie revealed the feed stranded above the |
| - // fold (#1209's cold-open shape). |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| - guard !settled else { return } |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - finishBottomPin() |
| + @State private var mountEnded = false |
| + |
| + // A search hit lands ON its message: page history until the target is |
| + // loaded, then jump-and-wave. (The open still lands at the bottom first — |
| + // the walk is bounded so a hit deeper than ~8 pages stays un-jumped |
| + // rather than risking a watchdog-length stall.) |
| + private func runPendingJump() { |
| + guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return } |
| + Self.pendingJump = nil |
| + Task { |
| + var tries = 0 |
| + while !model.messages.contains(where: { $0.id == j.message }), |
| + !model.exhausted, tries < 8 { |
| + tries += 1 |
| + await model.loadOlder() |
| + try? await Task.sleep(nanoseconds: 550_000_000) |
| + } |
| + guard model.messages.contains(where: { $0.id == j.message }) else { return } |
| + try? await Task.sleep(nanoseconds: 150_000_000) |
| + jumpAndWave(j.message) |
| } |
| } |
| - private func finishBottomPin() { |
| - pinningToBottom = false |
| - settled = true |
| - } |
| - |
| - private func updateJumpButtonVisible(gap: CGFloat) { |
| - let show = gap > Self.jumpButtonGap |
| - if show != jumpButtonVisible { jumpButtonVisible = show } |
| - } |
| - |
| + // The feed is a STOCK, unflipped UICollectionView (StockChatFeed — |
| + // promoted from the /freshpane experiment, which scrolled silky on |
| + // device where the flipped container's dressing didn't). Standard safe |
| + // areas: SwiftUI sizes it between the header and the composer/keyboard, |
| + // and the container keeps the bottom anchored across those resizes. |
| + // This screen only supplies the chrome around it and the row hooks. |
| private var feed: some View { |
| - GeometryReader { geo in |
| - ScrollViewReader { proxy in |
| - ScrollView { |
| - LazyVStack(spacing: 0) { |
| - // Iterate the published array directly: the old |
| - // Array(enumerated()) copied every heavy Message struct on |
| - // EVERY body evaluation (each publish, each keystroke) just |
| - // to know a row's position — with lazy rows, a per-row |
| - // binary search only runs for what's actually on screen. |
| - ForEach(model.messages) { m in |
| - let i = messageIndex(of: m.id) ?? 0 |
| - row(m, previous: i > 0 ? model.messages[i - 1] : nil, proxy: proxy) |
| - .id(m.id) |
| - // Removal is a plain fade, NOT a scale-implosion: |
| - // the LazyVStack destroys off-screen rows during |
| - // animated layout passes (cancelling a reply shrinks |
| - // the composer inset), and a scale removal imploded |
| - // the WHOLE feed to nothing until you left + returned. |
| - .transition(.asymmetric( |
| - insertion: .move(edge: .bottom).combined(with: .opacity), |
| - removal: .opacity)) |
| - .onAppear { |
| - // `landed` gate (the +0.4s opening beat): the |
| - // opening layout transiently materialises top |
| - // rows, and each spurious page prepend jumped |
| - // the feed up a screenful and churned layout so |
| - // hard scrolling felt dead for seconds. Hot |
| - // opens are `settled` from the first frame, so |
| - // that gate alone stopped protecting this. |
| - guard settled, landed, i < 8, model.ready, !model.exhausted else { return } |
| - Task { await model.loadOlder() } |
| - } |
| - } |
| - // The web's typing bubble: bouncing dots in the feed, |
| - // them-side (the composer-cluster text line clipped the |
| - // bubble beneath it). |
| - if !model.typing.isEmpty { |
| - HStack(spacing: 0) { |
| - TypingBubble(theme: .named(themeName), |
| - label: isGroup ? typingSummary(model.typing) : nil) |
| - // The bubble sizes itself as one MESSAGE line — |
| - // without the feed's size it fell back to the |
| - // default and outgrew small text settings. |
| - .environment(\.chatFontSize, chatFontSize) |
| - Spacer(minLength: 40) |
| - } |
| - .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12)) |
| - .transition(.opacity) |
| - } |
| - // Fixed-height pin target: scrollTo(lastRow) computes |
| - // against the row's ESTIMATED height while unmaterialised, |
| - // landing a tall final message one message short. Its |
| - // on-screen-ness gates the pins below: proxy.scrollTo is a |
| - // ~100ms layout stall, and while the sentinel is visible |
| - // the bottom anchor keeps growth pinned for free. |
| - Color.clear.frame(height: 1).id(Self.bottomSentinel) |
| - .onAppear { |
| - bottomVisible = true |
| - // The opening pin computed its offset from |
| - // ESTIMATED row heights; the sentinel materialising |
| - // means layout is now real — one unanimated re-pin |
| - // (deferred out of this layout pass) lands the |
| - // bottom exactly, and the feed reveals actually |
| - // pinned. Later visits to the bottom skip this: |
| - // pinningToBottom is only up during the open. |
| - if pinningToBottom { |
| - DispatchQueue.main.async { |
| - guard pinningToBottom else { return } |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - finishBottomPin() |
| - } |
| - } |
| - } |
| - .onDisappear { bottomVisible = false } |
| - } |
| - .padding(.vertical, 6) |
| - // Short chats read from the TOP: without this the bottom |
| - // anchor bottom-aligns content shorter than the viewport. |
| - .frame(minHeight: geo.size.height, alignment: .top) |
| - // Jump-button geometry probe: the CONTAINER's frame, not the |
| - // bottom sentinel's — a leaf row can be pruned by LazyVStack |
| - // once it's far enough off-screen, so reading gap off the |
| - // sentinel would freeze the wrong value the moment it's |
| - // culled, well before a real "moderate scroll" threshold. The |
| - // LazyVStack's own extent is never culled — SwiftUI needs it |
| - // for the scroll math regardless of which rows are realised — |
| - // so its maxY always reflects the true content bottom. State |
| - // only flips at the threshold crossing, so scrolling itself |
| - // never re-renders the feed. |
| - .background(GeometryReader { g in |
| - let gap = g.frame(in: .named("chatFeed")).maxY - geo.size.height |
| - Color.clear |
| - .onAppear { updateJumpButtonVisible(gap: gap) } |
| - .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) } |
| - }) |
| - // Keyed on the LAST id: new arrivals animate, history prepends |
| - // (which change the first id, not the last) don't. |
| - .animation(landed ? .spring(response: 0.42, dampingFraction: 0.8) : nil, |
| - value: model.messages.last?.id) |
| - // Inside the content, so the probe's superview chain passes |
| - // through the real UIScrollView. |
| - .background(ScrollViewIntrospect { scroll in |
| - scroll.keyboardDismissMode = .interactive |
| - }) |
| - } |
| - .coordinateSpace(name: "chatFeed") |
| - // Anchor to the bottom BEFORE first layout — a prewarmed model |
| - // arrives with its messages already folded, and a scrollTo in |
| - // onAppear races layout and loses (the land-at-top bug). The |
| - // anchor also keeps the newest message pinned as content grows. |
| - .defaultScrollAnchor(.bottom) |
| - // Force-scroll only for your OWN send — for everyone else's |
| - // messages defaultScrollAnchor(.bottom) already keeps the feed |
| - // pinned when you're at the bottom and leaves you alone when |
| - // you're reading history. |
| - .onChange(of: model.messages.last?.id) { _, id in |
| - // Pin for your own send — and for ANY arrival while the |
| - // keyboard is up: composing means you're at the conversation's |
| - // tail, and the bottom anchor doesn't hold through keyboard |
| - // insets, so replies were landing hidden behind the keyboard. |
| - // Your OWN send always pins — the reply banner collapsing at |
| - // send shifts the inset under the anchor, which left the new |
| - // message hiding behind the composer. Arrivals while composing |
| - // keep the cheap skip-when-flush gate. |
| - let mine = model.messages.last?.mine == true |
| - if id != nil, mine || (composerFocused && !bottomVisible) { |
| - withAnimation(.easeOut(duration: 0.15)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } |
| - // The open, foregrounded chat is being read — arrivals here |
| - // must not accrue an unread badge. |
| - if scenePhase == .active, |
| - let t = store.threads.first(where: { $0.id == thread.id }), t.unread { |
| - store.markRead(t) |
| - } |
| - } |
| - // Staggered re-pins: rows materialise with their real (often |
| - // taller) heights after the first jump, which can leave the feed |
| - // a message short of the bottom. |
| - .onChange(of: model.ready) { _, ready in |
| - if ready { settle(proxy) } |
| - } |
| - // The typing bubble appearing while you compose should be seen — |
| - // same keyboard-inset blind spot as arrivals. |
| - .onChange(of: model.typing.isEmpty) { _, empty in |
| - // Same gate: at the bottom the typing bubble slides in under |
| - // the anchor with no pin — the pin here was the "little pause |
| - // when 'is typing' appears". |
| - if !empty, composerFocused, !bottomVisible { |
| - withAnimation(.easeOut(duration: 0.15)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } |
| - } |
| - // The keyboard rising should lift the feed with it — the last |
| - // message lands above the composer, not behind the keyboard. |
| - // (The ignoresSafeArea(.keyboard)+contentMargins experiment is |
| - // deliberately reverted: it coincided with a bubble-rendering |
| - // report — keep the boring known-good behaviour.) |
| - .onReceive(NotificationCenter.default.publisher( |
| - for: UIResponder.keyboardWillShowNotification)) { _ in |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { |
| - withAnimation(.easeOut(duration: 0.15)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } |
| - } |
| - // The composer inset resolves its height a frame or two AFTER the |
| - // opening pin (GrowingTextView's intrinsic size + the glass), and |
| - // defaultScrollAnchor(.bottom) does NOT hold through bottom-inset |
| - // growth — so chats opened with the last message under the |
| - // composer. Mirror the keyboard handler: growth re-pins, but only |
| - // before the reveal or while the reader is at the bottom — never |
| - // yanking someone who has scrolled up into history. |
| - .onChange(of: composerHeight) { old, new in |
| - guard new > old, model.ready else { return } |
| - // Hot opens are `settled` from the first frame, but the inset |
| - // still resolves late — `landed` (the +0.4s opening beat) |
| - // keeps the hard, unanimated pin for that window. DEFERRED a |
| - // turn: this fires DURING the opening layout pass, when row |
| - // heights are still estimates, and a scrollTo computed |
| - // against those strands the viewport beyond the content — a |
| - // blank feed with the messages above the fold. One turn later |
| - // layout is real and the pin lands exactly (the same trick as |
| - // the sentinel's re-pin). |
| - if !settled || !landed { |
| - DispatchQueue.main.async { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } else if bottomVisible { |
| - withAnimation(.easeOut(duration: 0.15)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } |
| - } |
| - .onAppear { |
| - // The mount clock starts at the OPENING appear only — modal |
| - // returns (Quick Look, the video viewer) re-fire onAppear on |
| - // an already-settled feed and must not restart it. |
| - if mountSpan == nil, !settled { |
| - mountSpan = Perf.begin("feed.mount", slowMs: 500) |
| - } |
| - // A parked model is ready immediately — onChange(ready) never |
| - // fires on a revisit, so settle from here too, on the HOT |
| - // path: its content laid out before this appear, so the |
| - // bottom anchor already landed it. But skip it when returning |
| - // from Quick Look: re-pinning would strand the just-viewed |
| - // photo off-screen (see suppressSettleOnReturn). |
| - if suppressSettleOnReturn { |
| - suppressSettleOnReturn = false |
| - } else if model.ready, !settled { |
| - settle(proxy, hot: true) |
| - } |
| - // Arm the arrival animation only after the opening render has |
| - // settled (parked models are ready immediately). |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true } |
| - } |
| - // The mount span ends one turn after the reveal commits, so the |
| - // revealed frame's layout is measured — "feed.mount" now honestly |
| - // means tap → usable feed, cold fetch + pin phase included. |
| - .onChange(of: settled) { _, isSettled in |
| - guard isSettled, let span = mountSpan else { return } |
| - mountSpan = nil |
| - let count = model.messages.count |
| - DispatchQueue.main.async { span.end("\(count) messages") } |
| - } |
| - // Foregrounding with this chat on screen: merge whatever the NSE |
| - // filed while we were suspended straight from disk — the pushed |
| - // message shows before the socket reconnects and re-pulls. |
| - .onChange(of: scenePhase) { _, phase in |
| - guard phase == .active else { return } |
| - Task { await model.catchUpFromStore() } |
| - } |
| - // The feed reveals only once history has folded AND the pins have |
| - // run — the reader finds the chat already at the bottom, never |
| - // watching it snap there. Cold loads show the loader meanwhile. |
| - .opacity(model.ready && settled ? 1 : 0) |
| - .overlay { |
| - if !model.ready { |
| - ProgressView() |
| - } |
| + ZStack { |
| + StockChatFeed(model: model, |
| + interaction: interaction, |
| + controller: feedController, |
| + hooks: feedHooks, |
| + topOverlay: headerHeight, |
| + bottomOverlay: composerHeight, |
| + composerFocused: composerFocused, |
| + unreadCutoff: unreadCutoff, |
| + onFirstLayout: { count in |
| + mountEnded = true |
| + guard let span = mountSpan else { return } |
| + mountSpan = nil |
| + span.end("\(count) messages") |
| + }) |
| + // Full-bleed both ways, the Messages way: the container is a |
| + // view CONTROLLER declaring the glass overlays as REAL safe |
| + // areas (additionalSafeAreaInsets + automatic adjustment), so |
| + // the system's top-edge treatment gets honest geometry and |
| + // degrades to the subtle status-bar fade — every manual-inset |
| + // variant fed it garbage and it squashed rows over half the |
| + // screen. The keyboard safe area stays live (SwiftUI shrinks |
| + // the feed for it; the container keeps the bottom anchored |
| + // through the resize). |
| + .ignoresSafeArea(.container, edges: .vertical) |
| + // Cold loads show the loader; the first layout already lands at |
| + // the bottom, so there is no settle pass left to hide. |
| + if !model.ready { |
| + ProgressView() |
| } |
| - .animation(.easeOut(duration: 0.12), value: model.ready && settled) |
| - // Jump-to-latest (web parity): bottom-right of the feed, shown |
| - // once jumpButtonVisible flips (the sentinel probe above). |
| - .overlay(alignment: .bottomTrailing) { |
| - if model.ready && settled && jumpButtonVisible { |
| - Button { |
| - withAnimation(.easeOut(duration: 0.2)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } label: { |
| - Image(systemName: "chevron.down") |
| - .font(.system(size: 16, weight: .semibold)) |
| - .frame(width: 40, height: 40) |
| - .floatingGlass(in: Circle()) |
| - } |
| - .buttonStyle(.plain) |
| - .accessibilityLabel("Jump to latest") |
| - .padding(16) |
| - .transition(.opacity) |
| - } |
| + } |
| + // The open, foregrounded chat is being read — arrivals here must not |
| + // accrue an unread badge. |
| + .onChange(of: model.messages.last?.id) { _, _ in |
| + if scenePhase == .active, |
| + let t = store.threads.first(where: { $0.id == thread.id }), t.unread { |
| + store.markRead(t) |
| } |
| } |
| + // A search hit can arrive before or after history folds — cover both. |
| + .onChange(of: model.ready) { _, ready in |
| + if ready { runPendingJump() } |
| } |
| - } |
| - |
| - // A row's position in the feed — binary search, safe because every fold |
| - // path in ChatFeedModel sorts by id before publishing. |
| - private func messageIndex(of id: Int64) -> Int? { |
| - let list = model.messages |
| - var lo = 0, hi = list.count - 1 |
| - while lo <= hi { |
| - let mid = (lo + hi) / 2 |
| - if list[mid].id == id { return mid } |
| - if list[mid].id < id { lo = mid + 1 } else { hi = mid - 1 } |
| + // Foregrounding with this chat on screen: merge whatever the NSE |
| + // filed while we were suspended straight from disk — the pushed |
| + // message shows before the socket reconnects and re-pulls. |
| + .onChange(of: scenePhase) { _, phase in |
| + guard phase == .active else { return } |
| + Task { await model.catchUpFromStore() } |
| } |
| - return nil |
| - } |
| - |
| - @ViewBuilder |
| - private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?, |
| - proxy: ScrollViewProxy) -> some View { |
| - let sep = timeSeparator(at: m.at, previous: previous?.at) |
| - let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m) |
| - VStack(spacing: 0) { |
| - if let sep { |
| - separator(sep, at: m.at) |
| - } |
| - if m.action { |
| - Text(m.text) |
| - .font(.system(size: 13)) |
| - .foregroundStyle(.secondary) |
| - .multilineTextAlignment(.center) |
| - .frame(maxWidth: .infinity) |
| - .padding(.vertical, 3) |
| - .padding(.horizontal, 14) |
| - } else if model.foldsAsMuted(m) { |
| - // A muted member's message (#578): the quiet stub, revealed |
| - // per-message for the session. Always a "them" row. |
| - HStack(spacing: 0) { |
| - MutedMessageStub { model.revealMuted(m.id) } |
| - Spacer(minLength: 40) |
| - } |
| - .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| - bottom: 3, trailing: 12)) |
| - } else { |
| - HStack(spacing: 0) { |
| - if m.mine { Spacer(minLength: 40) } |
| - MessageBubble(message: m, showSender: isGroup && startsRun, |
| - senderName: model.senderName(m), |
| - theme: .named(themeName), |
| - onJump: { target in jumpAndWave(target, proxy: proxy) }, |
| - onOpenThread: { root in openThread(root) }, |
| - blobSource: { model.blobSource($0) }, |
| - onImageTap: { att, image, frame in |
| - openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| - }, |
| - onFileTap: { openFile($0) }, |
| - onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| - downloading: blobDownload, |
| - actions: messageActions(m), |
| - unfurl: { url in |
| - let card = await model.unfurl(url) |
| - // The link card loads AFTER the send pin ran |
| - // and grows the bubble below the viewport — |
| - // if this is the last row and the reader is |
| - // at the bottom, re-pin so the link they |
| - // just posted stays visible. |
| - if card != nil, m.id == model.messages.last?.id, bottomVisible { |
| - DispatchQueue.main.async { |
| - withAnimation(.easeOut(duration: 0.15)) { |
| - proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| - } |
| - } |
| - } |
| - return card |
| - }) |
| - .environment(\.chatFontSize, chatFontSize) |
| - if !m.mine { Spacer(minLength: 40) } |
| - } |
| - // Web-parity breathing room: a touch more air between bubbles, |
| - // more still at run starts. |
| - .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| - bottom: 3, trailing: 12)) |
| - // Swipe-right quick-reply: the bubble follows the finger, a |
| - // reply arrow fades in behind it, and a past-threshold release |
| - // starts the reply (the web's touch gesture). Mine-side |
| - // bubbles also shift left with the times peek. |
| - .offset(x: (replyDrag[m.id] ?? 0) - (m.mine ? timesReveal : 0)) |
| - .overlay(alignment: .leading) { |
| - let drag = replyDrag[m.id] ?? 0 |
| - if drag > 0 { |
| - // REVEALED by the swipe: the arrow rides in from |
| - // behind the bubble's edge, tracking the finger — |
| - // not parked at the margin waiting. |
| - Image(systemName: "arrowshape.turn.up.left.fill") |
| - .font(.system(size: 16)) |
| - .foregroundStyle(drag >= 40 ? AnyShapeStyle(.tint) |
| - : AnyShapeStyle(.tertiary)) |
| - .opacity(Double(min(1, drag / 44))) |
| - .scaleEffect(drag >= 40 ? 1 : 0.7 + 0.3 * min(1, drag / 40)) |
| - .offset(x: drag - 40) |
| - .allowsHitTesting(false) |
| - } |
| - } |
| - // The times peek (Messages): dragging left slides a time in at |
| - // the trailing edge — landing INSIDE the viewport with a |
| - // margin — springing back on release. Built only while a drag |
| - // is revealing it: chatTime's ICU formatting for every row on |
| - // every body eval is pure waste the other 99% of the time. |
| - .overlay(alignment: .trailing) { |
| - if timesReveal > 0 { |
| - Text(chatTime(m.at)) |
| - .font(.system(size: 11)) |
| - .foregroundStyle(.secondary) |
| - .fixedSize() |
| - .padding(.trailing, 10) |
| - .offset(x: max(0, Self.timesGutter - timesReveal)) |
| - .opacity(timesReveal / Self.timesGutter) |
| - .allowsHitTesting(false) |
| - // Removal rides the release spring, so letting go |
| - // still slides the times away instead of blinking. |
| - .transition(.move(edge: .trailing).combined(with: .opacity)) |
| - } |
| - } |
| - .simultaneousGesture(rowSwipe(m)) |
| - .clipped() |
| - // The jump wave, applied AFTER .clipped() so the wag never |
| - // beheads at the row edge (shared keyframes: JumpWave in core). |
| - .jumpWave(on: m.id == danceID, tick: danceTick) |
| + .onAppear { |
| + // The mount clock starts at the OPENING appear only — modal |
| + // returns (Quick Look, the video viewer) re-fire onAppear on an |
| + // already-landed feed and must not restart it. |
| + if mountSpan == nil, !mountEnded { |
| + mountSpan = Perf.begin("feed.mount", slowMs: 500) |
| } |
| + runPendingJump() |
| } |
| } |
| + // Everything a feed row needs from this screen, resolved at cell-configure |
| + // time (rows never observe the model — see FeedHooks). |
| + private var feedHooks: FeedHooks { |
| + FeedHooks(isGroup: isGroup, |
| + themeName: themeName, |
| + chatFontSize: chatFontSize, |
| + senderName: { model.senderName($0) }, |
| + foldsAsMuted: { model.foldsAsMuted($0) }, |
| + revealMuted: { model.revealMuted($0) }, |
| + actions: { messageActions($0) }, |
| + onJump: { jumpAndWave($0) }, |
| + onOpenThread: { openThread($0) }, |
| + blobSource: { model.blobSource($0) }, |
| + onImageTap: { att, image, frame in |
| + openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| + }, |
| + onFileTap: { openFile($0) }, |
| + onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| + unfurl: { await model.unfurl($0) }, |
| + peekUnfurl: { model.cachedUnfurl($0) }, |
| + startReply: { startReply($0) }) |
| + } |
| + |
| // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) -- |
| // The lifted thread panel over the dimmed feed. Rows reuse the exact |
| @@ -1141,19 +745,24 @@ struct NativeChatScreen: View { |
| } |
| private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| - MessageBubble(message: m, showSender: showSender, |
| - senderName: model.senderName(m), |
| - theme: .named(themeName), |
| - blobSource: { model.blobSource($0) }, |
| - onImageTap: { att, image, frame in |
| - openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| - }, |
| - onFileTap: { openFile($0) }, |
| - onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| - downloading: blobDownload, |
| - actions: messageActions(m), |
| - unfurl: { await model.unfurl($0) }) |
| - .environment(\.chatFontSize, chatFontSize) |
| + // Wrapped in the interaction reader: the screen deliberately doesn't |
| + // observe FeedInteraction, but the overlay's download rings still need |
| + // live progress. |
| + FeedInteractionReader(interaction: interaction) { inter in |
| + MessageBubble(message: m, showSender: showSender, |
| + senderName: model.senderName(m), |
| + theme: .named(themeName), |
| + blobSource: { model.blobSource($0) }, |
| + onImageTap: { att, image, frame in |
| + openQuickLook(att, thumbnail: image, sourceFrame: frame) |
| + }, |
| + onFileTap: { openFile($0) }, |
| + onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| + downloading: inter.blobDownload, |
| + actions: messageActions(m), |
| + unfurl: { await model.unfurl($0) }) |
| + .environment(\.chatFontSize, chatFontSize) |
| + } |
| } |
| // One catalog for the feed and the thread view — reply/edit/delete/copy/ |
| @@ -1211,88 +820,21 @@ struct NativeChatScreen: View { |
| } |
| // Scroll to a message and wave it — quote-taps and search hits share this. |
| - private func jumpAndWave(_ id: Int64, proxy: ScrollViewProxy) { |
| - // Animated only for NEAR jumps: animating across hundreds of lazily |
| - // estimated rows is a main-thread layout storm (the post-search |
| - // watchdog kill); far targets snap instantly, then wave. |
| - let distance = model.messages.count |
| - - (model.messages.firstIndex { $0.id == id } ?? model.messages.count) |
| - if distance <= 60 { |
| - withAnimation { proxy.scrollTo(id, anchor: .center) } |
| - } else { |
| - proxy.scrollTo(id, anchor: .center) |
| - } |
| + // The near/far animation split lives in the container's scrollTo. |
| + private func jumpAndWave(_ id: Int64) { |
| + feedController.scrollTo(id: id) |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| - danceID = id |
| - danceTick += 1 |
| - } |
| - } |
| - |
| - // One horizontal drag, two meanings: rightward on a bubble = quick-reply |
| - // (with a haptic at the threshold), leftward = peek the times. Vertical |
| - // drags stay the scroll's. |
| - @State private var timesReveal: CGFloat = 0 |
| - @State private var replyDrag: [Int64: CGFloat] = [:] |
| - @State private var replyArmed = false |
| - static let timesGutter: CGFloat = 64 |
| - static let bottomSentinel = "bottom-sentinel" |
| - |
| - private func rowSwipe(_ m: ChatFeedModel.Message) -> some Gesture { |
| - // A higher engage distance + stronger horizontal dominance: the swipe |
| - // must never contend with vertical scrolling (it read as scroll jank, |
| - // worst with the keyboard up). |
| - DragGesture(minimumDistance: 18) |
| - .onChanged { v in |
| - guard abs(v.translation.width) > abs(v.translation.height) * 1.6 else { return } |
| - if v.translation.width > 0 { |
| - let drag = min(72, v.translation.width * 0.6) |
| - replyDrag[m.id] = drag |
| - if drag >= 40, !replyArmed { |
| - replyArmed = true |
| - UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| - } |
| - } else { |
| - timesReveal = min(Self.timesGutter, -v.translation.width) |
| - } |
| - } |
| - .onEnded { _ in |
| - if (replyDrag[m.id] ?? 0) >= 40 { startReply(m) } |
| - replyArmed = false |
| - withAnimation(.spring(response: 0.35, dampingFraction: 0.72)) { |
| - replyDrag[m.id] = 0 |
| - timesReveal = 0 |
| - } |
| - } |
| - } |
| - |
| - private func separator(_ sep: TimeSeparator, at seconds: Int64) -> some View { |
| - Group { |
| - if sep == .dated { |
| - Text(chatDayLabel(seconds)).bold() + Text(" \(chatTime(seconds))") |
| - } else { |
| - Text(chatTime(seconds)) |
| - } |
| + interaction.danceID = id |
| + interaction.danceTick += 1 |
| } |
| - .font(.system(size: 12)) |
| - .foregroundStyle(.secondary) |
| - .frame(maxWidth: .infinity) |
| - // More air above a separator than below — it belongs to the messages |
| - // that follow it, not the ones before. |
| - .padding(.top, 18) |
| - .padding(.bottom, 8) |
| } |
| - // The attachment currently downloading + its 0…1 progress — drives the |
| - // video card's radial ring and the file chip's, same as the Mac. Shared |
| - // across both: a given attachment is either a video or a file, never both. |
| - @State private var blobDownload: (id: String, progress: Double)? |
| - |
| private func playVideo(_ att: Attachment, from frame: CGRect) { |
| - guard blobDownload == nil else { return } |
| - blobDownload = (att.id, 0) |
| + guard interaction.blobDownload == nil else { return } |
| + interaction.blobDownload = (att.id, 0) |
| Task { |
| - let data = await model.blobData(att) { p in blobDownload = (att.id, p) } |
| - blobDownload = nil |
| + let data = await model.blobData(att) { p in interaction.blobDownload = (att.id, p) } |
| + interaction.blobDownload = nil |
| guard let data else { return } |
| let loader = MemoryVideoAsset(data: data, mimeType: att.type) |
| let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset())) |
| @@ -1312,16 +854,16 @@ struct NativeChatScreen: View { |
| // temp dir is fine here: it's the member's deliberate act (like the web's |
| // file-chip download), and the dir is burned when the preview closes. |
| private func openFile(_ att: Attachment) { |
| - guard blobDownload == nil else { return } |
| - blobDownload = (att.id, 0) |
| + guard interaction.blobDownload == nil else { return } |
| + interaction.blobDownload = (att.id, 0) |
| // The web's 150ms grace: a fetch that finishes instantly never flashes |
| // a ring — only one that outlives the grace grows it. |
| let started = Date() |
| Task { |
| let data = await model.blobData(att) { p in |
| - if Date().timeIntervalSince(started) > 0.15 { blobDownload = (att.id, p) } |
| + if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) } |
| } |
| - blobDownload = nil |
| + interaction.blobDownload = nil |
| guard let data, |
| let dest = stagePreviewFile( |
| data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) |
| @@ -2154,49 +1696,3 @@ private struct PlayerSurface: UIViewControllerRepresentable { |
| } |
| } |
| } |
| -import SwiftUI |
| -import UIKit |
| - |
| -// 🤖 Reaches the UIScrollView underneath a SwiftUI ScrollView to set UIKit-only |
| -// behaviour (keyboardDismissMode = .interactive: the keyboard tracks the finger |
| -// AND the list keeps scrolling — SwiftUI's .scrollDismissesKeyboard(.interactively) |
| -// freezes the scroll while the keyboard moves). The probe sits in the content; |
| -// its superview chain passes through the scroll view. |
| -private struct ScrollViewIntrospect: UIViewRepresentable { |
| - let configure: (UIScrollView) -> Void |
| - |
| - func makeUIView(context: Context) -> UIView { |
| - let probe = UIView() |
| - probe.isUserInteractionEnabled = false |
| - DispatchQueue.main.async { apply(from: probe) } |
| - return probe |
| - } |
| - |
| - func updateUIView(_ probe: UIView, context: Context) { |
| - DispatchQueue.main.async { apply(from: probe) } |
| - } |
| - |
| - private func apply(from probe: UIView) { |
| - // Up first (probe inside the scroll content), then down from the top — |
| - // covers both content and background placements. |
| - var view: UIView? = probe.superview |
| - var top: UIView = probe |
| - while let v = view { |
| - if let scroll = v as? UIScrollView { |
| - configure(scroll) |
| - return |
| - } |
| - top = v |
| - view = v.superview |
| - } |
| - if let scroll = Self.descendantScrollView(of: top) { configure(scroll) } |
| - } |
| - |
| - private static func descendantScrollView(of view: UIView) -> UIScrollView? { |
| - for sub in view.subviews { |
| - if let scroll = sub as? UIScrollView { return scroll } |
| - if let found = descendantScrollView(of: sub) { return found } |
| - } |
| - return nil |
| - } |
| -} |
diff --git a/ios/LChat/Views/StockChatFeed.swift b/ios/LChat/Views/StockChatFeed.swift| new file mode 100644 |
| index 0000000..da12c78 |
| --- /dev/null |
| +++ b/ios/LChat/Views/StockChatFeed.swift |
| @@ -0,0 +1,1573 @@ |
| +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<Int, FeedItem>! |
| + private var current: [ChatFeedModel.Message] = [] |
| + private var typingNames: [String] = [] |
| + private var cancellables = Set<AnyCancellable>() |
| + 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<FeedHostedCell, FeedItem> { |
| + [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<Int, FeedItem>(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 () }) |
| + .dropFirst(3) |
| + .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<Int, FeedItem>() |
| + 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) |
| + 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<AnyView>? |
| + |
| + 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..<count { |
| + let h = itemAt(i).map { heightFor($0, width) } ?? 0 |
| + frames.append(CGRect(x: 0, y: y, width: width, height: h)) |
| + y += h |
| + } |
| + contentHeight = y |
| + } |
| + |
| + override var collectionViewContentSize: CGSize { |
| + CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight) |
| + } |
| + |
| + override func layoutAttributesForElements(in rect: CGRect) -> [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) |
| + } |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 524559d..b8bbb80 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -221,6 +221,16 @@ public final class ChatFeedModel: ObservableObject { |
| if dmClosed != closed { dmClosed = closed } |
| } |
| + #if DEBUG |
| + /// Feed-container tests drive the published stream directly — the setters |
| + /// are private so nothing in the app can bypass the fold, but geometry |
| + /// tests need a model with known content and no server. |
| + public func _testInject(messages: [Message]? = nil, typing: [String]? = nil) { |
| + if let messages { self.messages = messages; ready = true } |
| + if let typing { self.typing = typing } |
| + } |
| + #endif |
| + |
| /// Where an attachment's encrypted bytes live + the key to open them — an |
| /// AvatarSource because it's the same fetch+decrypt (BlobImageLoader), just |
| /// with the thread key instead of the instance key. |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index b0cae64..f7d4abc 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -183,7 +183,36 @@ public struct MessageBubble: View { |
| } |
| + // The common case — plain text, no chrome around the bubble at all — |
| + // renders WITHOUT the wrapper VStack and tag scaffolding. The profile |
| + // pad's hot stacks were almost entirely StackLayout.sizeThatFits |
| + // recursion (release SwiftUICore, so device Release pays it too), and |
| + // every stack level costs on every cell recycle; most feed rows are |
| + // exactly this shape. |
| + private var isSimpleText: Bool { |
| + message.attachments.isEmpty |
| + && message.reply == nil |
| + && message.reactions.isEmpty |
| + && !message.edited |
| + && message.replyCount == 0 |
| + && message.raw.warn != true |
| + && message.raw.card == nil |
| + && !(showSender && !message.mine) |
| + && !message.text.isEmpty |
| + && Self.cachedFirstLink(message.text) == nil |
| + } |
| + |
| public var body: some View { |
| + if isSimpleText { |
| + bubble |
| + .modifier(ActionsChrome(owner: self)) |
| + } else { |
| + fullBody |
| + .modifier(ActionsChrome(owner: self)) |
| + } |
| + } |
| + |
| + private var fullBody: some View { |
| VStack(alignment: message.mine ? .trailing : .leading, spacing: 3) { |
| if showSender, !message.mine, !(senderName ?? message.name).isEmpty { |
| Text(senderName ?? message.name) |
| @@ -270,19 +299,36 @@ public struct MessageBubble: View { |
| // quiet timestamp beside the bubble. On the Mac the menu is a real |
| // NSMenu via a catcher that lets left events through, so drag-to-select |
| // still works on bubble text; elsewhere it's a SwiftUI context menu. |
| - #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| - .overlay { |
| - if let actions, !message.action { |
| - MessageRightClick(actions: actions, mine: message.mine, timestamp: hoverTime) |
| + } |
| + |
| + // The action chrome, attached ONLY when this surface offers actions: |
| + // the iOS feed passes nil (its UIKit list owns the long-press menu), |
| + // and installing the interaction anyway cost ~1ms on every cell |
| + // recycle — pure scroll-path waste for a menu that could never show. |
| + // The thread view and any other actions-bearing surface keep it. On |
| + // the Mac it's the MessageRightClick overlay; on touch, the context |
| + // menu whose preview shape hugs the bubble — without that shape UIKit |
| + // snapshots the whole row stack (bubble + reactions + preview card) |
| + // and flashes a rectangular background while the menu opens. |
| + private struct ActionsChrome: ViewModifier { |
| + let owner: MessageBubble |
| + func body(content: Content) -> some View { |
| + if let actions = owner.actions, !owner.message.action { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + content.overlay { |
| + MessageRightClick(actions: actions, mine: owner.message.mine, |
| + timestamp: owner.hoverTime) |
| + } |
| + #else |
| + content |
| + .contentShape(.contextMenuPreview, |
| + RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + .contextMenu { owner.menuItems(actions) } |
| + #endif |
| + } else { |
| + content |
| } |
| } |
| - #else |
| - // The preview shape hugs the bubble — without it UIKit snapshots the |
| - // whole row stack (bubble + reactions + preview card) and flashes a |
| - // rectangular background around them while the menu opens. |
| - .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| - .contextMenu { if let actions, !message.action { menuItems(actions) } } |
| - #endif |
| } |
| // The Messages-inspired context menu: the reaction row up top (a palette |
| @@ -474,10 +520,15 @@ public struct MessageBubble: View { |
| MDBlockListView( |
| blocks: Self.cachedTree(message.text), |
| ctx: MDContext(fontSize: fontSize, ink: ink, mine: message.mine)) |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| // Selectable AND right-clickable: the MessageRightClick catcher |
| // above only claims right/ctrl-clicks, so left-drag selection |
| // reaches the text system while our menu owns the context click. |
| + // Mac-only: on touch the long-press menu owns Copy (native |
| + // selection/callout is suppressed on bubbles, like the web), and |
| + // the selection machinery cost real time on every cell recycle. |
| .textSelection(.enabled) |
| + #endif |
| .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13)) |
| .foregroundStyle(ink) |
| .background(shape.fill(message.mine |
| @@ -517,14 +568,23 @@ public struct MessageBubble: View { |
| var body: some View { |
| switch block { |
| case .p(let children): |
| - Text(MessageBubble.inlineText(children, ctx: ctx)) |
| - // The reading size comes from \.chatFontSize (web parity: |
| - // style.css --msg-font-size) — the Mac's pref or iOS's |
| - // Dynamic Type body size; derived sizes keep the fixed |
| - // ratios they've always had against it. |
| - .font(.system(size: ctx.fontSize)) |
| - .lineSpacing(2) |
| - .tint(ctx.ink) // links inherit the bubble's ink, underlined |
| + // A single unstyled run — most messages — skips the |
| + // AttributedString build/replay entirely: plain Text lays |
| + // out measurably cheaper, and this runs per cell recycle. |
| + if children.count == 1, case .text(let s) = children[0] { |
| + Text(s) |
| + .font(.system(size: ctx.fontSize)) |
| + .lineSpacing(2) |
| + } else { |
| + Text(MessageBubble.inlineText(children, ctx: ctx)) |
| + // The reading size comes from \.chatFontSize (web |
| + // parity: style.css --msg-font-size) — the Mac's pref |
| + // or iOS's Dynamic Type body size; derived sizes keep |
| + // the fixed ratios they've always had against it. |
| + .font(.system(size: ctx.fontSize)) |
| + .lineSpacing(2) |
| + .tint(ctx.ink) // links inherit the bubble's ink, underlined |
| + } |
| case .codeBlock(let text, _): |
| // ``` fence — verbatim monospace on the chip background, long |
| // lines scrolling rather than wrapping (.msg .bubble pre). |
| @@ -714,6 +774,10 @@ public struct MessageBubble: View { |
| Color.clear.frame(width: 1, height: 1) |
| } |
| } |
| + // Identity-reused on recycle (see AttachmentImage): another |
| + // link's resolved card must not render under this URL while the |
| + // fresh unfurl runs. |
| + .onChange(of: link) { _, _ in preview = nil } |
| .task(id: link) { preview = await unfurl(link) } |
| } |
| @@ -728,7 +792,15 @@ public struct MessageBubble: View { |
| } placeholder: { |
| Color.clear |
| } |
| - .frame(maxWidth: 280, maxHeight: 150) |
| + // A FIXED slot, not max-caps: the card's |
| + // height must be identical before and after |
| + // the image loads — the measure pass never |
| + // loads images, and a load-dependent height |
| + // is a cell whose card paints over the rows |
| + // below it (the 2026-07-21 App-test-group |
| + // separator overlap: cell 227 vs ideal 366, |
| + // the delta exactly this slot). |
| + .frame(width: 280, height: 150) |
| .clipped() |
| } |
| VStack(alignment: .leading, spacing: 2) { |
| @@ -884,6 +956,9 @@ public struct MessageBubble: View { |
| .trackingFrame(into: liveFrame) |
| .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| .onTapGesture { onTap?(attachment, liveFrame.rect) } |
| + // Identity-reused on recycle (see AttachmentImage): the previous |
| + // clip's poster must not survive into another message's card. |
| + .onChange(of: attachment.id) { _, _ in poster = nil } |
| .task(id: attachment.id) { |
| guard poster == nil, let posterSource else { return } |
| poster = await BlobImageLoader.shared.image(posterSource, |
| @@ -958,6 +1033,16 @@ public struct MessageBubble: View { |
| if isAnimatedGIF(image) { playing.toggle() } |
| else { onTap?(attachment, image, liveFrame.rect) } |
| } |
| + // Feed cells recycle by REUSING this view's identity for a |
| + // different message (no per-message .id — the remount was the |
| + // scroll cost), so loaded state must reset when the attachment |
| + // changes or the previous photo would pass the task's |
| + // image == nil guard and stick, wrongly, forever. |
| + .onChange(of: attachment.id) { _, _ in |
| + image = nil |
| + blur = nil |
| + playing = !gifsStartPaused() |
| + } |
| .task(id: attachment.id) { |
| guard image == nil, let source else { return } |
| // The feed decodes downsampled to its box class; a Mac |
diff --git a/ios/LChatFeedTests/RowLadderTests.swift b/ios/LChatFeedTests/RowLadderTests.swift| new file mode 100644 |
| index 0000000..beef59a |
| --- /dev/null |
| +++ b/ios/LChatFeedTests/RowLadderTests.swift |
| @@ -0,0 +1,435 @@ |
| +import Combine |
| +import SwiftUI |
| +import XCTest |
| + |
| +@testable import LChat |
| +@testable import LChatCore |
| + |
| +// The row-content ladder (2026-07-21): the container is settled — the scroll |
| +// cost lives in rendering real MessageBubble rows. These tests bisect WHICH |
| +// ingredient of a row is expensive by rebuilding the row rung by rung, from a |
| +// plain-text row (the diagnostic renderer's shape) up to the production |
| +// FeedMessageRow + .id, and timing the exact recycle path a scroll drives: |
| +// rootView swap + layout on one reused hosting controller. |
| +// |
| +// Not assertion-bound benchmarks — the printed LADDER/SWEEP numbers are the |
| +// instrument. Grind an optimization, re-run, compare. |
| +@MainActor |
| +final class RowLadderTests: XCTestCase { |
| + |
| + private var window: UIWindow! |
| + |
| + override func tearDown() { |
| + window?.isHidden = true |
| + window = nil |
| + super.tearDown() |
| + } |
| + |
| + // ---- content mix ---------------------------------------------------- |
| + |
| + static let longReply = |
| + "I can help with a few quick commands — try /help. " + |
| + "(Conversational AI answers aren't enabled on this server.)" |
| + |
| + private func mixedMessages(_ n: Int, reactions: Bool = true, |
| + senders: [String] = ["Eleven"]) -> [ChatFeedModel.Message] { |
| + (0..<n).map { i in |
| + let text: String |
| + switch i % 10 { |
| + case 0: text = Self.longReply |
| + case 1: text = "**Bold plans** for _tonight_ — bring `snacks` and ~maybe~ drinks" |
| + case 2: text = "😂🎉" |
| + case 3: text = "On my way 👍" |
| + case 4: text = "Did you see the thing about the thing? Unbelievable scenes." |
| + default: text = "Message number \(i) with a bit of ordinary text in it" |
| + } |
| + var m = ChatFeedModel.Message( |
| + id: 1000 + Int64(i), name: senders[i % senders.count], text: text, |
| + mine: i % 3 == 0, at: 1_700_000_000 + Int64(i) * 60, edited: false) |
| + if reactions, i % 7 == 0 { |
| + m.reactions = [Reaction(emoji: "👍", count: 2, mine: true), |
| + Reaction(emoji: "❤️", count: 1, mine: false)] |
| + } |
| + return m |
| + } |
| + } |
| + |
| + // Image rows: w/h + BlurHash present (the send path always stamps them), |
| + // blobSource nil in the harness — this times the LAYOUT cost of media |
| + // rows; the decrypt/decode cost is device-side (BlobImageLoader) and |
| + // measured by the hitch recorder, not here. |
| + private func mediaMessages(_ n: Int) -> [ChatFeedModel.Message] { |
| + (0..<n).map { i in |
| + var m = ChatFeedModel.Message( |
| + id: 3000 + Int64(i), name: "Eleven", |
| + text: i % 4 == 0 ? "Look at this one" : "", |
| + mine: i % 3 == 0, at: 1_700_000_000 + Int64(i) * 60, edited: false) |
| + m.attachments = [Attachment( |
| + id: "blob\(i)", name: "photo.jpg", type: "image/jpeg", |
| + w: 1200 + (i % 3) * 200, h: 900 + (i % 5) * 100, |
| + size: 250_000, bh: "LEHV6nWB2yk8pyo0adR*.7kCMdnj")] |
| + return m |
| + } |
| + } |
| + |
| + private func stubHooks(isGroup: Bool = false) -> FeedHooks { |
| + FeedHooks(isGroup: isGroup, themeName: "classic", chatFontSize: 15, |
| + senderName: { $0.name }, |
| + foldsAsMuted: { _ in false }, |
| + revealMuted: { _ in }, |
| + actions: { _ in MessageActions(canEdit: false, |
| + onReply: {}, onCopy: {}, |
| + onReact: { _ in }) }, |
| + onJump: { _ in }, onOpenThread: { _ in }, |
| + blobSource: { _ in nil }, |
| + onImageTap: { _, _, _ in }, onFileTap: { _ in }, |
| + onVideoTap: { _, _ in }, |
| + unfurl: { _ in nil }, |
| + startReply: { _ in }) |
| + } |
| + |
| + // ---- the rungs ------------------------------------------------------ |
| + |
| + // Each rung builds ONE row view for a message — from the diagnostic |
| + // renderer's shape up to the production tree. The ladder's whole point: |
| + // the first rung that gets expensive names the ingredient. |
| + private enum Rung: String, CaseIterable { |
| + case plain // bare Text — the lab row's shape |
| + case chrome // + bubble shape/fill/alignment (still plain Text) |
| + case bubble // + real MessageBubble (markdown, links, reactions) |
| + case row // + full FeedMessageRow (swipe/times overlays, hooks) |
| + case rowID // + .id(m.id) — the production tree, identity per recycle |
| + // Sub-bisect of the bubble delta — chrome plus ONE suspect each: |
| + case chromeSel // + .textSelection(.enabled) |
| + case chromeMenu // + .contextMenu (attached even with nil actions today) |
| + case chromeMD // + markdown AttributedString Text (MDBlockListView) |
| + } |
| + |
| + private func build(_ rung: Rung, _ m: ChatFeedModel.Message, |
| + prev: ChatFeedModel.Message?, |
| + hooks: FeedHooks, interaction: FeedInteraction) -> AnyView { |
| + switch rung { |
| + case .plain: |
| + return AnyView( |
| + Text(m.text) |
| + .font(.system(size: 15)) |
| + .frame(maxWidth: .infinity, alignment: m.mine ? .trailing : .leading) |
| + .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) |
| + case .chrome, .chromeSel, .chromeMenu, .chromeMD: |
| + let shape = UnevenRoundedRectangle( |
| + cornerRadii: .init(topLeading: 18, |
| + bottomLeading: m.mine ? 18 : 6, |
| + bottomTrailing: m.mine ? 6 : 18, |
| + topTrailing: 18), |
| + style: .continuous) |
| + let inner: AnyView |
| + switch rung { |
| + case .chromeSel: |
| + inner = AnyView(Text(m.text).font(.system(size: 15)) |
| + .textSelection(.enabled)) |
| + case .chromeMD: |
| + inner = AnyView(MessageBubble.MDBlockListView( |
| + blocks: MessageBubble.cachedTree(m.text), |
| + ctx: .init(fontSize: 15, |
| + ink: m.mine ? .white : .primary, |
| + mine: m.mine))) |
| + default: |
| + inner = AnyView(Text(m.text).font(.system(size: 15))) |
| + } |
| + let bubble = inner |
| + .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13)) |
| + .foregroundStyle(m.mine ? Color.white : Color.primary) |
| + .background(shape.fill(m.mine ? Color.blue : Color(.systemGray5))) |
| + let decorated: AnyView |
| + if case .chromeMenu = rung { |
| + // What production pays today with actions == nil: the shape + |
| + // the interaction machinery, an empty menu. |
| + decorated = AnyView(bubble |
| + .contentShape(.contextMenuPreview, |
| + RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + .contextMenu { EmptyView() }) |
| + } else { |
| + decorated = AnyView(bubble) |
| + } |
| + return AnyView( |
| + HStack(spacing: 0) { |
| + if m.mine { Spacer(minLength: 40) } |
| + decorated |
| + if !m.mine { Spacer(minLength: 40) } |
| + } |
| + .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) |
| + case .bubble: |
| + return AnyView( |
| + HStack(spacing: 0) { |
| + if m.mine { Spacer(minLength: 40) } |
| + MessageBubble(message: m, showSender: false, theme: .classic) |
| + .environment(\.chatFontSize, 15) |
| + if !m.mine { Spacer(minLength: 40) } |
| + } |
| + .padding(EdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12))) |
| + case .row: |
| + return AnyView( |
| + FeedMessageRow(message: m, previous: prev, |
| + hooks: hooks, interaction: interaction)) |
| + case .rowID: |
| + return AnyView( |
| + FeedMessageRow(message: m, previous: prev, |
| + hooks: hooks, interaction: interaction) |
| + .id(m.id)) |
| + } |
| + } |
| + |
| + // ---- the recycle-path meter ---------------------------------------- |
| + |
| + // One reused hosting controller, rootView swapped per message + forced |
| + // layout + CA commit — the exact work a cell recycle does mid-scroll |
| + // (FeedHostedCell.set(root:parent:) then the cell's layout pass). |
| + private func sweep(_ rung: Rung, over messages: [ChatFeedModel.Message], |
| + hooks: FeedHooks, interaction: FeedInteraction, |
| + hc: UIHostingController<AnyView>) -> (totalMs: Double, maxMs: Double) { |
| + var total = 0.0, maxStep = 0.0 |
| + for (i, m) in messages.enumerated() { |
| + let prev = i > 0 ? messages[i - 1] : nil |
| + let root = build(rung, m, prev: prev, hooks: hooks, interaction: interaction) |
| + let t = CACurrentMediaTime() |
| + hc.rootView = root |
| + hc.view.setNeedsLayout() |
| + hc.view.layoutIfNeeded() |
| + CATransaction.flush() |
| + let ms = (CACurrentMediaTime() - t) * 1000 |
| + total += ms |
| + maxStep = max(maxStep, ms) |
| + } |
| + return (total, maxStep) |
| + } |
| + |
| + private func makeHost() -> UIHostingController<AnyView> { |
| + let scene = UIApplication.shared.connectedScenes |
| + .compactMap { $0 as? UIWindowScene }.first! |
| + window = UIWindow(windowScene: scene) |
| + let rootVC = UIViewController() |
| + window.rootViewController = rootVC |
| + window.makeKeyAndVisible() |
| + let hc = UIHostingController(rootView: AnyView(EmptyView())) |
| + hc.safeAreaRegions = [] |
| + hc.sizingOptions = [] |
| + rootVC.addChild(hc) |
| + hc.view.frame = CGRect(x: 0, y: 0, width: 402, height: 300) |
| + hc.view.autoresizingMask = [] |
| + rootVC.view.addSubview(hc.view) |
| + hc.didMove(toParent: rootVC) |
| + pump(0.2) |
| + return hc |
| + } |
| + |
| + private func pump(_ seconds: TimeInterval = 0.3) { |
| + let until = Date().addingTimeInterval(seconds) |
| + while Date() < until { |
| + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02)) |
| + } |
| + } |
| + |
| + // ---- the ladder ----------------------------------------------------- |
| + |
| + // Per rung: a COLD pass (200 distinct messages — fresh mid-scroll rows, |
| + // markdown memos empty) and a WARM pass (same 200 again — memo hits, the |
| + // steady-state scroll-back cost). The rung whose numbers step up names |
| + // the expensive ingredient; cold-vs-warm splits parse cost from view |
| + // build/layout cost. |
| + func testRowLadderRecycleCost() { |
| + let interaction = FeedInteraction() |
| + let hooks = stubHooks() |
| + let messages = mixedMessages(200) |
| + let hc = makeHost() |
| + |
| + print("LADDER rung cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)") |
| + for rung in Rung.allCases { |
| + // Settle + isolate: each rung starts from an empty tree. |
| + hc.rootView = AnyView(EmptyView()) |
| + hc.view.layoutIfNeeded() |
| + pump(0.1) |
| + let cold = sweep(rung, over: messages, hooks: hooks, |
| + interaction: interaction, hc: hc) |
| + let warm = sweep(rung, over: messages, hooks: hooks, |
| + interaction: interaction, hc: hc) |
| + print(String(format: "LADDER %-10s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f", |
| + (rung.rawValue as NSString).utf8String!, |
| + cold.totalMs, cold.totalMs / 200, cold.maxMs, |
| + warm.totalMs, warm.totalMs / 200, warm.maxMs)) |
| + } |
| + } |
| + |
| + // The coverage Paul flagged missing: group rows (sender names, run |
| + // starts) and media rows (image attachments) — both at the production |
| + // rung, against the DM baseline from the ladder above. |
| + func testGroupAndMediaVariants() { |
| + let interaction = FeedInteraction() |
| + let hc = makeHost() |
| + |
| + let dm = mixedMessages(200) |
| + let dmHooks = stubHooks() |
| + let group = mixedMessages(200, senders: ["Alice", "Bob", "Bill", "Gregorious"]) |
| + let groupHooks = stubHooks(isGroup: true) |
| + let media = mediaMessages(200) |
| + |
| + print("VARIANT kind cold-total cold-avg cold-max warm-total warm-avg warm-max (ms)") |
| + for (label, msgs, hooks) in [("dm", dm, dmHooks), |
| + ("group", group, groupHooks), |
| + ("media", media, dmHooks)] { |
| + hc.rootView = AnyView(EmptyView()) |
| + hc.view.layoutIfNeeded() |
| + pump(0.1) |
| + let cold = sweep(.rowID, over: msgs, hooks: hooks, |
| + interaction: interaction, hc: hc) |
| + let warm = sweep(.rowID, over: msgs, hooks: hooks, |
| + interaction: interaction, hc: hc) |
| + print(String(format: "VARIANT %-7s %8.1f %8.2f %8.2f %8.1f %8.2f %8.2f", |
| + (label as NSString).utf8String!, |
| + cold.totalMs, cold.totalMs / 200, cold.maxMs, |
| + warm.totalMs, warm.totalMs / 200, warm.maxMs)) |
| + } |
| + } |
| + |
| + // Ground truth: the REAL container (StockChatFeed mounted in a window) |
| + // driven through a full top-to-bottom scroll in ~100pt steps, timing each |
| + // synchronous materialisation batch — split into the UIKit layout half |
| + // (setContentOffset + layoutIfNeeded: cell recycle + rootView swap) and |
| + // the CA commit half (CATransaction.flush: layer layout + text raster — |
| + // the per-frame work the render loop pays). Steps over the 120Hz (8.3ms) |
| + // and 60Hz (16.7ms) budgets are the felt hitches. |
| + // |
| + // THE container ladder: identical machinery, row content swapped rung by |
| + // rung via the test-only override — the first rung whose numbers step up |
| + // names the jank ingredient. |
| + func testScrollSweepCost() { |
| + let interaction = FeedInteraction() |
| + let hooks = stubHooks() |
| + let rungs: [(String, Rung?)] = [("plain", .plain), ("chrome", .chrome), |
| + ("chrome+sel", .chromeSel), |
| + ("chrome+menu", .chromeMenu), |
| + ("chrome+md", .chromeMD), |
| + ("bubble", .bubble), ("row", .row), |
| + ("prod", nil)] |
| + for (label, rung) in rungs { |
| + if let rung { |
| + StockChatFeed.testRowOverride = { [self] m, prev in |
| + build(rung, m, prev: prev, hooks: hooks, interaction: interaction) |
| + } |
| + } else { |
| + StockChatFeed.testRowOverride = nil |
| + } |
| + sweepContainer(label: label, messages: mixedMessages(200), isGroup: false) |
| + window.isHidden = true |
| + } |
| + StockChatFeed.testRowOverride = nil |
| + sweepContainer(label: "group", |
| + messages: mixedMessages(200, |
| + senders: ["Alice", "Bob", "Bill", "Gregorious"]), |
| + isGroup: true) |
| + window.isHidden = true |
| + sweepContainer(label: "media", messages: mediaMessages(200), isGroup: false) |
| + } |
| + |
| + // A ~25s continuous production-rung sweep — not an assertion, a TARGET: |
| + // run this test, `sample` the sim app process from the Mac while it |
| + // loops, and the hot stacks name the real per-row cost with symbols |
| + // (the ladder attributes WHICH ingredient; this attributes WHY). |
| + func testProfilePadProdSweep() { |
| + StockChatFeed.testRowOverride = nil |
| + let msgs = mixedMessages(200) |
| + sweepContainer(label: "pad-warm", messages: msgs, isGroup: false) |
| + guard let cv = (window.rootViewController?.view).flatMap(findCV) else { return } |
| + let deadline = Date().addingTimeInterval(40) |
| + print("PROFILEPAD sweeping until \(deadline)") |
| + // Announce the live sweep to the host Mac (sim processes share the |
| + // host filesystem): the sampler triggers on this marker instead of |
| + // guessing the window — the first attempt sampled test teardown. |
| + let marker = "/tmp/lchat-profilepad-live" |
| + FileManager.default.createFile(atPath: marker, contents: Data("\(getpid())".utf8)) |
| + defer { try? FileManager.default.removeItem(atPath: marker) } |
| + while Date() < deadline { |
| + var y = cv.contentSize.height // jump to bottom, sweep to top |
| + let top = -cv.adjustedContentInset.top |
| + cv.setContentOffset(CGPoint(x: 0, y: max(top, y - cv.bounds.height)), |
| + animated: false) |
| + cv.layoutIfNeeded() |
| + while y > top, Date() < deadline { |
| + y = max(top, y - 100) |
| + cv.setContentOffset(CGPoint(x: 0, y: y), animated: false) |
| + cv.layoutIfNeeded() |
| + CATransaction.flush() |
| + } |
| + } |
| + print("PROFILEPAD done") |
| + } |
| + |
| + private func findCV(_ v: UIView) -> UICollectionView? { |
| + if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv } |
| + for sub in v.subviews { if let hit = findCV(sub) { return hit } } |
| + return nil |
| + } |
| + |
| + private func sweepContainer(label: String, |
| + messages: [ChatFeedModel.Message], isGroup: Bool) { |
| + let inst = SavedInstance(origin: "https://example.invalid", |
| + deviceToken: "tok", screenName: "Paul", |
| + userPub: "", userPriv: "") |
| + let thread = ChatThread(id: "t1", kind: isGroup ? "group" : "dm", |
| + title: "Sweep", preview: "", lastAt: 0, |
| + unread: false, pinned: false, hidden: false, |
| + members: [], threadKeyB64: "") |
| + let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") |
| + let controller = FeedScrollController() |
| + let feed = StockChatFeed(model: model, |
| + interaction: FeedInteraction(), |
| + controller: controller, |
| + hooks: stubHooks(isGroup: isGroup), |
| + topOverlay: 103, bottomOverlay: 90, |
| + composerFocused: false, unreadCutoff: nil, |
| + onFirstLayout: { _ in }) |
| + let vc = UIHostingController(rootView: |
| + AnyView(feed.ignoresSafeArea(.container, edges: .vertical))) |
| + let scene = UIApplication.shared.connectedScenes |
| + .compactMap { $0 as? UIWindowScene }.first! |
| + window = UIWindow(windowScene: scene) |
| + window.rootViewController = vc |
| + window.makeKeyAndVisible() |
| + pump(0.4) |
| + model._testInject(messages: messages) |
| + pump(0.5) |
| + |
| + func find(_ v: UIView) -> UICollectionView? { |
| + if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv } |
| + for sub in v.subviews { if let hit = find(sub) { return hit } } |
| + return nil |
| + } |
| + guard let cv = find(window) else { XCTFail("no collection view"); return } |
| + cv.layoutIfNeeded() |
| + |
| + var layoutSteps: [Double] = [], flushSteps: [Double] = [] |
| + var y = cv.contentOffset.y |
| + let top = -cv.adjustedContentInset.top |
| + while y > top { |
| + y = max(top, y - 100) |
| + let t0 = CACurrentMediaTime() |
| + cv.setContentOffset(CGPoint(x: 0, y: y), animated: false) |
| + cv.layoutIfNeeded() |
| + let t1 = CACurrentMediaTime() |
| + CATransaction.flush() |
| + let t2 = CACurrentMediaTime() |
| + layoutSteps.append((t1 - t0) * 1000) |
| + flushSteps.append((t2 - t1) * 1000) |
| + } |
| + let combined = zip(layoutSteps, flushSteps).map(+) |
| + let over8 = combined.filter { $0 > 8.3 }.count |
| + let over16 = combined.filter { $0 > 16.7 }.count |
| + func stats(_ a: [Double]) -> String { |
| + String(format: "total=%.0f avg=%.2f max=%.2f", |
| + a.reduce(0, +), a.reduce(0, +) / Double(max(a.count, 1)), |
| + a.max() ?? 0) |
| + } |
| + print("SWEEP \(label) steps=\(combined.count) " + |
| + "layout[\(stats(layoutSteps))] flush[\(stats(flushSteps))] " + |
| + ">8.3ms=\(over8) >16.7ms=\(over16)") |
| + } |
| +} |
diff --git a/ios/LChatFeedTests/ShortChatFeedTests.swift b/ios/LChatFeedTests/ShortChatFeedTests.swift| new file mode 100644 |
| index 0000000..094bd38 |
| --- /dev/null |
| +++ b/ios/LChatFeedTests/ShortChatFeedTests.swift |
| @@ -0,0 +1,538 @@ |
| +import Combine |
| +import SwiftUI |
| +import XCTest |
| + |
| +@testable import LChat |
| +@testable import LChatCore |
| + |
| +// The short-chat bunching repro (2026-07-21): chats with a handful of |
| +// messages measured every row at one-line height, rendered truncated |
| +// ("try…"), overlapped cells, and floated mid-screen. These tests mount the |
| +// REAL container — StockChatFeed via UIHostingController in a window — over |
| +// a fake model, so the geometry is checkable in-process with no server and |
| +// no device. |
| +@MainActor |
| +final class ShortChatFeedTests: XCTestCase { |
| + |
| + // The exact text from the field report — wraps to 3 lines at 402pt. |
| + static let elevenReply = |
| + "I can help with a few quick commands — try /help. " + |
| + "(Conversational AI answers aren't enabled on this server.)" |
| + |
| + private var window: UIWindow! |
| + private var cancellables = Set<AnyCancellable>() |
| + |
| + override func tearDown() { |
| + window?.isHidden = true |
| + window = nil |
| + cancellables.removeAll() |
| + super.tearDown() |
| + } |
| + |
| + // ---- harness -------------------------------------------------------- |
| + |
| + private func makeModel(texts: [String]) -> ChatFeedModel { |
| + let inst = SavedInstance(origin: "https://example.invalid", |
| + deviceToken: "tok", screenName: "Paul", |
| + userPub: "", userPriv: "") |
| + let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", |
| + preview: "", lastAt: 0, unread: false, |
| + pinned: false, hidden: false, |
| + members: [], threadKeyB64: "") |
| + let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") |
| + model._testInject(messages: messages(texts)) |
| + return model |
| + } |
| + |
| + private func messages(_ texts: [String], startID: Int64 = 420) |
| + -> [ChatFeedModel.Message] { |
| + texts.enumerated().map { i, t in |
| + ChatFeedModel.Message(id: startID + Int64(i), name: "Eleven", text: t, |
| + mine: false, at: 1_700_000_000 + Int64(i) * 60, |
| + edited: false) |
| + } |
| + } |
| + |
| + private func stubHooks(isGroup: Bool = false) -> FeedHooks { |
| + FeedHooks(isGroup: isGroup, themeName: "classic", chatFontSize: 15, |
| + senderName: { $0.name }, |
| + foldsAsMuted: { _ in false }, |
| + revealMuted: { _ in }, |
| + actions: { _ in MessageActions(canEdit: false, |
| + onReply: {}, onCopy: {}, |
| + onReact: { _ in }) }, |
| + onJump: { _ in }, onOpenThread: { _ in }, |
| + blobSource: { _ in nil }, |
| + onImageTap: { _, _, _ in }, onFileTap: { _ in }, |
| + onVideoTap: { _, _ in }, |
| + unfurl: { _ in nil }, |
| + startReply: { _ in }) |
| + } |
| + |
| + @discardableResult |
| + private func mount(model: ChatFeedModel, isGroup: Bool = false) |
| + -> (UIHostingController<AnyView>, FeedScrollController) { |
| + let controller = FeedScrollController() |
| + let feed = StockChatFeed(model: model, |
| + interaction: FeedInteraction(), |
| + controller: controller, |
| + hooks: stubHooks(isGroup: isGroup), |
| + topOverlay: 103, bottomOverlay: 90, |
| + composerFocused: false, unreadCutoff: nil, |
| + onFirstLayout: { _ in }) |
| + let vc = UIHostingController(rootView: |
| + AnyView(feed.ignoresSafeArea(.container, edges: .vertical))) |
| + // Scene-based host app: a bare UIWindow(frame:) never attaches — it |
| + // must join the test host's window scene to lay out at all. |
| + let scene = UIApplication.shared.connectedScenes |
| + .compactMap { $0 as? UIWindowScene }.first! |
| + window = UIWindow(windowScene: scene) |
| + window.rootViewController = vc |
| + window.makeKeyAndVisible() |
| + pump(0.8) |
| + return (vc, controller) |
| + } |
| + |
| + private func pump(_ seconds: TimeInterval = 0.3) { |
| + let until = Date().addingTimeInterval(seconds) |
| + while Date() < until { |
| + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02)) |
| + } |
| + } |
| + |
| + private func feedCV() -> UICollectionView { |
| + func find(_ v: UIView) -> UICollectionView? { |
| + if let cv = v as? StockChatFeed.StockFeedCollectionView { return cv } |
| + for sub in v.subviews { if let hit = find(sub) { return hit } } |
| + return nil |
| + } |
| + guard let cv = find(window) else { |
| + XCTFail("no StockFeedCollectionView in the window") |
| + fatalError() |
| + } |
| + return cv |
| + } |
| + |
| + // ---- diagnostics ---------------------------------------------------- |
| + |
| + private func dumpState(_ cv: UICollectionView, label: String) { |
| + print("FEEDTEST[\(label)] offset=\(cv.contentOffset.y.rounded()) " + |
| + "contentH=\(cv.contentSize.height.rounded()) boundsH=\(cv.bounds.height) " + |
| + "adjTop=\(cv.adjustedContentInset.top) adjBottom=\(cv.adjustedContentInset.bottom)") |
| + let cells = cv.visibleCells |
| + .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } } |
| + .sorted { $0.0 < $1.0 } |
| + for (item, cell) in cells { |
| + let hosted = cell.contentView.subviews.first |
| + let ideal = hosted?.sizeThatFits( |
| + CGSize(width: cv.bounds.width, |
| + height: .greatestFiniteMagnitude)).height ?? -1 |
| + print(" #\(item) y=\(cell.frame.minY.rounded()) h=\(cell.frame.height.rounded()) " + |
| + "hostedH=\((hosted?.frame.height ?? -1).rounded()) idealH=\(ideal.rounded())") |
| + } |
| + } |
| + |
| + private func assertNoOverlaps(_ cv: UICollectionView, |
| + file: StaticString = #filePath, line: UInt = #line) { |
| + let cells = cv.visibleCells |
| + .compactMap { c in cv.indexPath(for: c).map { ($0.item, c.frame) } } |
| + .sorted { $0.0 < $1.0 } |
| + for (a, b) in zip(cells, cells.dropFirst()) { |
| + XCTAssertLessThanOrEqual(a.1.maxY, b.1.minY + 1.0, |
| + "cells #\(a.0)/#\(b.0) overlap: \(a.1) vs \(b.1)", |
| + file: file, line: line) |
| + } |
| + } |
| + |
| + // ---- the repro ------------------------------------------------------ |
| + |
| + // Five copies of the 3-line Eleven reply — the exact chat from the field |
| + // report. Every row must measure its real (multi-line) height, nothing |
| + // may overlap, and the rendered hosted view must FIT its cell. |
| + func testShortChatRowsMeasureTheirRealHeight() { |
| + let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 5)) |
| + mount(model: model) |
| + let cv = feedCV() |
| + dumpState(cv, label: "open") |
| + |
| + let cells = cv.visibleCells |
| + .compactMap { c in cv.indexPath(for: c).map { ($0.item, c) } } |
| + .sorted { $0.0 < $1.0 } |
| + XCTAssertGreaterThanOrEqual(cells.count, 5, "all five rows should be visible") |
| + for (item, cell) in cells where item < 5 { |
| + guard let hosted = cell.contentView.subviews.first else { |
| + XCTFail("cell #\(item) has no hosted content"); continue |
| + } |
| + let ideal = hosted.sizeThatFits( |
| + CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height |
| + // 3 lines of 15pt text + bubble chrome is well past one-line |
| + // height (~72): a one-line answer here is the measurement bug. |
| + XCTAssertGreaterThan(ideal, 80, |
| + "cell #\(item): ideal height \(ideal) looks like a one-line measure") |
| + XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, |
| + "cell #\(item): laid-out height \(cell.frame.height) vs ideal \(ideal)") |
| + } |
| + assertNoOverlaps(cv) |
| + } |
| + |
| + // The Eleven-DM lifecycle: open short chat → typing appears → reply |
| + // arrives → typing clears, several rounds. This is the churn that |
| + // produced overlapped/stale cells and the mid-screen float in the field. |
| + func testTypingChurnKeepsCellsDisjoint() { |
| + let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 3)) |
| + mount(model: model) |
| + let cv = feedCV() |
| + |
| + for round in 0..<3 { |
| + model._testInject(typing: ["Eleven"]) |
| + pump(0.25) |
| + var all = model.messages |
| + all.append(contentsOf: messages([Self.elevenReply], |
| + startID: 500 + Int64(round))) |
| + model._testInject(messages: all, typing: []) |
| + pump(0.25) |
| + dumpState(cv, label: "round\(round)") |
| + assertNoOverlaps(cv) |
| + } |
| + |
| + // Short content must rest against the TOP inset — not float |
| + // mid-screen: offset clamps to -adjustedTop when content fits. |
| + if cv.contentSize.height + cv.adjustedContentInset.bottom |
| + + cv.adjustedContentInset.top <= cv.bounds.height { |
| + XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top, |
| + accuracy: 1.0, "short content should rest at the top") |
| + } |
| + |
| + // The typing row hides by COLLAPSING to zero height — and its hosted |
| + // view must clip, or the dots paint straight through the collapsed |
| + // cell (the 2026-07-21 stuck-dots sighting). |
| + let typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) |
| + let attrs = cv.layoutAttributesForItem(at: typingIP) |
| + XCTAssertEqual(attrs?.frame.height ?? -1, 0, accuracy: 0.5, |
| + "typing row should be zero-height when nobody types") |
| + if let cell = cv.cellForItem(at: typingIP), |
| + let hosted = cell.contentView.subviews.first { |
| + XCTAssertTrue(hosted.clipsToBounds, "typing hosted view must clip") |
| + } |
| + } |
| + |
| + // The discriminator: same content injected AFTER the mount settles — |
| + // cells then configure from a plain run-loop turn instead of inside the |
| + // mount's SwiftUI transaction. If these render full-height, the wedge is |
| + // transaction-timing, not content. |
| + func testLateInjectionRendersFullHeights() { |
| + let model = makeModel(texts: []) |
| + mount(model: model) |
| + model._testInject(messages: messages( |
| + Array(repeating: Self.elevenReply, count: 5))) |
| + pump(0.6) |
| + let cv = feedCV() |
| + dumpState(cv, label: "late-inject") |
| + for cell in cv.visibleCells { |
| + guard let item = cv.indexPath(for: cell)?.item, item < 5, |
| + let hosted = cell.contentView.subviews.first else { continue } |
| + XCTAssertEqual(hosted.frame.height, cell.frame.height, accuracy: 2.0, |
| + "cell #\(item) hosted \(hosted.frame.height) vs cell \(cell.frame.height)") |
| + } |
| + // Tree autopsy: wedged vs healthy internals, for the fix hunt. |
| + for probe in [1, 3] { |
| + if let cell = cv.cellForItem(at: IndexPath(item: probe, section: 0)) { |
| + dumpTree(cell.contentView, indent: " T#\(probe) ") |
| + } |
| + } |
| + } |
| + |
| + private func dumpTree(_ v: UIView, indent: String, depth: Int = 0) { |
| + guard depth < 6 else { return } |
| + let label = (v as? UILabel)?.text ?? "" |
| + print("\(indent)\(String(repeating: "· ", count: depth))\(type(of: v)) " + |
| + "\(v.frame.integral) \(label.prefix(30))") |
| + for sub in v.subviews { dumpTree(sub, indent: indent, depth: depth + 1) } |
| + } |
| + |
| + // An unfurl card with an image must MEASURE with its image slot even |
| + // though the measuring pass never loads images — a load-dependent card |
| + // height painted over the rows below it (App test group, 2026-07-21: |
| + // cell 227 vs ideal 366, the delta exactly the image slot). |
| + func testUnfurlCardMeasuresImageSlotBeforeLoad() { |
| + let msg = ChatFeedModel.Message( |
| + id: 700, name: "James", text: "testing https://link.example/x", |
| + mine: false, at: 1_700_000_000, edited: false) |
| + func rowHeight(_ preview: LinkPreview) -> CGFloat { |
| + var h = stubHooks() |
| + h.peekUnfurl = { _ in .some(preview) } |
| + let row = FeedMessageRow(message: msg, previous: nil, |
| + hooks: h, interaction: FeedInteraction()) |
| + let hc = UIHostingController(rootView: AnyView(row)) |
| + hc.safeAreaRegions = [] |
| + return ceil(hc.sizeThatFits( |
| + in: CGSize(width: 402, height: CGFloat.greatestFiniteMagnitude)).height) |
| + } |
| + let url = URL(string: "https://link.example/x")! |
| + let bare = rowHeight(LinkPreview( |
| + url: url, title: "Title", description: "Desc", imageURL: nil)) |
| + let withImage = rowHeight(LinkPreview( |
| + url: url, title: "Title", description: "Desc", |
| + imageURL: URL(string: "https://unloadable.invalid/i.png"))) |
| + print("UNFURL bare=\(bare) withImage=\(withImage)") |
| + XCTAssertEqual(withImage - bare, 150, accuracy: 4, |
| + "image slot must reserve its space before the image loads") |
| + } |
| + |
| + // Mid-feed dated separators in a GROUP (sender labels + day gaps — the |
| + // App-test-group screenshot, 2026-07-21): every rendered cell must be |
| + // exactly its content's ideal height, or separators paint over the |
| + // neighbouring bubbles. |
| + func testGroupSeparatorRowsMeasureExactly() { |
| + let inst = SavedInstance(origin: "https://example.invalid", |
| + deviceToken: "tok", screenName: "Paul", |
| + userPub: "", userPriv: "") |
| + let thread = ChatThread(id: "t1", kind: "group", title: "Group", |
| + preview: "", lastAt: 0, unread: false, |
| + pinned: false, hidden: false, |
| + members: [], threadKeyB64: "") |
| + let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") |
| + var msgs: [ChatFeedModel.Message] = [] |
| + let names = ["James", "Alice", "James", "James", "Bill"] |
| + for i in 0..<5 { |
| + msgs.append(ChatFeedModel.Message( |
| + id: 100 + Int64(i), name: names[i], |
| + text: "Group message number \(i) with some words in it", |
| + mine: false, |
| + // Multi-day gaps → a DATED separator above every row. |
| + at: 1_700_000_000 + Int64(i) * 200_000, edited: false)) |
| + } |
| + model._testInject(messages: msgs) |
| + mount(model: model, isGroup: true) |
| + let cv = feedCV() |
| + dumpState(cv, label: "group-seps") |
| + for cell in cv.visibleCells { |
| + guard let item = cv.indexPath(for: cell)?.item, item < 5, |
| + let hosted = cell.contentView.subviews.first else { continue } |
| + let ideal = hosted.sizeThatFits( |
| + CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height |
| + XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, |
| + "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)") |
| + } |
| + assertNoOverlaps(cv) |
| + } |
| + |
| + // A prepend that CHANGES the boundary row's separator/run state (older |
| + // page lands above it) must re-measure that row — a kept stale height |
| + // is content taller than its cell, painting over neighbours. |
| + func testPrependReMeasuresBoundaryRow() { |
| + let model = makeModel(texts: []) |
| + mount(model: model) |
| + let cv = feedCV() |
| + // Newest page only: one message, no previous → separator ABOVE it. |
| + let newest = ChatFeedModel.Message( |
| + id: 500, name: "Eleven", text: Self.elevenReply, mine: false, |
| + at: 1_700_400_000, edited: false) |
| + model._testInject(messages: [newest]) |
| + pump(0.4) |
| + // The older page lands: same-day predecessor minutes earlier — the |
| + // boundary row LOSES its separator; an older-day head gains one. |
| + var all: [ChatFeedModel.Message] = [] |
| + for i in 0..<3 { |
| + all.append(ChatFeedModel.Message( |
| + id: 400 + Int64(i), name: "Eleven", |
| + text: "Older message \(i)", mine: false, |
| + at: 1_700_399_700 + Int64(i) * 60, edited: false)) |
| + } |
| + all.append(newest) |
| + model._testInject(messages: all) |
| + pump(0.4) |
| + dumpState(cv, label: "post-prepend") |
| + // The last item is the typing row — zero-height by design (its |
| + // content's ideal is the dots bubble; clipping hides it). |
| + let count = cv.numberOfItems(inSection: 0) |
| + for cell in cv.visibleCells { |
| + guard let hosted = cell.contentView.subviews.first, |
| + let item = cv.indexPath(for: cell)?.item, |
| + item < count - 1 else { continue } |
| + let ideal = hosted.sizeThatFits( |
| + CGSize(width: cv.bounds.width, height: .greatestFiniteMagnitude)).height |
| + XCTAssertEqual(cell.frame.height, ideal, accuracy: 2.0, |
| + "cell #\(item): laid out \(cell.frame.height) vs ideal \(ideal)") |
| + } |
| + assertNoOverlaps(cv) |
| + } |
| + |
| + // A typer's arriving message must retire their dots in the SAME apply — |
| + // the coordinator clears the local typing state itself rather than |
| + // waiting for the typing-off event, so the pane moves once (the |
| + // 2026-07-21 handoff jump). |
| + func testArrivalReplacesTypersDots() { |
| + let model = makeModel(texts: [Self.elevenReply]) |
| + mount(model: model) |
| + let cv = feedCV() |
| + model._testInject(typing: ["Eleven"]) |
| + pump(0.3) |
| + var typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) |
| + XCTAssertGreaterThan( |
| + cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? 0, 10, |
| + "typing row should have height while Eleven types") |
| + |
| + var all = model.messages |
| + all.append(contentsOf: messages(["Here you go"], startID: 900)) |
| + model._testInject(messages: all) // typing deliberately NOT cleared |
| + pump(0.3) |
| + typingIP = IndexPath(item: cv.numberOfItems(inSection: 0) - 1, section: 0) |
| + XCTAssertEqual( |
| + cv.layoutAttributesForItem(at: typingIP)?.frame.height ?? -1, 0, |
| + accuracy: 0.5, |
| + "the arrival should retire the typer's dots in the same apply") |
| + } |
| + |
| + // The owned long-press menu (FeedMenuOverlay): presenting for a wide and |
| + // a narrow bubble must float a bubble-sized snapshot (never row-wide) |
| + // with the palette and the labelled card; dismissing removes it. |
| + func testLongPressMenuPresentsBubbleSized() { |
| + let model = makeModel(texts: [Self.elevenReply, "Yas!", Self.elevenReply]) |
| + let (_, controller) = mount(model: model) |
| + let cv = feedCV() |
| + guard let coord = controller.target as? StockChatFeed.Coordinator else { |
| + XCTFail("no coordinator"); return |
| + } |
| + let container = cv.superview! |
| + for (item, id) in [(0, Int64(420)), (1, 421), (2, 422)] { |
| + let rect = coord.bubbleRect(for: .message(id)) |
| + XCTAssertNotNil(rect, "row #\(item) has no bubble rect") |
| + if let rect { |
| + XCTAssertGreaterThan(rect.width, 10) |
| + XCTAssertLessThan(rect.width, cv.bounds.width - 50, |
| + "row #\(item) bubble rect is row-wide") |
| + } |
| + coord.presentMenu(for: id) |
| + pump(0.3) |
| + let overlay = container.subviews |
| + .compactMap { $0 as? FeedMenuOverlay }.first |
| + XCTAssertNotNil(overlay, "row #\(item): no menu overlay presented") |
| + if let overlay { |
| + let buttons = allButtons(in: overlay) |
| + // 6 reactions + Reply + Copy from the stub actions. |
| + print("MENU #\(item) buttons=\(buttons.count)") |
| + XCTAssertGreaterThanOrEqual(buttons.count, 8) |
| + let bubbles = overlay.subviews.compactMap { $0 as? UIImageView } |
| + XCTAssertEqual(bubbles.count, 1) |
| + if let b = bubbles.first, let rect { |
| + XCTAssertEqual(b.frame.width, rect.width, accuracy: 1.0) |
| + } |
| + overlay.dismiss() |
| + pump(0.3) |
| + let remaining = container.subviews |
| + .compactMap { $0 as? FeedMenuOverlay } |
| + XCTAssertTrue(remaining.isEmpty, "overlay did not dismiss") |
| + } |
| + } |
| + // The typing row and lab rows carry no bubble. |
| + XCTAssertNil(coord.bubbleRect(for: .typing)) |
| + } |
| + |
| + // The open-cost number (the field's 3.1s feed.mount for 201 messages, |
| + // 2026-07-21): mount a 200-message chat and time to first settled |
| + // layout. Not an assertion-bound benchmark — the printed number is the |
| + // regression instrument. |
| + func testTwoHundredMessageMountCost() { |
| + // The test host boots LChatApp, which warms the persisted cache — |
| + // start empty so the cold number stays a cold number. |
| + FeedHeightCache.store.removeAll() |
| + var texts: [String] = [] |
| + for i in 0..<200 { |
| + texts.append(i % 3 == 0 ? Self.elevenReply : "Message number \(i) here") |
| + } |
| + // Empty mount first; then time the SYNCHRONOUS main-thread cost of |
| + // the 200-message publish — the apply + full measure + layout runs |
| + // inside the sink, and that block IS the felt freeze. |
| + let model = makeModel(texts: []) |
| + mount(model: model) |
| + let cv = feedCV() |
| + let t0 = Date() |
| + model._testInject(messages: messages(texts)) |
| + let syncMs = Int(Date().timeIntervalSince(t0) * 1000) |
| + pump(0.3) |
| + let firstContentH = cv.contentSize.height |
| + print("MOUNTCOST 200 rows: sync=\(syncMs)ms " + |
| + "contentH=\(Int(firstContentH))") |
| + XCTAssertGreaterThan(firstContentH, 5000) |
| + |
| + // RE-open (the field's 3.1s mounts were re-opens): a fresh screen |
| + // over the same content must ride the cross-open height cache. |
| + window.isHidden = true |
| + let model2 = makeModel(texts: []) |
| + mount(model: model2) |
| + let cv2 = feedCV() |
| + let t1 = Date() |
| + model2._testInject(messages: messages(texts)) |
| + let reopenMs = Int(Date().timeIntervalSince(t1) * 1000) |
| + pump(0.3) |
| + print("MOUNTCOST reopen: sync=\(reopenMs)ms " + |
| + "contentH=\(Int(cv2.contentSize.height))") |
| + XCTAssertEqual(cv2.contentSize.height, firstContentH, accuracy: 2, |
| + "cached heights diverge from measured ones") |
| + XCTAssertLessThan(reopenMs, syncMs / 2 + 30, |
| + "re-open did not ride the height cache") |
| + } |
| + |
| + // A relaunch must not re-pay the measure pass: the persisted cache |
| + // warms from disk and the first open rides it (the device ledger's |
| + // 3.1s feed.mount for 201 messages, 2026-07-21). |
| + func testHeightCacheSurvivesRelaunch() { |
| + FeedHeightCache.store.removeAll() |
| + var texts: [String] = [] |
| + for i in 0..<200 { |
| + texts.append(i % 3 == 0 ? Self.elevenReply : "Relaunch row \(i) content") |
| + } |
| + let model = makeModel(texts: []) |
| + mount(model: model) |
| + model._testInject(messages: messages(texts)) |
| + pump(0.3) |
| + let contentH = feedCV().contentSize.height |
| + FeedHeightCache.flushNow() |
| + pump(0.5) // the detached write must land before the "relaunch" |
| + |
| + FeedHeightCache.store.removeAll() // simulate a fresh process |
| + FeedHeightCache.warm() |
| + let deadline = Date().addingTimeInterval(3) |
| + while FeedHeightCache.store.isEmpty, Date() < deadline { pump(0.1) } |
| + XCTAssertFalse(FeedHeightCache.store.isEmpty, "warm() merged nothing from disk") |
| + |
| + window.isHidden = true |
| + let model2 = makeModel(texts: []) |
| + mount(model: model2) |
| + let t = Date() |
| + model2._testInject(messages: messages(texts)) |
| + let ms = Int(Date().timeIntervalSince(t) * 1000) |
| + pump(0.3) |
| + print("RELAUNCH open: sync=\(ms)ms contentH=\(Int(feedCV().contentSize.height))") |
| + XCTAssertEqual(feedCV().contentSize.height, contentH, accuracy: 2, |
| + "warmed heights diverge from measured ones") |
| + XCTAssertLessThan(ms, 60, "relaunch first open re-measured (\(ms)ms)") |
| + } |
| + |
| + private func allButtons(in v: UIView) -> [UIButton] { |
| + v.subviews.flatMap { sub -> [UIButton] in |
| + var out = allButtons(in: sub) |
| + if let b = sub as? UIButton { out.append(b) } |
| + return out |
| + } |
| + } |
| + |
| + // Keyboard lift + drop over a short chat — the inset churn path. |
| + func testKeyboardCycleOverShortChat() { |
| + let model = makeModel(texts: Array(repeating: Self.elevenReply, count: 4)) |
| + mount(model: model) |
| + let cv = feedCV() |
| + |
| + NotificationCenter.default.post( |
| + name: UIResponder.keyboardWillShowNotification, object: nil) |
| + pump(0.2) |
| + NotificationCenter.default.post( |
| + name: UIResponder.keyboardWillHideNotification, object: nil) |
| + pump(0.2) |
| + dumpState(cv, label: "after-keyboard") |
| + assertNoOverlaps(cv) |
| + XCTAssertEqual(cv.contentOffset.y, -cv.adjustedContentInset.top, |
| + accuracy: 1.0, "short content should rest at the top") |
| + } |
| +} |
diff --git a/ios/LChatUITests/FeedMenuUITests.swift b/ios/LChatUITests/FeedMenuUITests.swift| new file mode 100644 |
| index 0000000..47a155c |
| --- /dev/null |
| +++ b/ios/LChatUITests/FeedMenuUITests.swift |
| @@ -0,0 +1,73 @@ |
| +import XCTest |
| + |
| +// The long-press message menu, driven with REAL synthesized touches in the |
| +// simulator (2026-07-21): every code-level harness passed while the device |
| +// press stayed dead, so the gesture itself must be exercised. Runs against |
| +// the local dev server — pass TEST_RUNNER_DEV_LINK (the dev sign-in link |
| +// from .lchat-dev.json) on the xcodebuild invocation; without it the test |
| +// skips. |
| +final class FeedMenuUITests: XCTestCase { |
| + |
| + override func setUpWithError() throws { |
| + continueAfterFailure = false |
| + } |
| + |
| + func testLongPressOpensMenuAndReacts() throws { |
| + let env = ProcessInfo.processInfo.environment |
| + guard let link = env["DEV_LINK"], !link.isEmpty else { |
| + throw XCTSkip("Set TEST_RUNNER_DEV_LINK to the dev server sign-in link.") |
| + } |
| + |
| + let app = XCUIApplication() |
| + // The DEBUG dev-join door (AppState.init): an empty install joins |
| + // the dev space natively from this link before first paint. |
| + app.launchEnvironment["LCHAT_DEV_JOIN"] = link |
| + app.launch() |
| + |
| + // Open the dev-seeded group ("Dev chat" — lchat-dev-up's fixture). |
| + let row = app.staticTexts["Dev chat"].firstMatch |
| + XCTAssertTrue(row.waitForExistence(timeout: 25), "Dev chat not in thread list") |
| + shot(app, "menu-01-threads") |
| + row.tap() |
| + |
| + // The dev seed plants a welcome message — press that bubble (no |
| + // composer/keyboard in the loop; sim keyboards are flaky). |
| + let bubble = app.staticTexts |
| + .containing(NSPredicate(format: "label CONTAINS %@", "seeded by lchat-dev-up")) |
| + .firstMatch |
| + XCTAssertTrue(bubble.waitForExistence(timeout: 20), "seeded bubble never rendered") |
| + shot(app, "menu-02-feed") |
| + |
| + // THE press. The owned menu's reaction palette is the verdict. |
| + bubble.press(forDuration: 0.8) |
| + let react = app.buttons["React 👍"].firstMatch |
| + let appeared = react.waitForExistence(timeout: 5) |
| + shot(app, appeared ? "menu-03-open" : "menu-03-DEAD") |
| + XCTAssertTrue(appeared, "long-press did not open the menu") |
| + |
| + // React, and the overlay must dismiss. |
| + react.tap() |
| + Thread.sleep(forTimeInterval: 0.6) |
| + shot(app, "menu-04-after-react") |
| + XCTAssertFalse(app.buttons["React ❤️"].firstMatch.exists, |
| + "menu overlay did not dismiss after reacting") |
| + |
| + // A second press on the same bubble reopens it (repeatability). |
| + bubble.press(forDuration: 0.8) |
| + XCTAssertTrue(app.buttons["React 😂"].firstMatch.waitForExistence(timeout: 5), |
| + "second long-press did not reopen the menu") |
| + // Tap outside (the dimmed area above) dismisses without acting. |
| + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88)).tap() |
| + Thread.sleep(forTimeInterval: 0.6) |
| + XCTAssertFalse(app.buttons["React 😂"].firstMatch.exists, |
| + "outside tap did not dismiss the menu") |
| + shot(app, "menu-05-dismissed") |
| + } |
| + |
| + private func shot(_ app: XCUIApplication, _ name: String) { |
| + let a = XCTAttachment(screenshot: app.screenshot()) |
| + a.name = name |
| + a.lifetime = .keepAlways |
| + add(a) |
| + } |
| +} |
diff --git a/ios/project.yml b/ios/project.yml| index e0b787b..7c7b2d9 100644 |
| --- a/ios/project.yml |
| +++ b/ios/project.yml |
| @@ -289,6 +289,24 @@ targets: |
| - path: LChatUITests |
| dependencies: |
| - target: LChat |
| + settings: |
| + GENERATE_INFOPLIST_FILE: YES |
| + |
| + # In-process feed tests (Simulator): mount the REAL feed container in a |
| + # window against a fake ChatFeedModel — measurement/layout geometry is |
| + # testable without a server or a device (born from the short-chat bunching |
| + # triage, 2026-07-21). |
| + LChatFeedTests: |
| + type: bundle.unit-test |
| + platform: iOS |
| + sources: |
| + - path: LChatFeedTests |
| + dependencies: |
| + - target: LChat |
| + settings: |
| + GENERATE_INFOPLIST_FILE: YES |
| + TEST_HOST: $(BUILT_PRODUCTS_DIR)/LChat.app/LChat |
| + BUNDLE_LOADER: $(TEST_HOST) |
| schemes: |
| LChat: |
| build: |
| @@ -300,6 +318,7 @@ schemes: |
| config: Debug |
| targets: |
| - LChatUITests |
| + - LChatFeedTests |
| # Needed to archive/export the Mac app at all (see ios/release-mac.sh). |
| LChatMac: |
| build: |