diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift b/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift new file mode 100644 index 0000000..11726b9 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift @@ -0,0 +1,201 @@ +import UIKit + +// MARK: - ChatMessageListItem + +/// Bridges ChatMessage + pre-calculated layout to RosettaListView. +/// Telegram equivalent: ChatMessageItemImpl. +/// +/// Layout is pre-calculated by LayoutEngine (background thread). +/// This class just holds the results and creates/configures nodes. +final class ChatMessageListItem: RosettaListItem { + + let message: ChatMessage + let layout: MessageCellLayout + let textLayout: CoreTextTextLayout? + let actions: MessageCellActions + let replyName: String? + let replyText: String? + let replyMessageId: String? + let forwardSenderName: String? + let forwardSenderKey: String? + + // MARK: - RosettaListItem + + var id: String { message.id } + var approximateHeight: CGFloat { layout.totalHeight } + var reuseIdentifier: String { "msg" } + + init( + message: ChatMessage, + layout: MessageCellLayout, + textLayout: CoreTextTextLayout?, + actions: MessageCellActions, + replyName: String? = nil, + replyText: String? = nil, + replyMessageId: String? = nil, + forwardSenderName: String? = nil, + forwardSenderKey: String? = nil + ) { + self.message = message + self.layout = layout + self.textLayout = textLayout + self.actions = actions + self.replyName = replyName + self.replyText = replyText + self.replyMessageId = replyMessageId + self.forwardSenderName = forwardSenderName + self.forwardSenderKey = forwardSenderKey + } + + func nodeConfiguredForParams( + async: @escaping (@escaping () -> Void) -> Void, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void + ) { + let node = MessageListNode() + applyToNode(node) + let nodeLayout = RosettaListNodeLayout( + contentSize: CGSize(width: params.width, height: layout.totalHeight), + insets: .zero + ) + completion(node, nodeLayout) + } + + func updateNode( + async: @escaping (@escaping () -> Void) -> Void, + node: RosettaListNode, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + animation: RosettaListUpdateAnimation, + completion: @escaping (RosettaListNodeLayout) -> Void + ) { + if let messageNode = node as? MessageListNode { + applyToNode(messageNode) + } + let nodeLayout = RosettaListNodeLayout( + contentSize: CGSize(width: params.width, height: layout.totalHeight), + insets: .zero + ) + completion(nodeLayout) + } + + private func applyToNode(_ node: MessageListNode) { + node.cell.apply(layout: layout) + node.cell.configure( + message: message, + timestamp: layout.timestampText, + textLayout: textLayout, + actions: actions, + replyName: replyName, + replyText: replyText, + replyMessageId: replyMessageId, + forwardSenderName: forwardSenderName, + forwardSenderKey: forwardSenderKey + ) + } +} + +// MARK: - MessageListNode + +/// RosettaListNode containing a NativeMessageCell. +/// NativeMessageCell is UICollectionViewCell but works as plain UIView +/// when instantiated directly (not dequeued from UICollectionView). +final class MessageListNode: RosettaListNode { + + let cell = NativeMessageCell(frame: .zero) + + override init(frame: CGRect) { + super.init(frame: frame) + // UICollectionViewCell.contentView is the actual content container + cell.frame = bounds + cell.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentView.addSubview(cell.contentView) + } + + override func prepareForReuse() { + super.prepareForReuse() + cell.prepareForReuse() + } + + override func layoutSubviews() { + super.layoutSubviews() + cell.contentView.frame = contentView.bounds + cell.bounds = contentView.bounds + cell.layoutSubviews() + } +} + +// MARK: - UnreadSeparatorListItem + +/// Unread separator for RosettaListView. +final class UnreadSeparatorListItem: RosettaListItem { + + var id: String { "__unread_separator__" } + var approximateHeight: CGFloat { 25 } + var reuseIdentifier: String { "sep" } + + func nodeConfiguredForParams( + async: @escaping (@escaping () -> Void) -> Void, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void + ) { + let node = UnreadSeparatorNode() + let nodeLayout = RosettaListNodeLayout( + contentSize: CGSize(width: params.width, height: 25), + insets: .zero + ) + completion(node, nodeLayout) + } + + func updateNode( + async: @escaping (@escaping () -> Void) -> Void, + node: RosettaListNode, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + animation: RosettaListUpdateAnimation, + completion: @escaping (RosettaListNodeLayout) -> Void + ) { + completion(RosettaListNodeLayout( + contentSize: CGSize(width: params.width, height: 25), + insets: .zero + )) + } +} + +// MARK: - UnreadSeparatorNode + +final class UnreadSeparatorNode: RosettaListNode { + + private let bar: UIView = { + let v = UIView() + v.backgroundColor = UIColor(red: 0.16, green: 0.16, blue: 0.18, alpha: 1) + return v + }() + + private let label: UILabel = { + let l = UILabel() + l.text = "Unread Messages" + l.font = .systemFont(ofSize: 13, weight: .regular) + l.textColor = .white + l.textAlignment = .center + return l + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(bar) + bar.addSubview(label) + } + + override func layoutSubviews() { + super.layoutSubviews() + bar.frame = contentView.bounds + label.frame = bar.bounds + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListItem.swift b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListItem.swift new file mode 100644 index 0000000..024d585 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListItem.swift @@ -0,0 +1,139 @@ +import UIKit + +// MARK: - Layout Params + +/// Parameters for layout calculation (screen width, insets). +/// Telegram equivalent: `ListViewItemLayoutParams`. +struct RosettaListLayoutParams: Sendable { + let width: CGFloat + let leftInset: CGFloat + let rightInset: CGFloat +} + +// MARK: - Node Layout + +/// Calculated layout result — content size + insets. +/// Telegram equivalent: `ListViewItemNodeLayout`. +struct RosettaListNodeLayout { + let contentSize: CGSize + let insets: UIEdgeInsets + + var size: CGSize { + CGSize( + width: contentSize.width + insets.left + insets.right, + height: contentSize.height + insets.top + insets.bottom + ) + } +} + +// MARK: - Update Animation + +/// Animation type for node updates. +/// Telegram equivalent: `ListViewItemUpdateAnimation`. +enum RosettaListUpdateAnimation { + case none + case system(duration: Double, transition: ControlledTransitionConfig) +} + +struct ControlledTransitionConfig { + let duration: Double + let curve: AnimationCurve + + enum AnimationCurve { + case spring(damping: CGFloat) + case easeInOut + } + + static let `default` = ControlledTransitionConfig( + duration: 0.4, + curve: .spring(damping: 0.78) + ) +} + +// MARK: - Direction Hint + +/// Hint for insertion/deletion animation direction. +/// Telegram equivalent: `ListViewItemOperationDirectionHint`. +enum RosettaListDirectionHint { + case up + case down +} + +// MARK: - Scroll Position + +/// Target scroll position for programmatic scrolling. +/// Telegram equivalent: `ListViewScrollPosition`. +enum RosettaListScrollPosition { + case top(CGFloat) + case center + case bottom(CGFloat) + case visible +} + +// MARK: - List Item Protocol + +/// Data model protocol for list items. +/// Telegram equivalent: `ListViewItem` (ListViewItem.swift:72-106). +/// +/// Two-phase async layout pattern: +/// 1. `nodeConfiguredForParams` — creates node + calculates layout on background +/// 2. Completion delivers ready-to-display node + layout to main thread +protocol RosettaListItem: AnyObject { + /// Unique identifier for diffing. + var id: String { get } + + /// Estimated height before layout calculation. Used for scroll position + /// estimation when exact layout isn't ready yet. + /// Telegram: `approximateHeight` property. + var approximateHeight: CGFloat { get } + + /// Reuse identifier for node pool recycling. + var reuseIdentifier: String { get } + + /// Create a new node and calculate its layout. + /// `async` closure schedules work on background queue. + /// Telegram: `nodeConfiguredForParams` (ListViewItem.swift:72-75). + func nodeConfiguredForParams( + async: @escaping (@escaping () -> Void) -> Void, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void + ) + + /// Update an existing (recycled) node with new data. + /// Telegram: `updateNode` (ListViewItem.swift:78-88). + func updateNode( + async: @escaping (@escaping () -> Void) -> Void, + node: RosettaListNode, + params: RosettaListLayoutParams, + previousItem: RosettaListItem?, + nextItem: RosettaListItem?, + animation: RosettaListUpdateAnimation, + completion: @escaping (RosettaListNodeLayout) -> Void + ) +} + +// MARK: - Insert/Delete/Update Operations + +/// Batch operations for list updates. +/// Telegram equivalent: `ListViewInsertItem`, `ListViewDeleteItem`, `ListViewUpdateItem`. + +struct RosettaListInsertItem { + let index: Int + let previousIndex: Int? + let item: RosettaListItem + let directionHint: RosettaListDirectionHint? +} + +struct RosettaListDeleteItem { + let index: Int + let directionHint: RosettaListDirectionHint? +} + +struct RosettaListUpdateItem { + let index: Int + let previousIndex: Int + let item: RosettaListItem + let directionHint: RosettaListDirectionHint? +} diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListNode.swift b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListNode.swift new file mode 100644 index 0000000..06b15ab --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListNode.swift @@ -0,0 +1,208 @@ +import UIKit +import QuartzCore + +// MARK: - RosettaListNode + +/// Base class for list view cells. Manages its own animations. +/// Telegram equivalent: `ListViewItemNode` (ListViewItemNode.swift). +/// +/// Each node tracks: +/// - Its index in the data source +/// - Active animations (height, alpha, position) +/// - Layout information +/// +/// Subclass or wrap existing UIView (e.g., NativeMessageCell) in this. +class RosettaListNode: UIView { + + // MARK: - State + + /// Index in the data source. nil = pending removal. + var index: Int? + + /// Reuse identifier for node pool. + var reuseIdentifier: String = "" + + /// Current layout (set by RosettaListView after layout calculation). + private(set) var layout: RosettaListNodeLayout? + + /// The content view — subclasses add their UI here. + /// Using a content view allows the node to animate height independently. + let contentView = UIView() + + // MARK: - Animation State + + /// Animated height (used during insertion/deletion). + /// When nil, uses layout.size.height. + var apparentHeight: CGFloat? + + /// Active animations managed by the display link loop. + private(set) var activeAnimations: [NodeAnimation] = [] + + /// Whether this node has pending animations. + var hasActiveAnimations: Bool { !activeAnimations.isEmpty } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.frame = bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(contentView) + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + + func applyLayout(_ layout: RosettaListNodeLayout) { + self.layout = layout + } + + /// Effective height for positioning. + /// During animations, uses animating height. Otherwise uses layout height. + var effectiveHeight: CGFloat { + if let apparentHeight { return apparentHeight } + return layout?.size.height ?? 50 + } + + // MARK: - Prepare for Reuse + + /// Called when node is returned to pool. Reset transient state. + func prepareForReuse() { + index = nil + apparentHeight = nil + activeAnimations.removeAll() + alpha = 1 + layer.removeAllAnimations() + } + + // MARK: - Animations + + /// Add height animation (for insertion: 0 → target, for deletion: current → 0). + /// Telegram: `addApparentHeightAnimation` (ListViewItemNode.swift:132-147). + func addHeightAnimation( + from: CGFloat, + to: CGFloat, + duration: Double, + beginAt: CFTimeInterval, + curve: NodeAnimation.Curve = .spring(damping: 0.78) + ) { + apparentHeight = from + let animation = NodeAnimation( + property: .height, + from: from, + to: to, + duration: duration, + beginAt: beginAt, + curve: curve + ) + activeAnimations.append(animation) + } + + /// Add alpha animation (for insertion: 0 → 1). + func addAlphaAnimation( + from: CGFloat, + to: CGFloat, + duration: Double, + beginAt: CFTimeInterval + ) { + alpha = from + let animation = NodeAnimation( + property: .alpha, + from: from, + to: to, + duration: duration, + beginAt: beginAt, + curve: .easeInOut + ) + activeAnimations.append(animation) + } + + /// Update animations at current timestamp. Returns true if any animation is active. + @discardableResult + func updateAnimations(at timestamp: CFTimeInterval) -> Bool { + var i = activeAnimations.count - 1 + while i >= 0 { + let anim = activeAnimations[i] + let progress = anim.progress(at: timestamp) + + let value = anim.interpolatedValue(progress: progress) + + switch anim.property { + case .height: + apparentHeight = value + case .alpha: + alpha = value + case .offsetY: + // Handled by parent during layout pass + break + } + + if progress >= 1.0 { + // Animation complete + switch anim.property { + case .height: + apparentHeight = nil // Revert to layout height + case .alpha: + break + case .offsetY: + break + } + activeAnimations.remove(at: i) + } + i -= 1 + } + return !activeAnimations.isEmpty + } +} + +// MARK: - Node Animation + +/// Animation state for a single property. +/// Telegram uses spring physics (ListViewItemNode.swift:28-39). +struct NodeAnimation { + enum Property { + case height + case alpha + case offsetY + } + + enum Curve { + case spring(damping: CGFloat) + case easeInOut + case linear + } + + let property: Property + let from: CGFloat + let to: CGFloat + let duration: Double + let beginAt: CFTimeInterval + let curve: Curve + + func progress(at timestamp: CFTimeInterval) -> Double { + let elapsed = timestamp - beginAt + return min(1.0, max(0.0, elapsed / duration)) + } + + func interpolatedValue(progress: Double) -> CGFloat { + let t: CGFloat + switch curve { + case .spring(let damping): + // Underdamped spring approximation (Telegram: testSpringFriction/Constant) + let omega = CGFloat.pi * 2 / CGFloat(duration) + let decay = exp(-damping * CGFloat(progress) * omega * 0.1) + t = 1.0 - decay * cos(omega * CGFloat(progress) * 0.5) + return from + (to - from) * min(1, max(0, t)) + case .easeInOut: + // Cubic ease-in-out + let p = CGFloat(progress) + t = p < 0.5 ? 2 * p * p : 1 - pow(-2 * p + 2, 2) / 2 + return from + (to - from) * t + case .linear: + return from + (to - from) * CGFloat(progress) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListView.swift b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListView.swift new file mode 100644 index 0000000..a830f4a --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaListView.swift @@ -0,0 +1,629 @@ +import UIKit +import QuartzCore +import os + +// MARK: - RosettaListView + +/// Custom high-performance list view for chat messages. +/// Telegram-parity replacement for UICollectionView. +/// +/// Key differences from UICollectionView: +/// - Manual node management (addSubview/removeFromSuperview) +/// - Frame-based positioning (no autolayout) +/// - Async layout calculation (non-blocking main thread) +/// - Visibility culling (only ~30 nodes in memory) +/// - Inverted scroll (newest messages at bottom) +/// - CADisplayLink-driven animations +/// +/// Telegram source: Display/Source/ListView.swift (5358 lines) +@MainActor +final class RosettaListView: UIView, UIScrollViewDelegate { + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ListView") + + // MARK: - Configuration + + /// Preload buffer above/below viewport. Nodes outside this range are culled. + /// Telegram: `invisibleInset = 500.0` (ListView.swift:223). + var invisibleInset: CGFloat = 500.0 + + /// Stack items from bottom (chat mode). Newest items at visual bottom. + /// Telegram: `stackFromBottom` (ListView.swift:240). + var stackFromBottom: Bool = true + + /// Virtual scroll height. Large enough to scroll freely in both directions. + /// Telegram: `infiniteScrollSize = 10000.0`. + private let infiniteScrollSize: CGFloat = 10000.0 + + // MARK: - Callbacks + + /// Called when visible item range changes (for pagination triggers). + var displayedItemRangeChanged: ((_ range: ClosedRange?) -> Void)? + + /// Called when scroll position changes. + var onScroll: ((_ offset: CGFloat, _ isDragging: Bool) -> Void)? + + /// Called when user taps empty area. + var onTapBackground: (() -> Void)? + + // MARK: - State + + /// Data source items. + private(set) var items: [RosettaListItem] = [] + + /// Currently displayed nodes (ordered by visual position, bottom to top in inverted mode). + private(set) var visibleNodes: [RosettaListNode] = [] + + /// Node recycling pool keyed by reuse identifier. + private var nodePool: [String: [RosettaListNode]] = [:] + + /// Maps item ID → index for O(1) lookup. + private var itemIdToIndex: [String: Int] = [:] + + /// Transaction queue for serialized updates. + let transactionQueue = RosettaTransactionQueue() + + /// Layout params (computed from view width). + private var layoutParams: RosettaListLayoutParams { + RosettaListLayoutParams( + width: bounds.width, + leftInset: safeAreaInsets.left, + rightInset: safeAreaInsets.right + ) + } + + // MARK: - Insets + + /// Content insets (keyboard, composer, etc.). + var contentInsets: UIEdgeInsets = .zero { + didSet { + guard contentInsets != oldValue else { return } + let delta = contentInsets.bottom - oldValue.bottom + scroller.contentInset = contentInsets + + // Compensate offset for bottom inset changes (keyboard) + if delta > 0, !scroller.isDragging, !scroller.isDecelerating { + var offset = scroller.contentOffset + offset.y += delta + scroller.contentOffset = offset + } + } + } + + // MARK: - Subviews + + /// The scroll view that handles touch/scroll physics. + private let scroller: UIScrollView = { + let sv = UIScrollView() + sv.scrollsToTop = false + sv.delaysContentTouches = false + sv.canCancelContentTouches = true + sv.showsVerticalScrollIndicator = true + sv.showsHorizontalScrollIndicator = false + sv.alwaysBounceVertical = true + return sv + }() + + /// Container inside scroller for node views. + private let nodeContainer = UIView() + + // MARK: - Display Link + + private var displayLink: CADisplayLink? + private var needsAnimationUpdate = false + + // MARK: - Scroll Tracking + + /// Anchor point for maintaining scroll position during layout changes. + private var anchorItemId: String? + private var anchorOffset: CGFloat = 0 + + /// Whether initial content has been laid out. + private var hasLaidOutInitialContent = false + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupDisplayLink() + setupGestures() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + deinit { + displayLink?.invalidate() + transactionQueue.cancelAll() + } + + // MARK: - Setup + + private func setupViews() { + scroller.delegate = self + scroller.frame = bounds + scroller.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(scroller) + + // Set initial content size for infinite scroll + scroller.contentSize = CGSize(width: bounds.width, height: infiniteScrollSize * 2) + + nodeContainer.frame = CGRect(origin: .zero, size: scroller.contentSize) + scroller.addSubview(nodeContainer) + } + + private func setupDisplayLink() { + displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired)) + displayLink?.add(to: .main, forMode: .common) + displayLink?.isPaused = true + } + + private func setupGestures() { + let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped)) + tap.cancelsTouchesInView = false + scroller.addGestureRecognizer(tap) + } + + override func layoutSubviews() { + super.layoutSubviews() + let oldWidth = scroller.frame.width + scroller.frame = bounds + if bounds.width != oldWidth { + scroller.contentSize.width = bounds.width + nodeContainer.frame.size.width = bounds.width + invalidateAllLayouts() + } + } + + // MARK: - Public API: Set Items (Full Replace) + + /// Replace all items with a new list. Calculates layouts async. + /// Telegram: `transaction()` with deleteItems + insertItems. + func setItems(_ newItems: [RosettaListItem], animated: Bool = false) { + transactionQueue.enqueue { [weak self] completion in + guard let self else { completion(); return } + self.performSetItems(newItems, animated: animated, completion: completion) + } + } + + /// Insert items at specific indices. + func insertItems(_ inserts: [RosettaListInsertItem], animated: Bool = true) { + transactionQueue.enqueue { [weak self] completion in + guard let self else { completion(); return } + self.performInsertItems(inserts, animated: animated, completion: completion) + } + } + + // MARK: - Scroll API + + /// Scroll to bottom (newest messages in inverted mode). + /// Telegram: `scrollToEndOfHistory()` (ChatHistoryListNode.swift:3494). + func scrollToBottom(animated: Bool) { + let targetY: CGFloat + if stackFromBottom { + targetY = infiniteScrollSize - bounds.height + contentInsets.bottom + } else { + targetY = -contentInsets.top + } + scroller.setContentOffset(CGPoint(x: 0, y: targetY), animated: animated) + } + + /// Scroll to item with given ID. + func scrollToItem(id: String, position: RosettaListScrollPosition = .center, animated: Bool = true) { + guard let node = visibleNodes.first(where: { $0.index.flatMap({ items[$0].id }) == id }) else { return } + let nodeFrame = node.frame + let targetY: CGFloat + switch position { + case .top(let offset): + targetY = nodeFrame.minY - contentInsets.top - offset + case .center: + targetY = nodeFrame.midY - bounds.height / 2 + case .bottom(let offset): + targetY = nodeFrame.maxY - bounds.height + contentInsets.bottom + offset + case .visible: + let visibleRect = CGRect( + x: 0, y: scroller.contentOffset.y + contentInsets.top, + width: bounds.width, + height: bounds.height - contentInsets.top - contentInsets.bottom + ) + if visibleRect.contains(nodeFrame) { return } + targetY = nodeFrame.midY - bounds.height / 2 + } + scroller.setContentOffset(CGPoint(x: 0, y: targetY), animated: animated) + } + + // MARK: - Node Pool + + private func dequeueNode(reuseId: String) -> RosettaListNode? { + nodePool[reuseId]?.popLast() + } + + private func enqueueNode(_ node: RosettaListNode) { + node.prepareForReuse() + node.removeFromSuperview() + nodePool[node.reuseIdentifier, default: []].append(node) + } + + // MARK: - Internal: Set Items + + private func performSetItems( + _ newItems: [RosettaListItem], + animated: Bool, + completion: @escaping () -> Void + ) { + // Remove all existing nodes + for node in visibleNodes { + enqueueNode(node) + } + visibleNodes.removeAll() + + items = newItems + rebuildIdIndex() + + guard !items.isEmpty else { + completion() + return + } + + // Calculate layouts for visible window + buffer + let params = layoutParams + let visibleRange = estimateVisibleRange() + + var pendingLayouts = visibleRange.count + guard pendingLayouts > 0 else { completion(); return } + + for i in visibleRange { + let item = items[i] + let prevItem = i > 0 ? items[i - 1] : nil + let nextItem = i < items.count - 1 ? items[i + 1] : nil + + item.nodeConfiguredForParams( + async: { f in + // For initial load: synchronous to avoid blank screen + f() + }, + params: params, + previousItem: prevItem, + nextItem: nextItem + ) { [weak self] node, layout in + guard let self else { return } + node.index = i + node.reuseIdentifier = item.reuseIdentifier + node.applyLayout(layout) + self.visibleNodes.append(node) + + pendingLayouts -= 1 + if pendingLayouts == 0 { + self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) } + self.layoutVisibleNodes() + self.addVisibleNodesToView() + if !self.hasLaidOutInitialContent { + self.hasLaidOutInitialContent = true + if self.stackFromBottom { + self.scrollToBottom(animated: false) + } + } + completion() + } + } + } + } + + // MARK: - Internal: Insert Items + + private func performInsertItems( + _ inserts: [RosettaListInsertItem], + animated: Bool, + completion: @escaping () -> Void + ) { + let timestamp = CACurrentMediaTime() + + // Update items array + let sorted = inserts.sorted { $0.index < $1.index } + for insert in sorted.reversed() { + items.insert(insert.item, at: min(insert.index, items.count)) + } + rebuildIdIndex() + + // Re-index existing nodes + for node in visibleNodes { + if let oldIndex = node.index, + let id = findItemId(for: node), + let newIndex = itemIdToIndex[id] { + node.index = newIndex + } + } + + let params = layoutParams + + var pendingLayouts = sorted.count + guard pendingLayouts > 0 else { completion(); return } + + for insert in sorted { + let i = min(insert.index, items.count - 1) + let item = items[i] + let prevItem = i > 0 ? items[i - 1] : nil + let nextItem = i < items.count - 1 ? items[i + 1] : nil + + item.nodeConfiguredForParams( + async: { f in f() }, + params: params, + previousItem: prevItem, + nextItem: nextItem + ) { [weak self] node, layout in + guard let self else { return } + node.index = i + node.reuseIdentifier = item.reuseIdentifier + node.applyLayout(layout) + + if animated { + let targetHeight = layout.size.height + node.addHeightAnimation( + from: 0, + to: targetHeight, + duration: 0.4, + beginAt: timestamp + ) + node.addAlphaAnimation(from: 0, to: 1, duration: 0.2, beginAt: timestamp) + self.setNeedsAnimationUpdate() + } + + self.visibleNodes.append(node) + + pendingLayouts -= 1 + if pendingLayouts == 0 { + self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) } + self.layoutVisibleNodes() + self.addVisibleNodesToView() + completion() + } + } + } + } + + // MARK: - Layout Engine + + /// Position all visible nodes based on their layout heights. + /// Inverted mode: items stack from bottom of visible area upward. + private func layoutVisibleNodes() { + guard !visibleNodes.isEmpty else { return } + + if stackFromBottom { + // Bottom-to-top: newest item at bottom, oldest at top. + // Reference point: bottom of visible area = infiniteScrollSize + var y = infiniteScrollSize + for node in visibleNodes.reversed() { + let h = node.effectiveHeight + y -= h + node.frame = CGRect(x: 0, y: y, width: bounds.width, height: h) + node.contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: node.layout?.size.height ?? h) + } + } else { + var y: CGFloat = 0 + for node in visibleNodes { + let h = node.effectiveHeight + node.frame = CGRect(x: 0, y: y, width: bounds.width, height: h) + node.contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: h) + y += h + } + } + } + + /// Add visible nodes to the node container (if not already added). + private func addVisibleNodesToView() { + for node in visibleNodes { + if node.superview !== nodeContainer { + nodeContainer.addSubview(node) + } + } + } + + // MARK: - Visibility Culling + + /// Remove nodes outside the visible area + buffer. + /// Telegram: `removeInvisibleNodes()` (ListViewIntermediateState.swift:615-692). + private func performVisibilityCulling() { + let visibleTop = scroller.contentOffset.y - invisibleInset + let visibleBottom = scroller.contentOffset.y + bounds.height + invisibleInset + + var i = visibleNodes.count - 1 + while i >= 0 { + let node = visibleNodes[i] + let frame = node.frame + if frame.maxY < visibleTop || frame.minY > visibleBottom { + enqueueNode(node) + visibleNodes.remove(at: i) + } + i -= 1 + } + + // Load nodes that should be visible but aren't + loadMissingVisibleNodes() + } + + /// Check if there are items that should be visible but don't have nodes. + private func loadMissingVisibleNodes() { + guard !items.isEmpty else { return } + + let visibleTop = scroller.contentOffset.y - invisibleInset + let visibleBottom = scroller.contentOffset.y + bounds.height + invisibleInset + + // Determine which indices should be visible based on approximate heights + let existingIndices = Set(visibleNodes.compactMap(\.index)) + + // Find range boundaries from existing nodes + guard let minVisibleIndex = visibleNodes.compactMap(\.index).min(), + let maxVisibleIndex = visibleNodes.compactMap(\.index).max() else { return } + + // Check items above current visible range + if minVisibleIndex > 0 { + let topNode = visibleNodes.first(where: { $0.index == minVisibleIndex }) + if let topNode, topNode.frame.minY > visibleTop { + // Need to load items above + let startIndex = max(0, minVisibleIndex - 5) // Load 5 at a time + for i in startIndex..= 0, index < items.count else { return } + let item = items[index] + let params = layoutParams + let prevItem = index > 0 ? items[index - 1] : nil + let nextItem = index < items.count - 1 ? items[index + 1] : nil + + item.nodeConfiguredForParams( + async: { f in + if synchronous { f() } + else { DispatchQueue.global(qos: .userInitiated).async { f() } } + }, + params: params, + previousItem: prevItem, + nextItem: nextItem + ) { [weak self] node, layout in + guard let self else { return } + node.index = index + node.reuseIdentifier = item.reuseIdentifier + node.applyLayout(layout) + self.visibleNodes.append(node) + self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) } + self.layoutVisibleNodes() + self.addVisibleNodesToView() + } + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + performVisibilityCulling() + notifyVisibleRangeChanged() + onScroll?(scrollView.contentOffset.y, scrollView.isDragging) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // Capture anchor for position restoration + captureScrollAnchor() + } + + // MARK: - Display Link + + @objc private func displayLinkFired() { + let timestamp = CACurrentMediaTime() + var anyActive = false + + for node in visibleNodes { + if node.updateAnimations(at: timestamp) { + anyActive = true + } + } + + if anyActive { + layoutVisibleNodes() + } else { + displayLink?.isPaused = true + needsAnimationUpdate = false + // Clean up completed deletion animations + visibleNodes.removeAll { $0.index == nil && !$0.hasActiveAnimations } + } + } + + private func setNeedsAnimationUpdate() { + if !needsAnimationUpdate { + needsAnimationUpdate = true + displayLink?.isPaused = false + } + } + + // MARK: - Helpers + + private func rebuildIdIndex() { + itemIdToIndex = Dictionary(uniqueKeysWithValues: items.enumerated().map { ($1.id, $0) }) + } + + private func findItemId(for node: RosettaListNode) -> String? { + guard let index = node.index, index < items.count else { return nil } + return items[index].id + } + + private func estimateVisibleRange() -> Range { + guard !items.isEmpty else { return 0..<0 } + // For initial load, estimate based on approximate heights + // Start from the end (newest messages) for stackFromBottom + if stackFromBottom { + let viewportHeight = bounds.height + invisibleInset * 2 + var totalHeight: CGFloat = 0 + var startIndex = items.count + while startIndex > 0 && totalHeight < viewportHeight { + startIndex -= 1 + totalHeight += items[startIndex].approximateHeight + } + return startIndex..= topY { + if let index = node.index, index < items.count { + anchorItemId = items[index].id + anchorOffset = node.frame.minY - topY + } + break + } + } + } + + private func invalidateAllLayouts() { + // TODO: Recalculate all visible node layouts for new width + } + + private func notifyVisibleRangeChanged() { + guard !visibleNodes.isEmpty else { + displayedItemRangeChanged?(nil) + return + } + let indices = visibleNodes.compactMap(\.index) + guard let minIdx = indices.min(), let maxIdx = indices.max() else { + displayedItemRangeChanged?(nil) + return + } + displayedItemRangeChanged?(minIdx...maxIdx) + } + + // MARK: - Gestures + + @objc private func backgroundTapped() { + onTapBackground?() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/RosettaTransactionQueue.swift b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaTransactionQueue.swift new file mode 100644 index 0000000..16b891d --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ListView/RosettaTransactionQueue.swift @@ -0,0 +1,43 @@ +import Foundation + +// MARK: - Transaction Queue + +/// Serializes list view updates to prevent concurrent modification. +/// Telegram equivalent: `ListViewTransactionQueue` (ListViewTransactionQueue.swift:1-67). +/// +/// Pattern: FIFO queue. Next transaction starts only after previous completes. +/// Prevents UI glitches from overlapping snapshot + layout operations. +final class RosettaTransactionQueue { + + typealias Transaction = (@escaping () -> Void) -> Void + + private var pending: [Transaction] = [] + private var isExecuting = false + + /// Enqueue a transaction. If queue is idle, executes immediately. + /// Transaction MUST call the completion closure when done. + func enqueue(_ transaction: @escaping Transaction) { + pending.append(transaction) + if !isExecuting { + executeNext() + } + } + + private func executeNext() { + guard !pending.isEmpty else { + isExecuting = false + return + } + isExecuting = true + let transaction = pending.removeFirst() + transaction { [weak self] in + self?.executeNext() + } + } + + /// Cancel all pending transactions (e.g., when view is deallocated). + func cancelAll() { + pending.removeAll() + isExecuting = false + } +}