| 1 | import Combine |
| 2 | import LChatCore |
| 3 | import SwiftUI |
| 4 | import 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 |
| 38 | final 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). |
| 57 | struct 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 |
| 84 | protocol 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 |
| 92 | final 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 | |
| 98 | struct 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 | // feedMessages, not messages: the main feed's list leaves out |
| 286 | // thread-only replies (they live in the reply-thread view). |
| 287 | model.$feedMessages |
| 288 | .removeDuplicates() |
| 289 | .sink { [weak self] in self?.setMessages($0) } |
| 290 | .store(in: &cancellables) |
| 291 | model.$typing |
| 292 | .removeDuplicates() |
| 293 | .sink { [weak self] in self?.setTyping($0) } |
| 294 | .store(in: &cancellables) |
| 295 | // Render-level inputs the rows resolve at configure time: a |
| 296 | // rename, a mute, a reveal — refresh the visible rows. |
| 297 | // removeDuplicates matters: a re-assignment of an unchanged value |
| 298 | // must not re-host every visible cell. |
| 299 | model.$senderNames.removeDuplicates().map { _ in () } |
| 300 | .merge(with: model.$mutedPeople.removeDuplicates().map { _ in () }) |
| 301 | .merge(with: model.$revealedMuted.removeDuplicates().map { _ in () }) |
| 302 | .merge(with: model.$mentionIndex.removeDuplicates().map { _ in () }) |
| 303 | .dropFirst(4) |
| 304 | .sink { [weak self] in self?.reconfigureAll() } |
| 305 | .store(in: &cancellables) |
| 306 | // An empty chat has no first layout to anchor — end the mount |
| 307 | // measurement when the (empty) history lands instead. |
| 308 | model.$ready |
| 309 | .filter { $0 } |
| 310 | .prefix(1) |
| 311 | .sink { [weak self] _ in |
| 312 | guard let self, !self.firstApplyDone, self.current.isEmpty else { return } |
| 313 | let cb = self.onFirstLayout |
| 314 | DispatchQueue.main.async { cb?(0) } |
| 315 | } |
| 316 | .store(in: &cancellables) |
| 317 | } |
| 318 | |
| 319 | // ---- data ----------------------------------------------------------- |
| 320 | |
| 321 | private func index(of id: Int64) -> Int? { |
| 322 | // Binary search — every fold path publishes sorted by id. |
| 323 | var lo = 0, hi = current.count - 1 |
| 324 | while lo <= hi { |
| 325 | let mid = (lo + hi) / 2 |
| 326 | if current[mid].id == id { return mid } |
| 327 | if current[mid].id < id { lo = mid + 1 } else { hi = mid - 1 } |
| 328 | } |
| 329 | return nil |
| 330 | } |
| 331 | |
| 332 | private func setMessages(_ new: [ChatFeedModel.Message]) { |
| 333 | let oldNewest = current.last?.id |
| 334 | current = new |
| 335 | apply(newestChanged: new.last?.id != oldNewest, |
| 336 | newestIsMine: new.last?.mine == true) |
| 337 | } |
| 338 | |
| 339 | private func setTyping(_ names: [String]) { |
| 340 | let appeared = typingNames.isEmpty && !names.isEmpty |
| 341 | let changed = typingNames.isEmpty != names.isEmpty |
| 342 | typingNames = names |
| 343 | if changed { |
| 344 | // The bubble appearing counts as bottom growth (pin while |
| 345 | // composing, like an arrival); a label-only change (who's |
| 346 | // typing) just refreshes the row. |
| 347 | apply(newestChanged: appeared, newestIsMine: false) |
| 348 | } else if !names.isEmpty { |
| 349 | var snap = dataSource.snapshot() |
| 350 | if snap.itemIdentifiers.contains(.typing) { |
| 351 | snap.reconfigureItems([.typing]) |
| 352 | dataSource.apply(snap, animatingDifferences: false) |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | private func items() -> [FeedItem] { |
| 358 | var list: [FeedItem] = typingNames.isEmpty ? [] : [.typing] |
| 359 | list.reserveCapacity(current.count + 1) |
| 360 | for m in current.reversed() { list.append(.message(m.id)) } |
| 361 | return list |
| 362 | } |
| 363 | |
| 364 | // What each row rendered from last time, so an apply only reconfigures |
| 365 | // rows whose content really changed: the message itself (edits, |
| 366 | // reactions, reply counts) or its PREVIOUS neighbour (the separator / |
| 367 | // sender-run boundary shifts when a page lands above it). |
| 368 | private var lastPairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] |
| 369 | |
| 370 | // Every apply is NON-ANIMATED, deliberately. UIKit batch-update |
| 371 | // animations in a transformed collection view were the launch-day |
| 372 | // grief in one costume after another: the default insert animation |
| 373 | // mirrors through the flip (bubbles "arriving from the top"), and |
| 374 | // frequent applies interrupt in-flight alpha animations, stranding |
| 375 | // cells half-transparent (the "faded" feed). Non-animated applies are |
| 376 | // also what makes the structural guarantees real: at the bottom an |
| 377 | // arrival shows in place with NO offset write at all, and a far-end |
| 378 | // history append moves nothing. An arrival-entrance animation can |
| 379 | // come back later as OUR OWN cell animation — never performBatchUpdates'. |
| 380 | private func apply(newestChanged: Bool, newestIsMine: Bool) { |
| 381 | guard dataSource != nil else { return } |
| 382 | let old = dataSource.snapshot().itemIdentifiers |
| 383 | var snap = NSDiffableDataSourceSnapshot<Int, FeedItem>() |
| 384 | snap.appendSections([0]) |
| 385 | let new = items() |
| 386 | snap.appendItems(new) |
| 387 | let oldSet = Set(old) |
| 388 | let newSet = Set(new) |
| 389 | var pairs: [Int64: (msg: ChatFeedModel.Message, prevID: Int64?)] = [:] |
| 390 | pairs.reserveCapacity(current.count) |
| 391 | var changed: [FeedItem] = [] |
| 392 | for (i, m) in current.enumerated() { |
| 393 | let pair = (msg: m, prevID: i > 0 ? current[i - 1].id : nil) |
| 394 | pairs[m.id] = pair |
| 395 | if oldSet.contains(.message(m.id)), let last = lastPairs[m.id], |
| 396 | last.msg != pair.msg || last.prevID != pair.prevID { |
| 397 | changed.append(.message(m.id)) |
| 398 | } |
| 399 | } |
| 400 | // Oldest previously-applied message id — the append-only test. |
| 401 | let lastOldest = lastPairs.keys.min() |
| 402 | lastPairs = pairs |
| 403 | snap.reconfigureItems(changed) |
| 404 | |
| 405 | guard firstApplyDone else { |
| 406 | dataSource.apply(snap, animatingDifferences: false) |
| 407 | if !current.isEmpty { |
| 408 | firstApplyDone = true |
| 409 | needsInitialAnchor = true |
| 410 | collectionView.setNeedsLayout() |
| 411 | } |
| 412 | return |
| 413 | } |
| 414 | |
| 415 | let inserted = new.filter { !oldSet.contains($0) } |
| 416 | let removed = old.filter { !newSet.contains($0) } |
| 417 | // Scroll-back pages append past the far end of the flipped layout: |
| 418 | // nothing between the viewport and flipped-zero moves, so the |
| 419 | // apply needs no forced layout, no anchor, no offset write. This |
| 420 | // fast path is most of what makes scroll-back feel native — the |
| 421 | // earlier restore-on-every-apply wrote contentOffset mid-gesture, |
| 422 | // which kills deceleration dead (the "locked" scroll feel). |
| 423 | let appendOnly = removed.isEmpty && inserted.allSatisfy { item in |
| 424 | if case .message(let id) = item { return id < (lastOldest ?? Int64.min) } |
| 425 | return false |
| 426 | } |
| 427 | let wasAtBottom = gapFromBottom() < 44 |
| 428 | |
| 429 | if appendOnly || (inserted.isEmpty && removed.isEmpty) { |
| 430 | // Appends and reconfigure-only passes hold position by |
| 431 | // construction (at the bottom, growth of the newest row is |
| 432 | // structural too — flipped y=0 stays glued to the composer). |
| 433 | dataSource.apply(snap, animatingDifferences: false) |
| 434 | return |
| 435 | } |
| 436 | if wasAtBottom { |
| 437 | // Arrivals at the bottom: the insert at index 0 shifts content |
| 438 | // up under a constant offset — the new message simply IS the |
| 439 | // bottom now. No write, nothing to fight the finger. |
| 440 | dataSource.apply(snap, animatingDifferences: false) |
| 441 | return |
| 442 | } |
| 443 | // Scrolled up with content changing at or below the viewport |
| 444 | // (arrivals, deletes, NSE catch-up merges): hold the viewport by |
| 445 | // anchor, against REAL post-apply layout. |
| 446 | let anchor = captureAnchor() |
| 447 | dataSource.apply(snap, animatingDifferences: false) |
| 448 | collectionView.layoutIfNeeded() |
| 449 | if newestChanged, newestIsMine || config.composerFocused { |
| 450 | // Your own send always pins; so does any bottom growth while |
| 451 | // composing (you're at the conversation's tail). |
| 452 | pinToBottom(animated: !UIAccessibility.isReduceMotionEnabled) |
| 453 | } else if !collectionView.isTracking, !collectionView.isDecelerating { |
| 454 | restore(anchor) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | private func reconfigureAll() { |
| 459 | guard dataSource != nil else { return } |
| 460 | var snap = dataSource.snapshot() |
| 461 | snap.reconfigureItems(snap.itemIdentifiers) |
| 462 | dataSource.apply(snap, animatingDifferences: false) |
| 463 | } |
| 464 | |
| 465 | // ---- viewport anchoring -------------------------------------------- |
| 466 | |
| 467 | private struct Anchor { |
| 468 | let id: Int64 |
| 469 | let offsetInViewport: CGFloat |
| 470 | } |
| 471 | |
| 472 | private func captureAnchor() -> Anchor? { |
| 473 | // The newest-most visible MESSAGE row (lowest flipped y) and where |
| 474 | // it sits relative to the viewport. |
| 475 | let visible = collectionView.indexPathsForVisibleItems.sorted { $0.item < $1.item } |
| 476 | for ip in visible { |
| 477 | guard case .message(let id) = dataSource.itemIdentifier(for: ip), |
| 478 | let attr = collectionView.layoutAttributesForItem(at: ip) else { continue } |
| 479 | return Anchor(id: id, offsetInViewport: attr.frame.minY - collectionView.contentOffset.y) |
| 480 | } |
| 481 | return nil |
| 482 | } |
| 483 | |
| 484 | private func restore(_ anchor: Anchor?) { |
| 485 | guard let anchor, |
| 486 | let ip = dataSource.indexPath(for: .message(anchor.id)), |
| 487 | let attr = collectionView.layoutAttributesForItem(at: ip) else { return } |
| 488 | let target = attr.frame.minY - anchor.offsetInViewport |
| 489 | let minY = -collectionView.contentInset.top |
| 490 | let maxY = max(minY, collectionView.contentSize.height |
| 491 | + collectionView.contentInset.bottom - collectionView.bounds.height) |
| 492 | collectionView.contentOffset.y = min(max(target, minY), maxY) |
| 493 | } |
| 494 | |
| 495 | // ---- scrolling ------------------------------------------------------ |
| 496 | |
| 497 | private func gapFromBottom() -> CGFloat { |
| 498 | collectionView.contentOffset.y + collectionView.contentInset.top |
| 499 | } |
| 500 | |
| 501 | func pinToBottom(animated: Bool) { |
| 502 | collectionView.setContentOffset( |
| 503 | CGPoint(x: 0, y: -collectionView.contentInset.top), animated: animated) |
| 504 | } |
| 505 | |
| 506 | func scrollTo(id: Int64) { |
| 507 | guard let ip = dataSource.indexPath(for: .message(id)) else { return } |
| 508 | // Animated only for NEAR jumps (the old container's rule): an |
| 509 | // animated scroll across hundreds of estimated rows recalibrates |
| 510 | // heights on the way and can land rough; far targets snap. |
| 511 | collectionView.scrollToItem(at: ip, at: .centeredVertically, |
| 512 | animated: ip.item <= 60) |
| 513 | } |
| 514 | |
| 515 | func scrollViewDidScroll(_ scrollView: UIScrollView) { |
| 516 | lastGap = gapFromBottom() |
| 517 | // Jump-to-latest after a MODERATE scroll up (much wider than the |
| 518 | // pin threshold, so it doesn't appear for reading the newest |
| 519 | // couple of messages). Only threshold crossings leave UIKit. |
| 520 | let show = lastGap > 400 |
| 521 | if show != jumpVisible { |
| 522 | jumpVisible = show |
| 523 | let cb = onJumpVisible |
| 524 | DispatchQueue.main.async { cb?(show) } |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | // Scroll-back: nearing the oldest loaded rows pages more in. The far |
| 529 | // end of a flipped list — cells only materialise when genuinely close, |
| 530 | // so there is no transient-layout storm to gate against (the model's |
| 531 | // own cooldown paces repeat fires). |
| 532 | func collectionView(_ collectionView: UICollectionView, |
| 533 | willDisplay cell: UICollectionViewCell, |
| 534 | forItemAt indexPath: IndexPath) { |
| 535 | guard firstApplyDone, |
| 536 | indexPath.item >= collectionView.numberOfItems(inSection: 0) - 8 else { return } |
| 537 | let model = model |
| 538 | Task { await model.loadOlder() } |
| 539 | } |
| 540 | |
| 541 | // ---- layout: insets, keyboard, first anchor ------------------------- |
| 542 | |
| 543 | func configUpdated(_ new: Config) { |
| 544 | let old = config |
| 545 | config = new |
| 546 | if old.themeName != new.themeName || old.fontSize != new.fontSize |
| 547 | || old.isGroup != new.isGroup { |
| 548 | reconfigureAll() |
| 549 | } |
| 550 | if old.topOverlay != new.topOverlay || old.bottomOverlay != new.bottomOverlay { |
| 551 | // Composer growth (reply banner, multiline) while at the |
| 552 | // bottom keeps the last message visible; a reader up in |
| 553 | // history just gets the inset and stays put. |
| 554 | let wasAtBottom = lastGap < 44 |
| 555 | updateInsets() |
| 556 | if wasAtBottom, !collectionView.isTracking { |
| 557 | pinToBottom(animated: false) |
| 558 | } |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | private var insetUpdateQueued = false |
| 563 | |
| 564 | private func queueInsetUpdate() { |
| 565 | guard !insetUpdateQueued else { return } |
| 566 | insetUpdateQueued = true |
| 567 | DispatchQueue.main.async { |
| 568 | self.insetUpdateQueued = false |
| 569 | self.updateInsets() |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | private func updateInsets() { |
| 574 | guard let cv = collectionView else { return } |
| 575 | // bottomOverlay = composer + home bar (keyboard excluded — the |
| 576 | // feed ignores the keyboard safe area; the overlap arrives via |
| 577 | // the notifications below, and subsumes the home bar when up). |
| 578 | let homeBar = cv.window?.safeAreaInsets.bottom ?? 0 |
| 579 | let occlusion = config.bottomOverlay + max(0, keyboardOverlap - homeBar) |
| 580 | // Short chats read from the TOP: pad the flipped top (the visual |
| 581 | // bottom) with the shortfall so content aligns under the header. |
| 582 | let avail = cv.bounds.height - occlusion - config.topOverlay |
| 583 | let shortfall = max(0, avail - cv.contentSize.height) |
| 584 | let inset = UIEdgeInsets(top: occlusion + shortfall, left: 0, |
| 585 | bottom: config.topOverlay, right: 0) |
| 586 | guard inset != cv.contentInset else { return } |
| 587 | let wasAtBottom = lastGap < 44 |
| 588 | cv.contentInset = inset |
| 589 | if !cv.isTracking, !needsInitialAnchor, |
| 590 | wasAtBottom || gapFromBottom() < 0 { |
| 591 | // Re-pin when we were at the bottom — and ALSO when a |
| 592 | // shrinking top inset left the offset beyond the new rest |
| 593 | // position (negative gap): UIKit never corrects that on its |
| 594 | // own, and the feed sits stuck high with an empty band above |
| 595 | // the composer (the keyboard-up screenshot's shape). |
| 596 | cv.contentOffset.y = -inset.top |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | @objc private func keyboardWillChange(_ note: Notification) { |
| 601 | guard let cv = collectionView, let window = cv.window, |
| 602 | let end = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect |
| 603 | else { return } |
| 604 | let endInWindow = window.convert(end, from: nil) |
| 605 | let overlap = max(0, window.bounds.maxY - endInWindow.minY) |
| 606 | guard overlap != keyboardOverlap else { return } |
| 607 | let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] |
| 608 | as? Double ?? 0.25 |
| 609 | let curve = note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] |
| 610 | as? Int ?? 7 |
| 611 | let wasAtBottom = lastGap < 44 |
| 612 | keyboardOverlap = overlap |
| 613 | // Ride the keyboard's own curve; pinned readers stay pinned (the |
| 614 | // feed lifts with the keyboard), history readers stay put. |
| 615 | UIView.animate(withDuration: duration, delay: 0, |
| 616 | options: [UIView.AnimationOptions(rawValue: UInt(curve << 16)), |
| 617 | .beginFromCurrentState]) { |
| 618 | self.updateInsets() |
| 619 | if wasAtBottom, !cv.isTracking { |
| 620 | cv.contentOffset.y = -cv.contentInset.top |
| 621 | } |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | // Called by the collection view from layoutSubviews once bounds are |
| 626 | // real: the first anchor must run against actual geometry, and BEFORE |
| 627 | // first display — the reader finds the chat already landed, first |
| 628 | // frame, no settle pass to hide. |
| 629 | // |
| 630 | // ⚠️ This runs INSIDE a layout pass, so it must not re-enter layout: |
| 631 | // no scrollToItem, no layoutIfNeeded, no second super.layoutSubviews. |
| 632 | // The first cut did exactly that and UIKit trapped on recursive |
| 633 | // collection-view layout (the launch-day crash — MetricKit payloads |
| 634 | // 2026-07-20 19:25/19:26Z, six nested layout frames). Writing |
| 635 | // contentInset/contentOffset directly only DIRTIES layout for the |
| 636 | // same commit — bounded, the mechanism working as designed. |
| 637 | fileprivate func performInitialAnchor() { |
| 638 | needsInitialAnchor = false |
| 639 | updateInsets() |
| 640 | if let cutoff = unreadCutoff, |
| 641 | let target = current.first(where: { $0.at > cutoff && !$0.mine }), |
| 642 | let ip = dataSource.indexPath(for: .message(target.id)) { |
| 643 | // First unread at the TOP (just under the title pill), so |
| 644 | // reading continues where it left off — Messages-style. |
| 645 | // Flipped: the visual top is the item's frame END. The frame |
| 646 | // here can still be an estimate for a deep target, so one |
| 647 | // exact correction runs after the pass, outside layout. |
| 648 | if let attr = collectionView.layoutAttributesForItem(at: ip) { |
| 649 | setOffsetShowing(flippedMaxY: attr.frame.maxY) |
| 650 | } |
| 651 | DispatchQueue.main.async { [weak self] in |
| 652 | guard let self, let ip = self.dataSource.indexPath(for: .message(target.id)) |
| 653 | else { return } |
| 654 | self.collectionView.scrollToItem(at: ip, at: .bottom, animated: false) |
| 655 | self.lastGap = self.gapFromBottom() |
| 656 | } |
| 657 | } else { |
| 658 | collectionView.contentOffset.y = -collectionView.contentInset.top |
| 659 | } |
| 660 | unreadCutoff = nil |
| 661 | lastGap = gapFromBottom() |
| 662 | let count = current.count |
| 663 | let cb = onFirstLayout |
| 664 | // Deferred a turn so the screen's onAppear (which starts the |
| 665 | // mount span) has run whichever order SwiftUI commits them. |
| 666 | DispatchQueue.main.async { cb?(count) } |
| 667 | } |
| 668 | |
| 669 | // Position the viewport so the given flipped-space y (an item's frame |
| 670 | // end = its visual top edge) sits at the visual top of the feed. |
| 671 | private func setOffsetShowing(flippedMaxY: CGFloat) { |
| 672 | let cv = collectionView! |
| 673 | let target = flippedMaxY - cv.bounds.height + cv.contentInset.bottom |
| 674 | let minY = -cv.contentInset.top |
| 675 | cv.contentOffset.y = max(target, minY) |
| 676 | } |
| 677 | |
| 678 | #if DEBUG |
| 679 | // ---- feed-dump (diagnostic) ---------------------------------------- |
| 680 | // Writes the live UIKit truth — the collection view's metrics, every |
| 681 | // visible cell's frame/alpha/transform, and every ancestor + sibling |
| 682 | // up to the window — to Library/Logs/feed-dump.txt every few seconds |
| 683 | // while the feed is on screen. Pullable over devicectl, so a "why |
| 684 | // does it LOOK like that" report becomes numbers instead of theories. |
| 685 | private var dumpTimer: Timer? |
| 686 | |
| 687 | fileprivate func setDumping(_ on: Bool) { |
| 688 | dumpTimer?.invalidate() |
| 689 | dumpTimer = nil |
| 690 | guard on else { return } |
| 691 | let t = Timer(timeInterval: 4, repeats: true) { [weak self] _ in |
| 692 | MainActor.assumeIsolated { self?.writeDump() } |
| 693 | } |
| 694 | RunLoop.main.add(t, forMode: .common) |
| 695 | dumpTimer = t |
| 696 | } |
| 697 | |
| 698 | private func viewLine(_ v: UIView, marker: Bool = false) -> String { |
| 699 | let f = v.frame |
| 700 | let bg = v.backgroundColor.map { bg in |
| 701 | var w: CGFloat = 0, a: CGFloat = 0 |
| 702 | bg.getWhite(&w, alpha: &a) |
| 703 | return String(format: "bg(w%.2f,a%.2f)", w, a) |
| 704 | } ?? "bg(nil)" |
| 705 | return String(format: "%@%@ %.0f,%.0f %.0fx%.0f alpha=%.2f opac=%.2f %@%@%@ ty.d=%.0f", |
| 706 | marker ? "→ " : " ", String(describing: type(of: v)), |
| 707 | f.origin.x, f.origin.y, f.width, f.height, |
| 708 | v.alpha, v.layer.opacity, bg, |
| 709 | v.isHidden ? " HIDDEN" : "", |
| 710 | v.clipsToBounds ? " clips" : "", |
| 711 | v.transform.d) |
| 712 | } |
| 713 | |
| 714 | private func writeDump() { |
| 715 | guard let cv = collectionView, let window = cv.window else { return } |
| 716 | var out: [String] = ["== feed dump \(Date()) =="] |
| 717 | out.append("window \(window.bounds.size) safeArea=\(window.safeAreaInsets)") |
| 718 | out.append("cv frame(win)=\(cv.convert(cv.bounds, to: window)) inset=\(cv.contentInset) " |
| 719 | + "offset=\(cv.contentOffset.y) contentH=\(cv.contentSize.height) " |
| 720 | + "gap=\(gapFromBottom()) lastGap=\(lastGap)") |
| 721 | out.append("config top=\(config.topOverlay) bottom=\(config.bottomOverlay) " |
| 722 | + "kb=\(keyboardOverlap) focused=\(config.composerFocused)") |
| 723 | out.append("-- visible cells (item: frame-in-cv alpha/opacity) --") |
| 724 | for ip in cv.indexPathsForVisibleItems.sorted(by: { $0.item < $1.item }) { |
| 725 | guard let cell = cv.cellForItem(at: ip) else { continue } |
| 726 | let host = cell.contentView.subviews.first |
| 727 | out.append(String(format: " #%d y=%.0f h=%.0f cell(a=%.2f o=%.2f) content(ty.d=%.0f a=%.2f) host=%@ hostA=%.2f", |
| 728 | ip.item, cell.frame.origin.y, cell.frame.height, |
| 729 | cell.alpha, cell.layer.opacity, |
| 730 | cell.contentView.transform.d, cell.contentView.alpha, |
| 731 | host.map { String(describing: type(of: $0)) } ?? "none", |
| 732 | host?.alpha ?? -1)) |
| 733 | } |
| 734 | out.append("-- ancestors + siblings (window-ward) --") |
| 735 | var v: UIView = cv |
| 736 | while let sup = v.superview { |
| 737 | for sib in sup.subviews { |
| 738 | out.append(viewLine(sib, marker: sib === v)) |
| 739 | } |
| 740 | out.append(" ^ parent " + viewLine(sup)) |
| 741 | v = sup |
| 742 | } |
| 743 | let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] |
| 744 | .appendingPathComponent("Logs", isDirectory: true) |
| 745 | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 746 | try? out.joined(separator: "\n") |
| 747 | .write(to: dir.appendingPathComponent("feed-dump.txt"), |
| 748 | atomically: true, encoding: .utf8) |
| 749 | } |
| 750 | #endif |
| 751 | |
| 752 | fileprivate func boundsChanged() { |
| 753 | // Called from layoutSubviews — the inset write waits a turn (see |
| 754 | // the contentSize KVO note). A pure height change needs no re-pin: |
| 755 | // the flipped origin keeps the newest message glued to the bottom |
| 756 | // edge through resizes on its own. |
| 757 | queueInsetUpdate() |
| 758 | } |
| 759 | } |
| 760 | |
| 761 | // The counter-flip lives on the CELL, enforced after every layout pass: |
| 762 | // assigning a contentConfiguration REPLACES the cell's contentView, so a |
| 763 | // transform set in the registration handler silently vanishes on the next |
| 764 | // (re)configure — rows then render upside down, and the mixed-orientation |
| 765 | // overlap reads as a ghosted, washed-out feed. layoutSubviews runs after |
| 766 | // every configuration swap, so the flip always lands on the CURRENT |
| 767 | // content view. (The cell's own transform can't be used instead: the |
| 768 | // layout stamps attribute transforms over it.) |
| 769 | final class FlippedCell: UICollectionViewCell { |
| 770 | override func layoutSubviews() { |
| 771 | super.layoutSubviews() |
| 772 | if contentView.transform.d != -1 { |
| 773 | contentView.transform = CGAffineTransform(scaleX: 1, y: -1) |
| 774 | } |
| 775 | } |
| 776 | } |
| 777 | |
| 778 | // The flipped collection view itself: initial-anchor timing + the |
| 779 | // VoiceOver ordering tax (index order is newest→oldest; visual order is |
| 780 | // oldest→newest, and VoiceOver must read visually). |
| 781 | final class FlippedCollectionView: UICollectionView { |
| 782 | weak var coordinator: Coordinator? |
| 783 | private var lastSize: CGSize = .zero |
| 784 | |
| 785 | override func didMoveToWindow() { |
| 786 | super.didMoveToWindow() |
| 787 | #if DEBUG |
| 788 | coordinator?.setDumping(window != nil) |
| 789 | #endif |
| 790 | } |
| 791 | |
| 792 | override func layoutSubviews() { |
| 793 | super.layoutSubviews() |
| 794 | if bounds.size != lastSize { |
| 795 | let hadSize = lastSize != .zero |
| 796 | lastSize = bounds.size |
| 797 | if hadSize { coordinator?.boundsChanged() } |
| 798 | } |
| 799 | // The initial anchor is non-reentrant BY CONTRACT (see its ⚠️): |
| 800 | // it only writes insets/offset, which dirty layout for this same |
| 801 | // commit — never a forced nested pass. |
| 802 | if bounds.height > 0, coordinator?.needsInitialAnchor == true { |
| 803 | coordinator?.performInitialAnchor() |
| 804 | } |
| 805 | } |
| 806 | |
| 807 | // Visible cells, oldest first (higher item index = older = visually |
| 808 | // higher), so VoiceOver reads top-to-bottom like every other feed. |
| 809 | override var accessibilityElements: [Any]? { |
| 810 | get { |
| 811 | indexPathsForVisibleItems |
| 812 | .sorted { $0.item > $1.item } |
| 813 | .compactMap { cellForItem(at: $0) } |
| 814 | } |
| 815 | set {} |
| 816 | } |
| 817 | } |
| 818 | } |
| 819 | |
| 820 | // ---- rows (the SwiftUI content hosted in each cell) ------------------------- |
| 821 | |
| 822 | |
| 823 | struct FeedMessageRow: View { |
| 824 | let message: ChatFeedModel.Message |
| 825 | let previous: ChatFeedModel.Message? |
| 826 | let hooks: FeedHooks |
| 827 | @ObservedObject var interaction: FeedInteraction |
| 828 | // Ladder rung 4 drops the long-press menu/actions; 0 (full) includes it. |
| 829 | |
| 830 | // The times-peek shift: just past the WIDEST localized timestamp plus a |
| 831 | // Messages-tight gap — a fixed 64 left a ~30pt dead channel between the |
| 832 | // bubble edge and the time (Paul's 2026-07-21 screenshot comparison). |
| 833 | // Measured from a 20:55 sample so both "20:55" and "8:55 PM" locales fit. |
| 834 | static let timesGutter: CGFloat = { |
| 835 | var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) |
| 836 | c.hour = 20; c.minute = 55 |
| 837 | let d = Calendar.current.date(from: c) ?? Date() |
| 838 | let s = chatTime(Int64(d.timeIntervalSince1970)) |
| 839 | let w = (s as NSString).size(withAttributes: |
| 840 | [.font: UIFont.systemFont(ofSize: 12)]).width |
| 841 | return ceil(max(w, 28)) + 8 |
| 842 | }() |
| 843 | |
| 844 | var body: some View { |
| 845 | let sep = timeSeparator(at: message.at, previous: previous?.at) |
| 846 | let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, message) |
| 847 | // The wrapper VStack exists only when a separator does — a one-child |
| 848 | // stack still runs the whole StackLayout pass per recycle (the |
| 849 | // profile pad's hot path), and most rows carry no separator. |
| 850 | if let sep { |
| 851 | VStack(spacing: 0) { |
| 852 | separator(sep, at: message.at) |
| 853 | rowBody(startsRun: startsRun) |
| 854 | } |
| 855 | } else { |
| 856 | rowBody(startsRun: startsRun) |
| 857 | } |
| 858 | } |
| 859 | |
| 860 | @ViewBuilder private func rowBody(startsRun: Bool) -> some View { |
| 861 | if message.action { |
| 862 | Text(message.text) |
| 863 | .font(.system(size: 13)) |
| 864 | .foregroundStyle(.secondary) |
| 865 | .multilineTextAlignment(.center) |
| 866 | .frame(maxWidth: .infinity) |
| 867 | .padding(.vertical, 3) |
| 868 | .padding(.horizontal, 14) |
| 869 | } else if hooks.foldsAsMuted(message) { |
| 870 | // A muted member's message (#578): the quiet stub, revealed |
| 871 | // per-message for the session. Always a "them" row. |
| 872 | MutedMessageStub { hooks.revealMuted(message.id) } |
| 873 | .padding(.trailing, 40) |
| 874 | .frame(maxWidth: .infinity, alignment: .leading) |
| 875 | .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| 876 | bottom: 3, trailing: 12)) |
| 877 | } else { |
| 878 | bubbleRow(startsRun: startsRun) |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | private func bubbleRow(startsRun: Bool) -> some View { |
| 883 | MessageBubble(message: message, showSender: hooks.isGroup && startsRun, |
| 884 | senderName: hooks.senderName(message), |
| 885 | theme: .named(hooks.themeName), |
| 886 | onJump: hooks.onJump, |
| 887 | onOpenThread: hooks.onOpenThread, |
| 888 | blobSource: hooks.blobSource, |
| 889 | onImageTap: hooks.onImageTap, |
| 890 | onFileTap: hooks.onFileTap, |
| 891 | onVideoTap: hooks.onVideoTap, |
| 892 | downloading: interaction.blobDownload, |
| 893 | // The UIKit list owns the long-press menu (the |
| 894 | // coordinator's context-menu delegate); a SwiftUI |
| 895 | // .contextMenu in hosted cells never fired. |
| 896 | actions: nil, |
| 897 | unfurl: hooks.unfurl, |
| 898 | peekUnfurl: hooks.peekUnfurl) |
| 899 | .environment(\.chatFontSize, hooks.chatFontSize) |
| 900 | .environment(\.mentionIndex, hooks.mentionIndex()) |
| 901 | // A tapped @mention chip carries a MentionLink URL — intercept it |
| 902 | // into the profile card; every real link keeps the system route. |
| 903 | .environment(\.openURL, OpenURLAction { url in |
| 904 | guard let uid = MentionLink.uid(from: url) else { return .systemAction } |
| 905 | hooks.onMentionTap(uid) |
| 906 | return .handled |
| 907 | }) |
| 908 | // The far-side gap + speaking-side alignment as two unary layout |
| 909 | // nodes instead of HStack + Spacers: the profile pad's hot stacks |
| 910 | // were StackLayout.sizeThatFits recursion — every stack/spacer is |
| 911 | // a level, and this runs on every cell recycle (2026-07-21). |
| 912 | .padding(message.mine ? .leading : .trailing, 40) |
| 913 | .frame(maxWidth: .infinity, |
| 914 | alignment: message.mine ? .trailing : .leading) |
| 915 | // Web-parity breathing room: a touch more air between bubbles, |
| 916 | // more still at run starts. |
| 917 | .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12, |
| 918 | bottom: 3, trailing: 12)) |
| 919 | // Swipe-right quick-reply: the bubble follows the finger, a |
| 920 | // reply arrow fades in behind it, and a past-threshold release |
| 921 | // starts the reply (the web's touch gesture). Mine-side |
| 922 | // bubbles also shift left with the times peek. |
| 923 | .offset(x: (interaction.replyDrag[message.id] ?? 0) |
| 924 | - (message.mine ? interaction.timesReveal : 0)) |
| 925 | .overlay(alignment: .leading) { |
| 926 | let drag = interaction.replyDrag[message.id] ?? 0 |
| 927 | if drag > 0 { |
| 928 | // REVEALED by the swipe: the arrow rides in from behind the |
| 929 | // bubble's edge, tracking the finger — not parked at the |
| 930 | // margin waiting. |
| 931 | Image(systemName: "arrowshape.turn.up.left.fill") |
| 932 | .font(.system(size: 16)) |
| 933 | .foregroundStyle(drag >= 40 ? AnyShapeStyle(.tint) |
| 934 | : AnyShapeStyle(.tertiary)) |
| 935 | .opacity(Double(min(1, drag / 44))) |
| 936 | .scaleEffect(drag >= 40 ? 1 : 0.7 + 0.3 * min(1, drag / 40)) |
| 937 | .offset(x: drag - 40) |
| 938 | .allowsHitTesting(false) |
| 939 | } |
| 940 | } |
| 941 | // The times peek (Messages): dragging left slides a time in at |
| 942 | // the trailing edge — landing INSIDE the viewport with a |
| 943 | // margin — springing back on release. Built only while a drag |
| 944 | // is revealing it: chatTime's ICU formatting for every row on |
| 945 | // every body eval is pure waste the other 99% of the time. |
| 946 | .overlay(alignment: .trailing) { |
| 947 | if interaction.timesReveal > 0 { |
| 948 | Text(chatTime(message.at)) |
| 949 | .font(.system(size: 12)) |
| 950 | .foregroundStyle(.secondary) |
| 951 | .fixedSize() |
| 952 | .padding(.trailing, 8) |
| 953 | .offset(x: max(0, Self.timesGutter - interaction.timesReveal)) |
| 954 | .opacity(interaction.timesReveal / Self.timesGutter) |
| 955 | .allowsHitTesting(false) |
| 956 | // Removal rides the release spring, so letting go |
| 957 | // still slides the times away instead of blinking. |
| 958 | .transition(.move(edge: .trailing).combined(with: .opacity)) |
| 959 | } |
| 960 | } |
| 961 | // The swipe that drives replyDrag/timesReveal is a UIKit pan on the |
| 962 | // collection view (StockChatFeed.Coordinator.rowSwiped) — a SwiftUI |
| 963 | // DragGesture here claimed vertical flicks away from the scroll pan |
| 964 | // (the 2026-07-21 Contributors freeze). Rows just render the state. |
| 965 | .clipped() |
| 966 | // The jump wave, applied AFTER .clipped() so the wag never |
| 967 | // beheads at the row edge (shared keyframes: JumpWave in core). |
| 968 | .jumpWave(on: message.id == interaction.danceID, tick: interaction.danceTick) |
| 969 | // Optimistic send (web parity): the copy already shows instantly; once it |
| 970 | // has gone slow (5s unconfirmed) the bubble dims, with a non-visual cue |
| 971 | // for VoiceOver so the delay isn't conveyed by opacity alone. Outermost + |
| 972 | // additive, so it never disturbs the bubble's own layout. |
| 973 | .opacity(message.pending && message.slow ? 0.55 : 1) |
| 974 | .accessibilityHint(message.pending ? (message.slow ? "Still sending" : "Sending") : "") |
| 975 | } |
| 976 | |
| 977 | private func separator(_ sep: TimeSeparator, at seconds: Int64) -> some View { |
| 978 | Group { |
| 979 | if sep == .dated { |
| 980 | Text(chatDayLabel(seconds)).bold() + Text(" \(chatTime(seconds))") |
| 981 | } else { |
| 982 | Text(chatTime(seconds)) |
| 983 | } |
| 984 | } |
| 985 | .font(.system(size: 12)) |
| 986 | .foregroundStyle(.secondary) |
| 987 | .frame(maxWidth: .infinity) |
| 988 | // More air above a separator than below — it belongs to the messages |
| 989 | // that follow it, not the ones before. |
| 990 | .padding(.top, 18) |
| 991 | .padding(.bottom, 8) |
| 992 | } |
| 993 | } |
| 994 | |
| 995 | // Observes the shared interaction state on behalf of a subtree whose owner |
| 996 | // deliberately doesn't (NativeChatScreen holds FeedInteraction as plain |
| 997 | // @State so drags never re-render the whole screen; the reply-thread |
| 998 | // overlay's bubbles still need live download progress). |
| 999 | struct FeedInteractionReader<Content: View>: View { |
| 1000 | @ObservedObject var interaction: FeedInteraction |
| 1001 | @ViewBuilder let content: (FeedInteraction) -> Content |
| 1002 | var body: some View { content(interaction) } |
| 1003 | } |
| 1004 | |
| 1005 | // The web's typing bubble: bouncing dots in the feed, them-side. |
| 1006 | struct FeedTypingRow: View { |
| 1007 | let label: String? |
| 1008 | let themeName: String |
| 1009 | let chatFontSize: CGFloat |
| 1010 | |
| 1011 | var body: some View { |
| 1012 | HStack(spacing: 0) { |
| 1013 | TypingBubble(theme: .named(themeName), label: label) |
| 1014 | // The bubble sizes itself as one MESSAGE line — without the |
| 1015 | // feed's size it fell back to the default and outgrew small |
| 1016 | // text settings. |
| 1017 | .environment(\.chatFontSize, chatFontSize) |
| 1018 | Spacer(minLength: 40) |
| 1019 | } |
| 1020 | .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12)) |
| 1021 | } |
| 1022 | } |
| 1023 | |