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 // @mentions: the index bubbles style "@Name" runs against (resolved at // configure time, like senderName), and the tap → profile-card hook. var mentionIndex: () -> MentionIndex? = { nil } var onMentionTap: (String) -> Void = { _ in } } // 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! // 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() 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 { [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(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. // feedMessages, not messages: the main feed's list leaves out // thread-only replies (they live in the reply-thread view). model.$feedMessages .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 () }) .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) .dropFirst(4) .sink { [weak self] in self?.reconfigureAll() } .store(in: &cancellables) // An empty chat has no first layout to anchor — end the mount // measurement when the (empty) history lands instead. model.$ready .filter { $0 } .prefix(1) .sink { [weak self] _ in guard let self, !self.firstApplyDone, self.current.isEmpty else { return } let cb = self.onFirstLayout DispatchQueue.main.async { cb?(0) } } .store(in: &cancellables) } // ---- data ----------------------------------------------------------- private func index(of id: Int64) -> Int? { // 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() 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) .environment(\.mentionIndex, hooks.mentionIndex()) // A tapped @mention chip carries a MentionLink URL — intercept it // into the profile card; every real link keeps the system route. .environment(\.openURL, OpenURLAction { url in guard let uid = MentionLink.uid(from: url) else { return .systemAction } hooks.onMentionTap(uid) return .handled }) // 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) // Optimistic send (web parity): the copy already shows instantly; once it // has gone slow (5s unconfirmed) the bubble dims, with a non-visual cue // for VoiceOver so the delay isn't conveyed by opacity alone. Outermost + // additive, so it never disturbs the bubble's own layout. .opacity(message.pending && message.slow ? 0.55 : 1) .accessibilityHint(message.pending ? (message.slow ? "Still sending" : "Sending") : "") } 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: 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)) } }