eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import LChatCore
3import SwiftUI
4import UIKit
5
6// The iOS feed container: a FLIPPED UICollectionView (the Telegram approach).
7// The collection view is scaled (1, -1) so its "top" is the visual bottom, and
8// every cell's contentView is scaled back, so rows render upright. The data
9// source runs newest→oldest (index 0 = newest message). What that buys, and
10// why the SwiftUI ScrollView + defaultScrollAnchor container this replaces
11// had to go (#1209/#1210/#1211 — the anchor/settle bug class):
12//
13// - Opening at the bottom is the resting state, not a pin: offset -inset.top
14// IS the newest message, from the first layout pass. No settle phase, no
15// opacity hold, no estimated-height scrollTo that could strand the viewport.
16// - Scroll-back "prepends" are APPENDS at the far end of the data source —
17// they never move existing content, so there is no offset compensation and
18// no stranded-viewport class at all.
19// - Growth at the bottom (a send, a typing bubble, an unfurl card resolving)
20// extends content upward from flipped y=0 while the offset holds — the
21// newest message stays glued to the composer with no re-pin machinery.
22// - The keyboard and composer are ordinary contentInset changes.
23//
24// Flipped-list taxes paid here: VoiceOver order (the collection view override
25// below), the scroll indicator (hidden — it would run backwards), and
26// scrollsToTop (disabled — the status-bar tap would jump to the NEWEST
27// message, not the oldest as everywhere else on iOS).
28//
29// Rows stay SwiftUI via UIHostingConfiguration, so MessageBubble and its whole
30// interaction surface (context menu, swipe-to-reply, Quick Look zoom anchors,
31// the jump wave) ride along unchanged.
32
33// The cross-row interaction state the old container held as screen @State.
34// It lives in ONE observable object that only the row views (and the reply
35// thread's bubbles) observe — the screen deliberately does NOT, so a 60Hz
36// swipe drag re-renders the visible cells and nothing else.
37@MainActor
38final class FeedInteraction: ObservableObject {
39 // The times peek: dragging left slides every row's time in together.
40 @Published var timesReveal: CGFloat = 0
41 // Swipe-right quick-reply: the dragged bubble's offset, by message id.
42 @Published var replyDrag: [Int64: CGFloat] = [:]
43 // The jump wave target (the Mac feed's Minion); tick retriggers same-id.
44 @Published var danceID: Int64?
45 @Published var danceTick = 0
46 // The attachment currently downloading + its 0…1 progress — drives the
47 // video card's radial ring and the file chip's. One at a time is enforced
48 // by the callers (a given attachment is either a video or a file).
49 @Published var blobDownload: (id: String, progress: Double)?
50 // Threshold haptic latch for the reply swipe — no render effect.
51 var replyArmed = false
52}
53
54// Everything a row needs from the screen, resolved at configure time. Plain
55// values + closures; rows never observe the model (that per-publish whole-feed
56// re-render was the 34-88ms stall the old container's comments document).
57struct FeedHooks {
58 var isGroup: Bool
59 var themeName: String
60 var chatFontSize: CGFloat
61 var senderName: (ChatFeedModel.Message) -> String
62 var foldsAsMuted: (ChatFeedModel.Message) -> Bool
63 var revealMuted: (Int64) -> Void
64 var actions: (ChatFeedModel.Message) -> MessageActions
65 var onJump: (Int64) -> Void
66 var onOpenThread: (Int64) -> Void
67 var blobSource: (Attachment) -> AvatarSource?
68 var onImageTap: (Attachment, PlatformImage, CGRect) -> Void
69 var onFileTap: (Attachment) -> Void
70 var onVideoTap: (Attachment, CGRect) -> Void
71 var unfurl: (URL) async -> LinkPreview?
72 // Synchronous unfurl-cache peek — height pre-measurement must see an
73 // already-resolved card (.some(nil) = known-empty; nil = not fetched).
74 var peekUnfurl: (URL) -> LinkPreview?? = { _ in nil }
75 var startReply: (ChatFeedModel.Message) -> Void
76 // @mentions: the index bubbles style "@Name" runs against (resolved at
77 // configure time, like senderName), and the tap → profile-card hook.
78 var mentionIndex: () -> MentionIndex? = { nil }
79 var onMentionTap: (String) -> Void = { _ in }
80}
81
82// What a feed container owes the screen: the two scroll verbs.
83@MainActor
84protocol FeedScrolling: AnyObject {
85 func pinToBottom(animated: Bool)
86 func scrollTo(id: Int64)
87}
88
89// The screen's handle on the scroll position — jump-to-latest, quote jumps,
90// the search-hit jump. A thin command surface; the container owns the math.
91@MainActor
92final class FeedScrollController {
93 weak var target: (any FeedScrolling)?
94 func scrollToBottom(animated: Bool) { target?.pinToBottom(animated: animated) }
95 func scrollTo(id: Int64) { target?.scrollTo(id: id) }
96}
97
98struct FlippedChatFeed: UIViewRepresentable {
99 let model: ChatFeedModel
100 let interaction: FeedInteraction
101 let controller: FeedScrollController
102 let hooks: FeedHooks
103 // The header / composer regions the feed extends under (it ignores the
104 // container safe areas and gets them back as content insets, so messages
105 // scroll beneath the glass exactly as the ScrollView's did).
106 let topOverlay: CGFloat
107 let bottomOverlay: CGFloat
108 let composerFocused: Bool
109 // Notification-tap open with unreads: land the first unread at the TOP.
110 let unreadCutoff: Int64?
111 let onJumpVisible: (Bool) -> Void
112 // Fires once, after the first content layout lands — the feed.mount end.
113 let onFirstLayout: (Int) -> Void
114
115 func makeCoordinator() -> Coordinator {
116 Coordinator(model: model, interaction: interaction, hooks: hooks,
117 unreadCutoff: unreadCutoff)
118 }
119
120 func makeUIView(context: Context) -> UICollectionView {
121 let coordinator = context.coordinator
122 coordinator.config = Config(topOverlay: topOverlay, bottomOverlay: bottomOverlay,
123 composerFocused: composerFocused,
124 isGroup: hooks.isGroup, themeName: hooks.themeName,
125 fontSize: hooks.chatFontSize)
126 coordinator.onJumpVisible = onJumpVisible
127 coordinator.onFirstLayout = onFirstLayout
128 let cv = coordinator.buildCollectionView()
129 controller.target = coordinator
130 coordinator.subscribe()
131 return cv
132 }
133
134 func updateUIView(_ cv: UICollectionView, context: Context) {
135 let coordinator = context.coordinator
136 coordinator.hooks = hooks
137 coordinator.onJumpVisible = onJumpVisible
138 coordinator.onFirstLayout = onFirstLayout
139 coordinator.configUpdated(Config(topOverlay: topOverlay, bottomOverlay: bottomOverlay,
140 composerFocused: composerFocused,
141 isGroup: hooks.isGroup, themeName: hooks.themeName,
142 fontSize: hooks.chatFontSize))
143 }
144
145 // The config the coordinator diffs on updateUIView: overlay insets adjust
146 // in place; theme/type-size changes reconfigure the visible rows.
147 struct Config: Equatable {
148 var topOverlay: CGFloat = 0
149 var bottomOverlay: CGFloat = 0
150 var composerFocused = false
151 var isGroup = false
152 var themeName = "classic"
153 var fontSize: CGFloat = 17
154 }
155
156 enum FeedItem: Hashable {
157 case message(Int64)
158 case typing
159 }
160
161 @MainActor
162 final class Coordinator: NSObject, UICollectionViewDelegate, FeedScrolling {
163 let model: ChatFeedModel
164 let interaction: FeedInteraction
165 var hooks: FeedHooks
166 var config = Config()
167 var onJumpVisible: ((Bool) -> Void)?
168 var onFirstLayout: ((Int) -> Void)?
169
170 private(set) var collectionView: FlippedCollectionView!
171 private var dataSource: UICollectionViewDiffableDataSource<Int, FeedItem>!
172 // The messages as last applied, ascending by id (the model's order).
173 private var current: [ChatFeedModel.Message] = []
174 private var typingNames: [String] = []
175 private var cancellables = Set<AnyCancellable>()
176 private var contentSizeObservation: NSKeyValueObservation?
177 private var keyboardOverlap: CGFloat = 0
178 private var firstApplyDone = false
179 // Consumed by the first anchor pass; nil = open at the bottom.
180 private var unreadCutoff: Int64?
181 // Set by the first apply; the collection view calls back from the
182 // first REAL layout (makeUIView-time bounds are zero, so anchoring
183 // must wait for layoutSubviews).
184 fileprivate var needsInitialAnchor = false
185 private var jumpVisible = false
186 // The last scroll-time gap from the bottom — the pin decisions read
187 // this rather than recomputing against an inset mid-mutation.
188 private var lastGap: CGFloat = 0
189
190 init(model: ChatFeedModel, interaction: FeedInteraction, hooks: FeedHooks,
191 unreadCutoff: Int64?) {
192 self.model = model
193 self.interaction = interaction
194 self.hooks = hooks
195 self.unreadCutoff = unreadCutoff
196 }
197
198 deinit {
199 NotificationCenter.default.removeObserver(self)
200 }
201
202 // ---- construction ---------------------------------------------------
203
204 func buildCollectionView() -> UICollectionView {
205 let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
206 heightDimension: .estimated(60))
207 let item = NSCollectionLayoutItem(layoutSize: size)
208 let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
209 let layout = UICollectionViewCompositionalLayout(section: NSCollectionLayoutSection(group: group))
210
211 let cv = FlippedCollectionView(frame: .zero, collectionViewLayout: layout)
212 cv.coordinator = self
213 cv.transform = CGAffineTransform(scaleX: 1, y: -1)
214 cv.backgroundColor = .clear
215 cv.allowsSelection = false
216 cv.keyboardDismissMode = .interactive
217 // We own every inset (header, composer, keyboard, the short-chat
218 // shortfall); UIKit's automatic adjustment doesn't understand the
219 // flip and would fight all of them.
220 cv.contentInsetAdjustmentBehavior = .never
221 // Flipped-list taxes: the indicator would travel backwards, and
222 // the status-bar tap would jump to the NEWEST message.
223 cv.showsVerticalScrollIndicator = false
224 cv.scrollsToTop = false
225 cv.delegate = self
226 collectionView = cv
227
228 let registration = UICollectionView.CellRegistration<FlippedCell, FeedItem> {
229 [weak self] cell, _, item in
230 guard let self else { return }
231 switch item {
232 case .typing:
233 let label = self.config.isGroup ? typingSummary(self.typingNames) : nil
234 cell.contentConfiguration = UIHostingConfiguration {
235 FeedTypingRow(label: label, themeName: self.config.themeName,
236 chatFontSize: self.config.fontSize)
237 }.margins(.all, 0)
238 case .message(let id):
239 // A dequeue can race an apply; never leave a reused cell
240 // showing another row's stale content.
241 guard let idx = self.index(of: id) else {
242 cell.contentConfiguration = nil
243 return
244 }
245 let m = self.current[idx]
246 let prev = idx > 0 ? self.current[idx - 1] : nil
247 cell.contentConfiguration = UIHostingConfiguration {
248 FeedMessageRow(message: m, previous: prev,
249 hooks: self.hooks, interaction: self.interaction)
250 // Hosted identity pins to the MESSAGE, not the
251 // cell: on reuse the hosted tree would otherwise
252 // UPDATE from one message to another and animate
253 // the layout diff — bubbles visibly morphing
254 // mid-scroll. A new identity mounts fresh instead
255 // (cheap: the parse/layout memos are global).
256 .id(m.id)
257 }.margins(.all, 0)
258 }
259 }
260 dataSource = UICollectionViewDiffableDataSource<Int, FeedItem>(collectionView: cv) {
261 cv, indexPath, item in
262 cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item)
263 }
264
265 NotificationCenter.default.addObserver(
266 self, selector: #selector(keyboardWillChange(_:)),
267 name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
268 // Late height changes (unfurl cards, reaction chips) move
269 // contentSize — the short-chat shortfall inset must track it.
270 // DEFERRED a turn: this KVO fires mid-layout and mid-batch-update,
271 // where mutating contentInset is undefined behaviour (UIKit
272 // internal-consistency crashes).
273 contentSizeObservation = cv.observe(\.contentSize) { [weak self] _, _ in
274 MainActor.assumeIsolated { self?.queueInsetUpdate() }
275 }
276 updateInsets()
277 return cv
278 }
279
280 func subscribe() {
281 // @Published emits the NEW value synchronously (and once on
282 // subscribe — which delivers a parked model's messages right here,
283 // before the first layout). Always use the emitted value: the
284 // property itself still holds the old one mid-publish.
285 model.$messages
286 .removeDuplicates()
287 .sink { [weak self] in self?.setMessages($0) }
288 .store(in: &cancellables)
289 model.$typing
290 .removeDuplicates()
291 .sink { [weak self] in self?.setTyping($0) }
292 .store(in: &cancellables)
293 // Render-level inputs the rows resolve at configure time: a
294 // rename, a mute, a reveal — refresh the visible rows.
295 // removeDuplicates matters: a re-assignment of an unchanged value
296 // must not re-host every visible cell.
297 model.$senderNames.removeDuplicates().map { _ in () }
298 .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () })
299 .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () })
300 .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () })
301 .dropFirst(4)
302 .sink { [weak self] in self?.reconfigureAll() }
303 .store(in: &cancellables)
304 // An empty chat has no first layout to anchor — end the mount
305 // measurement when the (empty) history lands instead.
306 model.$ready
307 .filter { $0 }
308 .prefix(1)
309 .sink { [weak self] _ in
310 guard let self, !self.firstApplyDone, self.current.isEmpty else { return }
311 let cb = self.onFirstLayout
312 DispatchQueue.main.async { cb?(0) }
313 }
314 .store(in: &cancellables)
315 }
316
317 // ---- data -----------------------------------------------------------
318
319 private func index(of id: Int64) -> Int? {
320 // Binary search — every fold path publishes sorted by id.
321 var lo = 0, hi = current.count - 1
322 while lo <= hi {
323 let mid = (lo + hi) / 2
324 if current[mid].id == id { return mid }
325 if current[mid].id < id { lo = mid + 1 } else { hi = mid - 1 }
326 }
327 return nil
328 }
329
330 private func setMessages(_ new: [ChatFeedModel.Message]) {
331 let oldNewest = current.last?.id
332 current = new
333 apply(newestChanged: new.last?.id != oldNewest,
334 newestIsMine: new.last?.mine == true)
335 }
336
337 private func setTyping(_ names: [String]) {
338 let appeared = typingNames.isEmpty && !names.isEmpty
339 let changed = typingNames.isEmpty != names.isEmpty
340 typingNames = names
341 if changed {
342 // The bubble appearing counts as bottom growth (pin while
343 // composing, like an arrival); a label-only change (who's
344 // typing) just refreshes the row.
345 apply(newestChanged: appeared, newestIsMine: false)
346 } else if !names.isEmpty {
347 var snap = dataSource.snapshot()
348 if snap.itemIdentifiers.contains(.typing) {
349 snap.reconfigureItems([.typing])
350 dataSource.apply(snap, animatingDifferences: false)
351 }
352 }
353 }
354
355 private func items() -> [FeedItem] {
356 var list: [FeedItem] = typingNames.isEmpty ? [] : [.typing]
357 list.reserveCapacity(current.count + 1)
358 for m in current.reversed() { list.append(.message(m.id)) }
359 return list
360 }
361
362 // What each row rendered from last time, so an apply only reconfigures
363 // rows whose content really changed: the message itself (edits,
364 // reactions, reply counts) or its PREVIOUS neighbour (the separator /
365 // sender-run boundary shifts when a page lands above it).
366 private var lastPairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:]
367
368 // Every apply is NON-ANIMATED, deliberately. UIKit batch-update
369 // animations in a transformed collection view were the launch-day
370 // grief in one costume after another: the default insert animation
371 // mirrors through the flip (bubbles "arriving from the top"), and
372 // frequent applies interrupt in-flight alpha animations, stranding
373 // cells half-transparent (the "faded" feed). Non-animated applies are
374 // also what makes the structural guarantees real: at the bottom an
375 // arrival shows in place with NO offset write at all, and a far-end
376 // history append moves nothing. An arrival-entrance animation can
377 // come back later as OUR OWN cell animation — never performBatchUpdates'.
378 private func apply(newestChanged: Bool, newestIsMine: Bool) {
379 guard dataSource != nil else { return }
380 let old = dataSource.snapshot().itemIdentifiers
381 var snap = NSDiffableDataSourceSnapshot<Int, FeedItem>()
382 snap.appendSections([0])
383 let new = items()
384 snap.appendItems(new)
385 let oldSet = Set(old)
386 let newSet = Set(new)
387 var pairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:]
388 pairs.reserveCapacity(current.count)
389 var changed: [FeedItem] = []
390 for (i, m) in current.enumerated() {
391 let pair = (msg: m, prevID: i > 0 ? current[i - 1].id : nil)
392 pairs[m.id] = pair
393 if oldSet.contains(.message(m.id)), let last = lastPairs[m.id],
394 last.msg != pair.msg || last.prevID != pair.prevID {
395 changed.append(.message(m.id))
396 }
397 }
398 // Oldest previously-applied message id — the append-only test.
399 let lastOldest = lastPairs.keys.min()
400 lastPairs = pairs
401 snap.reconfigureItems(changed)
402
403 guard firstApplyDone else {
404 dataSource.apply(snap, animatingDifferences: false)
405 if !current.isEmpty {
406 firstApplyDone = true
407 needsInitialAnchor = true
408 collectionView.setNeedsLayout()
409 }
410 return
411 }
412
413 let inserted = new.filter { !oldSet.contains($0) }
414 let removed = old.filter { !newSet.contains($0) }
415 // Scroll-back pages append past the far end of the flipped layout:
416 // nothing between the viewport and flipped-zero moves, so the
417 // apply needs no forced layout, no anchor, no offset write. This
418 // fast path is most of what makes scroll-back feel native — the
419 // earlier restore-on-every-apply wrote contentOffset mid-gesture,
420 // which kills deceleration dead (the "locked" scroll feel).
421 let appendOnly = removed.isEmpty && inserted.allSatisfy { item in
422 if case .message(let id) = item { return id < (lastOldest ?? Int64.min) }
423 return false
424 }
425 let wasAtBottom = gapFromBottom() < 44
426
427 if appendOnly || (inserted.isEmpty && removed.isEmpty) {
428 // Appends and reconfigure-only passes hold position by
429 // construction (at the bottom, growth of the newest row is
430 // structural too — flipped y=0 stays glued to the composer).
431 dataSource.apply(snap, animatingDifferences: false)
432 return
433 }
434 if wasAtBottom {
435 // Arrivals at the bottom: the insert at index 0 shifts content
436 // up under a constant offset — the new message simply IS the
437 // bottom now. No write, nothing to fight the finger.
438 dataSource.apply(snap, animatingDifferences: false)
439 return
440 }
441 // Scrolled up with content changing at or below the viewport
442 // (arrivals, deletes, NSE catch-up merges): hold the viewport by
443 // anchor, against REAL post-apply layout.
444 let anchor = captureAnchor()
445 dataSource.apply(snap, animatingDifferences: false)
446 collectionView.layoutIfNeeded()
447 if newestChanged, newestIsMine || config.composerFocused {
448 // Your own send always pins; so does any bottom growth while
449 // composing (you're at the conversation's tail).
450 pinToBottom(animated: !UIAccessibility.isReduceMotionEnabled)
451 } else if !collectionView.isTracking, !collectionView.isDecelerating {
452 restore(anchor)
453 }
454 }
455
456 private func reconfigureAll() {
457 guard dataSource != nil else { return }
458 var snap = dataSource.snapshot()
459 snap.reconfigureItems(snap.itemIdentifiers)
460 dataSource.apply(snap, animatingDifferences: false)
461 }
462
463 // ---- viewport anchoring --------------------------------------------
464
465 private struct Anchor {
466 let id: Int64
467 let offsetInViewport: CGFloat
468 }
469
470 private func captureAnchor() -> Anchor? {
471 // The newest-most visible MESSAGE row (lowest flipped y) and where
472 // it sits relative to the viewport.
473 let visible = collectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item }
474 for ip in visible {
475 guard case .message(let id) = dataSource.itemIdentifier(for: ip),
476 let attr = collectionView.layoutAttributesForItem(at: ip) else { continue }
477 return Anchor(id: id, offsetInViewport: attr.frame.minY - collectionView.contentOffset.y)
478 }
479 return nil
480 }
481
482 private func restore(_ anchor: Anchor?) {
483 guard let anchor,
484 let ip = dataSource.indexPath(for: .message(anchor.id)),
485 let attr = collectionView.layoutAttributesForItem(at: ip) else { return }
486 let target = attr.frame.minY - anchor.offsetInViewport
487 let minY = -collectionView.contentInset.top
488 let maxY = max(minY, collectionView.contentSize.height
489 + collectionView.contentInset.bottom - collectionView.bounds.height)
490 collectionView.contentOffset.y = min(max(target, minY), maxY)
491 }
492
493 // ---- scrolling ------------------------------------------------------
494
495 private func gapFromBottom() -> CGFloat {
496 collectionView.contentOffset.y + collectionView.contentInset.top
497 }
498
499 func pinToBottom(animated: Bool) {
500 collectionView.setContentOffset(
501 CGPoint(x: 0, y: -collectionView.contentInset.top), animated: animated)
502 }
503
504 func scrollTo(id: Int64) {
505 guard let ip = dataSource.indexPath(for: .message(id)) else { return }
506 // Animated only for NEAR jumps (the old container's rule): an
507 // animated scroll across hundreds of estimated rows recalibrates
508 // heights on the way and can land rough; far targets snap.
509 collectionView.scrollToItem(at: ip, at: .centeredVertically,
510 animated: ip.item <= 60)
511 }
512
513 func scrollViewDidScroll(_ scrollView: UIScrollView) {
514 lastGap = gapFromBottom()
515 // Jump-to-latest after a MODERATE scroll up (much wider than the
516 // pin threshold, so it doesn't appear for reading the newest
517 // couple of messages). Only threshold crossings leave UIKit.
518 let show = lastGap > 400
519 if show != jumpVisible {
520 jumpVisible = show
521 let cb = onJumpVisible
522 DispatchQueue.main.async { cb?(show) }
523 }
524 }
525
526 // Scroll-back: nearing the oldest loaded rows pages more in. The far
527 // end of a flipped list — cells only materialise when genuinely close,
528 // so there is no transient-layout storm to gate against (the model's
529 // own cooldown paces repeat fires).
530 func collectionView(_ collectionView: UICollectionView,
531 willDisplay cell: UICollectionViewCell,
532 forItemAt indexPath: IndexPath) {
533 guard firstApplyDone,
534 indexPath.item >= collectionView.numberOfItems(inSection: 0) - 8 else { return }
535 let model = model
536 Task { await model.loadOlder() }
537 }
538
539 // ---- layout: insets, keyboard, first anchor -------------------------
540
541 func configUpdated(_ new: Config) {
542 let old = config
543 config = new
544 if old.themeName != new.themeName || old.fontSize != new.fontSize
545 || old.isGroup != new.isGroup {
546 reconfigureAll()
547 }
548 if old.topOverlay != new.topOverlay || old.bottomOverlay != new.bottomOverlay {
549 // Composer growth (reply banner, multiline) while at the
550 // bottom keeps the last message visible; a reader up in
551 // history just gets the inset and stays put.
552 let wasAtBottom = lastGap < 44
553 updateInsets()
554 if wasAtBottom, !collectionView.isTracking {
555 pinToBottom(animated: false)
556 }
557 }
558 }
559
560 private var insetUpdateQueued = false
561
562 private func queueInsetUpdate() {
563 guard !insetUpdateQueued else { return }
564 insetUpdateQueued = true
565 DispatchQueue.main.async {
566 self.insetUpdateQueued = false
567 self.updateInsets()
568 }
569 }
570
571 private func updateInsets() {
572 guard let cv = collectionView else { return }
573 // bottomOverlay = composer + home bar (keyboard excluded — the
574 // feed ignores the keyboard safe area; the overlap arrives via
575 // the notifications below, and subsumes the home bar when up).
576 let homeBar = cv.window?.safeAreaInsets.bottom ?? 0
577 let occlusion = config.bottomOverlay + max(0, keyboardOverlap - homeBar)
578 // Short chats read from the TOP: pad the flipped top (the visual
579 // bottom) with the shortfall so content aligns under the header.
580 let avail = cv.bounds.height - occlusion - config.topOverlay
581 let shortfall = max(0, avail - cv.contentSize.height)
582 let inset = UIEdgeInsets(top: occlusion + shortfall, left: 0,
583 bottom: config.topOverlay, right: 0)
584 guard inset != cv.contentInset else { return }
585 let wasAtBottom = lastGap < 44
586 cv.contentInset = inset
587 if !cv.isTracking, !needsInitialAnchor,
588 wasAtBottom || gapFromBottom() < 0 {
589 // Re-pin when we were at the bottom — and ALSO when a
590 // shrinking top inset left the offset beyond the new rest
591 // position (negative gap): UIKit never corrects that on its
592 // own, and the feed sits stuck high with an empty band above
593 // the composer (the keyboard-up screenshot's shape).
594 cv.contentOffset.y = -inset.top
595 }
596 }
597
598 @objc private func keyboardWillChange(_ note: Notification) {
599 guard let cv = collectionView, let window = cv.window,
600 let end = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
601 else { return }
602 let endInWindow = window.convert(end, from: nil)
603 let overlap = max(0, window.bounds.maxY - endInWindow.minY)
604 guard overlap != keyboardOverlap else { return }
605 let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey]
606 as? Double ?? 0.25
607 let curve = note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey]
608 as? Int ?? 7
609 let wasAtBottom = lastGap < 44
610 keyboardOverlap = overlap
611 // Ride the keyboard's own curve; pinned readers stay pinned (the
612 // feed lifts with the keyboard), history readers stay put.
613 UIView.animate(withDuration: duration, delay: 0,
614 options: [UIView.AnimationOptions(rawValue: UInt(curve << 16)),
615 .beginFromCurrentState]) {
616 self.updateInsets()
617 if wasAtBottom, !cv.isTracking {
618 cv.contentOffset.y = -cv.contentInset.top
619 }
620 }
621 }
622
623 // Called by the collection view from layoutSubviews once bounds are
624 // real: the first anchor must run against actual geometry, and BEFORE
625 // first display — the reader finds the chat already landed, first
626 // frame, no settle pass to hide.
627 //
628 // ⚠️ This runs INSIDE a layout pass, so it must not re-enter layout:
629 // no scrollToItem, no layoutIfNeeded, no second super.layoutSubviews.
630 // The first cut did exactly that and UIKit trapped on recursive
631 // collection-view layout (the launch-day crash — MetricKit payloads
632 // 2026-07-20 19:25/19:26Z, six nested layout frames). Writing
633 // contentInset/contentOffset directly only DIRTIES layout for the
634 // same commit — bounded, the mechanism working as designed.
635 fileprivate func performInitialAnchor() {
636 needsInitialAnchor = false
637 updateInsets()
638 if let cutoff = unreadCutoff,
639 let target = current.first(where: { $0.at > cutoff && !$0.mine }),
640 let ip = dataSource.indexPath(for: .message(target.id)) {
641 // First unread at the TOP (just under the title pill), so
642 // reading continues where it left off — Messages-style.
643 // Flipped: the visual top is the item's frame END. The frame
644 // here can still be an estimate for a deep target, so one
645 // exact correction runs after the pass, outside layout.
646 if let attr = collectionView.layoutAttributesForItem(at: ip) {
647 setOffsetShowing(flippedMaxY: attr.frame.maxY)
648 }
649 DispatchQueue.main.async { [weak self] in
650 guard let self, let ip = self.dataSource.indexPath(for: .message(target.id))
651 else { return }
652 self.collectionView.scrollToItem(at: ip, at: .bottom, animated: false)
653 self.lastGap = self.gapFromBottom()
654 }
655 } else {
656 collectionView.contentOffset.y = -collectionView.contentInset.top
657 }
658 unreadCutoff = nil
659 lastGap = gapFromBottom()
660 let count = current.count
661 let cb = onFirstLayout
662 // Deferred a turn so the screen's onAppear (which starts the
663 // mount span) has run whichever order SwiftUI commits them.
664 DispatchQueue.main.async { cb?(count) }
665 }
666
667 // Position the viewport so the given flipped-space y (an item's frame
668 // end = its visual top edge) sits at the visual top of the feed.
669 private func setOffsetShowing(flippedMaxY: CGFloat) {
670 let cv = collectionView!
671 let target = flippedMaxY - cv.bounds.height + cv.contentInset.bottom
672 let minY = -cv.contentInset.top
673 cv.contentOffset.y = max(target, minY)
674 }
675
676 #if DEBUG
677 // ---- feed-dump (diagnostic) ----------------------------------------
678 // Writes the live UIKit truth — the collection view's metrics, every
679 // visible cell's frame/alpha/transform, and every ancestor + sibling
680 // up to the window — to Library/Logs/feed-dump.txt every few seconds
681 // while the feed is on screen. Pullable over devicectl, so a "why
682 // does it LOOK like that" report becomes numbers instead of theories.
683 private var dumpTimer: Timer?
684
685 fileprivate func setDumping(_ on: Bool) {
686 dumpTimer?.invalidate()
687 dumpTimer = nil
688 guard on else { return }
689 let t = Timer(timeInterval: 4, repeats: true) { [weak self] _ in
690 MainActor.assumeIsolated { self?.writeDump() }
691 }
692 RunLoop.main.add(t, forMode: .common)
693 dumpTimer = t
694 }
695
696 private func viewLine(_ v: UIView, marker: Bool = false) -> String {
697 let f = v.frame
698 let bg = v.backgroundColor.map { bg in
699 var w: CGFloat = 0, a: CGFloat = 0
700 bg.getWhite(&w, alpha: &a)
701 return String(format: "bg(w%.2f,a%.2f)", w, a)
702 } ?? "bg(nil)"
703 return String(format: "%@%@ %.0f,%.0f %.0fx%.0f alpha=%.2f opac=%.2f %@%@%@ ty.d=%.0f",
704 marker ? "→ " : " ", String(describing: type(of: v)),
705 f.origin.x, f.origin.y, f.width, f.height,
706 v.alpha, v.layer.opacity, bg,
707 v.isHidden ? " HIDDEN" : "",
708 v.clipsToBounds ? " clips" : "",
709 v.transform.d)
710 }
711
712 private func writeDump() {
713 guard let cv = collectionView, let window = cv.window else { return }
714 var out: [String] = ["== feed dump \(Date()) =="]
715 out.append("window \(window.bounds.size) safeArea=\(window.safeAreaInsets)")
716 out.append("cv frame(win)=\(cv.convert(cv.bounds, to: window)) inset=\(cv.contentInset) "
717 + "offset=\(cv.contentOffset.y) contentH=\(cv.contentSize.height) "
718 + "gap=\(gapFromBottom()) lastGap=\(lastGap)")
719 out.append("config top=\(config.topOverlay) bottom=\(config.bottomOverlay) "
720 + "kb=\(keyboardOverlap) focused=\(config.composerFocused)")
721 out.append("-- visible cells (item: frame-in-cv alpha/opacity) --")
722 for ip in cv.indexPathsForVisibleItems.sorted(by: { $0.item < $1.item }) {
723 guard let cell = cv.cellForItem(at: ip) else { continue }
724 let host = cell.contentView.subviews.first
725 out.append(String(format: " #%d y=%.0f h=%.0f cell(a=%.2f o=%.2f) content(ty.d=%.0f a=%.2f) host=%@ hostA=%.2f",
726 ip.item, cell.frame.origin.y, cell.frame.height,
727 cell.alpha, cell.layer.opacity,
728 cell.contentView.transform.d, cell.contentView.alpha,
729 host.map { String(describing: type(of: $0)) } ?? "none",
730 host?.alpha ?? -1))
731 }
732 out.append("-- ancestors + siblings (window-ward) --")
733 var v: UIView = cv
734 while let sup = v.superview {
735 for sib in sup.subviews {
736 out.append(viewLine(sib, marker: sib === v))
737 }
738 out.append(" ^ parent " + viewLine(sup))
739 v = sup
740 }
741 let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
742 .appendingPathComponent("Logs", isDirectory: true)
743 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
744 try? out.joined(separator: "\n")
745 .write(to: dir.appendingPathComponent("feed-dump.txt"),
746 atomically: true, encoding: .utf8)
747 }
748 #endif
749
750 fileprivate func boundsChanged() {
751 // Called from layoutSubviews — the inset write waits a turn (see
752 // the contentSize KVO note). A pure height change needs no re-pin:
753 // the flipped origin keeps the newest message glued to the bottom
754 // edge through resizes on its own.
755 queueInsetUpdate()
756 }
757 }
758
759 // The counter-flip lives on the CELL, enforced after every layout pass:
760 // assigning a contentConfiguration REPLACES the cell's contentView, so a
761 // transform set in the registration handler silently vanishes on the next
762 // (re)configure — rows then render upside down, and the mixed-orientation
763 // overlap reads as a ghosted, washed-out feed. layoutSubviews runs after
764 // every configuration swap, so the flip always lands on the CURRENT
765 // content view. (The cell's own transform can't be used instead: the
766 // layout stamps attribute transforms over it.)
767 final class FlippedCell: UICollectionViewCell {
768 override func layoutSubviews() {
769 super.layoutSubviews()
770 if contentView.transform.d != -1 {
771 contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
772 }
773 }
774 }
775
776 // The flipped collection view itself: initial-anchor timing + the
777 // VoiceOver ordering tax (index order is newest→oldest; visual order is
778 // oldest→newest, and VoiceOver must read visually).
779 final class FlippedCollectionView: UICollectionView {
780 weak var coordinator: Coordinator?
781 private var lastSize: CGSize = .zero
782
783 override func didMoveToWindow() {
784 super.didMoveToWindow()
785 #if DEBUG
786 coordinator?.setDumping(window != nil)
787 #endif
788 }
789
790 override func layoutSubviews() {
791 super.layoutSubviews()
792 if bounds.size != lastSize {
793 let hadSize = lastSize != .zero
794 lastSize = bounds.size
795 if hadSize { coordinator?.boundsChanged() }
796 }
797 // The initial anchor is non-reentrant BY CONTRACT (see its ⚠️):
798 // it only writes insets/offset, which dirty layout for this same
799 // commit — never a forced nested pass.
800 if bounds.height > 0, coordinator?.needsInitialAnchor == true {
801 coordinator?.performInitialAnchor()
802 }
803 }
804
805 // Visible cells, oldest first (higher item index = older = visually
806 // higher), so VoiceOver reads top-to-bottom like every other feed.
807 override var accessibilityElements: [Any]? {
808 get {
809 indexPathsForVisibleItems
810 .sorted { $0.item > $1.item }
811 .compactMap { cellForItem(at: $0) }
812 }
813 set {}
814 }
815 }
816}
817
818// ---- rows (the SwiftUI content hosted in each cell) -------------------------
819
820
821struct FeedMessageRow: View {
822 let message: ChatFeedModel.Message
823 let previous: ChatFeedModel.Message?
824 let hooks: FeedHooks
825 @ObservedObject var interaction: FeedInteraction
826 // Ladder rung 4 drops the long-press menu/actions; 0 (full) includes it.
827
828 // The times-peek shift: just past the WIDEST localized timestamp plus a
829 // Messages-tight gap — a fixed 64 left a ~30pt dead channel between the
830 // bubble edge and the time (Paul's 2026-07-21 screenshot comparison).
831 // Measured from a 20:55 sample so both "20:55" and "8:55 PM" locales fit.
832 static let timesGutter: CGFloat = {
833 var c = Calendar.current.dateComponents([.year, .month, .day], from: Date())
834 c.hour = 20; c.minute = 55
835 let d = Calendar.current.date(from: c) ?? Date()
836 let s = chatTime(Int64(d.timeIntervalSince1970))
837 let w = (s as NSString).size(withAttributes:
838 [.font: UIFont.systemFont(ofSize: 12)]).width
839 return ceil(max(w, 28)) + 8
840 }()
841
842 var body: some View {
843 let sep = timeSeparator(at: message.at, previous: previous?.at)
844 let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, message)
845 // The wrapper VStack exists only when a separator does — a one-child
846 // stack still runs the whole StackLayout pass per recycle (the
847 // profile pad's hot path), and most rows carry no separator.
848 if let sep {
849 VStack(spacing: 0) {
850 separator(sep, at: message.at)
851 rowBody(startsRun: startsRun)
852 }
853 } else {
854 rowBody(startsRun: startsRun)
855 }
856 }
857
858 @ViewBuilder private func rowBody(startsRun: Bool) -> some View {
859 if message.action {
860 Text(message.text)
861 .font(.system(size: 13))
862 .foregroundStyle(.secondary)
863 .multilineTextAlignment(.center)
864 .frame(maxWidth: .infinity)
865 .padding(.vertical, 3)
866 .padding(.horizontal, 14)
867 } else if hooks.foldsAsMuted(message) {
868 // A muted member's message (#578): the quiet stub, revealed
869 // per-message for the session. Always a "them" row.
870 MutedMessageStub { hooks.revealMuted(message.id) }
871 .padding(.trailing, 40)
872 .frame(maxWidth: .infinity, alignment: .leading)
873 .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12,
874 bottom: 3, trailing: 12))
875 } else {
876 bubbleRow(startsRun: startsRun)
877 }
878 }
879
880 private func bubbleRow(startsRun: Bool) -> some View {
881 MessageBubble(message: message, showSender: hooks.isGroup && startsRun,
882 senderName: hooks.senderName(message),
883 theme: .named(hooks.themeName),
884 onJump: hooks.onJump,
885 onOpenThread: hooks.onOpenThread,
886 blobSource: hooks.blobSource,
887 onImageTap: hooks.onImageTap,
888 onFileTap: hooks.onFileTap,
889 onVideoTap: hooks.onVideoTap,
890 downloading: interaction.blobDownload,
891 // The UIKit list owns the long-press menu (the
892 // coordinator's context-menu delegate); a SwiftUI
893 // .contextMenu in hosted cells never fired.
894 actions: nil,
895 unfurl: hooks.unfurl,
896 peekUnfurl: hooks.peekUnfurl)
897 .environment(\.chatFontSize, hooks.chatFontSize)
898 .environment(\.mentionIndex, hooks.mentionIndex())
899 // A tapped @mention chip carries a MentionLink URL — intercept it
900 // into the profile card; every real link keeps the system route.
901 .environment(\.openURL, OpenURLAction { url in
902 guard let uid = MentionLink.uid(from: url) else { return .systemAction }
903 hooks.onMentionTap(uid)
904 return .handled
905 })
906 // The far-side gap + speaking-side alignment as two unary layout
907 // nodes instead of HStack + Spacers: the profile pad's hot stacks
908 // were StackLayout.sizeThatFits recursion — every stack/spacer is
909 // a level, and this runs on every cell recycle (2026-07-21).
910 .padding(message.mine ? .leading : .trailing, 40)
911 .frame(maxWidth: .infinity,
912 alignment: message.mine ? .trailing : .leading)
913 // Web-parity breathing room: a touch more air between bubbles,
914 // more still at run starts.
915 .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12,
916 bottom: 3, trailing: 12))
917 // Swipe-right quick-reply: the bubble follows the finger, a
918 // reply arrow fades in behind it, and a past-threshold release
919 // starts the reply (the web's touch gesture). Mine-side
920 // bubbles also shift left with the times peek.
921 .offset(x: (interaction.replyDrag[message.id] ?? 0)
922 - (message.mine ? interaction.timesReveal : 0))
923 .overlay(alignment: .leading) {
924 let drag = interaction.replyDrag[message.id] ?? 0
925 if drag > 0 {
926 // REVEALED by the swipe: the arrow rides in from behind the
927 // bubble's edge, tracking the finger — not parked at the
928 // margin waiting.
929 Image(systemName: "arrowshape.turn.up.left.fill")
930 .font(.system(size: 16))
931 .foregroundStyle(drag >= 40 ? AnyShapeStyle(.tint)
932 : AnyShapeStyle(.tertiary))
933 .opacity(Double(min(1, drag / 44)))
934 .scaleEffect(drag >= 40 ? 1 : 0.7 + 0.3 * min(1, drag / 40))
935 .offset(x: drag - 40)
936 .allowsHitTesting(false)
937 }
938 }
939 // The times peek (Messages): dragging left slides a time in at
940 // the trailing edge — landing INSIDE the viewport with a
941 // margin — springing back on release. Built only while a drag
942 // is revealing it: chatTime's ICU formatting for every row on
943 // every body eval is pure waste the other 99% of the time.
944 .overlay(alignment: .trailing) {
945 if interaction.timesReveal > 0 {
946 Text(chatTime(message.at))
947 .font(.system(size: 12))
948 .foregroundStyle(.secondary)
949 .fixedSize()
950 .padding(.trailing, 8)
951 .offset(x: max(0, Self.timesGutter - interaction.timesReveal))
952 .opacity(interaction.timesReveal / Self.timesGutter)
953 .allowsHitTesting(false)
954 // Removal rides the release spring, so letting go
955 // still slides the times away instead of blinking.
956 .transition(.move(edge: .trailing).combined(with: .opacity))
957 }
958 }
959 // The swipe that drives replyDrag/timesReveal is a UIKit pan on the
960 // collection view (StockChatFeed.Coordinator.rowSwiped) — a SwiftUI
961 // DragGesture here claimed vertical flicks away from the scroll pan
962 // (the 2026-07-21 Contributors freeze). Rows just render the state.
963 .clipped()
964 // The jump wave, applied AFTER .clipped() so the wag never
965 // beheads at the row edge (shared keyframes: JumpWave in core).
966 .jumpWave(on: message.id == interaction.danceID, tick: interaction.danceTick)
967 // Optimistic send (web parity): the copy already shows instantly; once it
968 // has gone slow (5s unconfirmed) the bubble dims, with a non-visual cue
969 // for VoiceOver so the delay isn't conveyed by opacity alone. Outermost +
970 // additive, so it never disturbs the bubble's own layout.
971 .opacity(message.pending && message.slow ? 0.55 : 1)
972 .accessibilityHint(message.pending ? (message.slow ? "Still sending" : "Sending") : "")
973 }
974
975 private func separator(_ sep: TimeSeparator, at seconds: Int64) -> some View {
976 Group {
977 if sep == .dated {
978 Text(chatDayLabel(seconds)).bold() + Text(" \(chatTime(seconds))")
979 } else {
980 Text(chatTime(seconds))
981 }
982 }
983 .font(.system(size: 12))
984 .foregroundStyle(.secondary)
985 .frame(maxWidth: .infinity)
986 // More air above a separator than below — it belongs to the messages
987 // that follow it, not the ones before.
988 .padding(.top, 18)
989 .padding(.bottom, 8)
990 }
991}
992
993// Observes the shared interaction state on behalf of a subtree whose owner
994// deliberately doesn't (NativeChatScreen holds FeedInteraction as plain
995// @State so drags never re-render the whole screen; the reply-thread
996// overlay's bubbles still need live download progress).
997struct FeedInteractionReader<Content: View>: View {
998 @ObservedObject var interaction: FeedInteraction
999 @ViewBuilder let content: (FeedInteraction) -> Content
1000 var body: some View { content(interaction) }
1001}
1002
1003// The web's typing bubble: bouncing dots in the feed, them-side.
1004struct FeedTypingRow: View {
1005 let label: String?
1006 let themeName: String
1007 let chatFontSize: CGFloat
1008
1009 var body: some View {
1010 HStack(spacing: 0) {
1011 TypingBubble(theme: .named(themeName), label: label)
1012 // The bubble sizes itself as one MESSAGE line — without the
1013 // feed's size it fell back to the default and outgrew small
1014 // text settings.
1015 .environment(\.chatFontSize, chatFontSize)
1016 Spacer(minLength: 40)
1017 }
1018 .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12))
1019 }
1020}
1021