diff --git a/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift b/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift index 11726b9..99c83c6 100644 --- a/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift +++ b/Rosetta/Features/Chats/ChatDetail/ListView/ChatMessageListItem.swift @@ -12,6 +12,7 @@ final class ChatMessageListItem: RosettaListItem { let message: ChatMessage let layout: MessageCellLayout let textLayout: CoreTextTextLayout? + let timestampText: String let actions: MessageCellActions let replyName: String? let replyText: String? @@ -29,6 +30,7 @@ final class ChatMessageListItem: RosettaListItem { message: ChatMessage, layout: MessageCellLayout, textLayout: CoreTextTextLayout?, + timestampText: String, actions: MessageCellActions, replyName: String? = nil, replyText: String? = nil, @@ -39,6 +41,7 @@ final class ChatMessageListItem: RosettaListItem { self.message = message self.layout = layout self.textLayout = textLayout + self.timestampText = timestampText self.actions = actions self.replyName = replyName self.replyText = replyText @@ -86,7 +89,7 @@ final class ChatMessageListItem: RosettaListItem { node.cell.apply(layout: layout) node.cell.configure( message: message, - timestamp: layout.timestampText, + timestamp: timestampText, textLayout: textLayout, actions: actions, replyName: replyName, diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 7586a64..adb9243 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -10,7 +10,12 @@ import SwiftUI /// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing /// /// Subviews are always present but hidden when not needed (no alloc/dealloc overhead). -final class NativeMessageCell: UICollectionViewCell { +/// Base class: UIView (not UICollectionViewCell) — works in both RosettaListView and UICollectionView wrapper. +final class NativeMessageCell: UIView { + + /// Content container. UICollectionViewCell provides this automatically; + /// as a plain UIView we create our own for API compatibility. + let contentView = UIView() // MARK: - Constants @@ -299,6 +304,9 @@ final class NativeMessageCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) + contentView.frame = bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(contentView) setupViews() } @@ -2144,13 +2152,9 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Self-sizing (from pre-calculated layout) - override func preferredLayoutAttributesFitting( - _ layoutAttributes: UICollectionViewLayoutAttributes - ) -> UICollectionViewLayoutAttributes { - // Always return concrete height — never fall to super (expensive self-sizing) - let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes - attrs.size.height = currentLayout?.totalHeight ?? 50 - return attrs + /// Effective height for external layout systems. + var preferredHeight: CGFloat { + currentLayout?.totalHeight ?? 50 } // MARK: - Link Tap @@ -3840,8 +3844,7 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Reuse - override func prepareForReuse() { - super.prepareForReuse() + func prepareForReuse() { uploadOverlayShowTime = 0 if let observer = uploadProgressObserver { NotificationCenter.default.removeObserver(observer) @@ -4516,3 +4519,43 @@ final class ForwardItemSubview: UIView { } } } + +// MARK: - UICollectionView Wrapper + +/// Thin UICollectionViewCell wrapper for NativeMessageCell (UIView). +/// Allows existing UICollectionView-based NativeMessageList to continue working +/// while NativeMessageCell itself is a plain UIView for RosettaListView. +final class NativeMessageCollectionCell: UICollectionViewCell { + + let messageView = NativeMessageCell(frame: .zero) + + override init(frame: CGRect) { + super.init(frame: frame) + messageView.frame = contentView.bounds + messageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentView.addSubview(messageView) + backgroundColor = .clear + contentView.backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func prepareForReuse() { + super.prepareForReuse() + messageView.prepareForReuse() + } + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes + attrs.size.height = messageView.preferredHeight + return attrs + } + + override func layoutSubviews() { + super.layoutSubviews() + messageView.frame = contentView.bounds + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index d06c784..3c6a2c2 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -109,8 +109,19 @@ final class NativeMessageListController: UIViewController { private var collectionView: UICollectionView! private static let unreadSeparatorId = "__unread_separator__" + /// Extract NativeMessageCell from a collection view cell (wrapper pattern). + private func messageCell(from cell: UICollectionViewCell) -> NativeMessageCell? { + (cell as? NativeMessageCollectionCell)?.messageView + } + + /// Extract NativeMessageCell at index path. + private func messageCell(at indexPath: IndexPath) -> NativeMessageCell? { + guard let cell = collectionView.cellForItem(at: indexPath) else { return nil } + return messageCell(from: cell) + } + private var dataSource: UICollectionViewDiffableDataSource! - private var nativeCellRegistration: UICollectionView.CellRegistration! + private var nativeCellRegistration: UICollectionView.CellRegistration! private var separatorCellRegistration: UICollectionView.CellRegistration! // MARK: - Insertion Animation @@ -348,7 +359,7 @@ final class NativeMessageListController: UIViewController { let sortedIndexPaths = collectionView.indexPathsForVisibleItems .sorted { $0.item < $1.item } for ip in sortedIndexPaths { - guard let cell = collectionView.cellForItem(at: ip) as? NativeMessageCell, + guard let cell = messageCell(at: ip), let msgId = messageId(for: ip) else { continue } let isOutgoing = layoutCache[msgId]?.isOutgoing ?? false // Hide cells before animation — they'll fade in via skeleton transition @@ -476,8 +487,9 @@ final class NativeMessageListController: UIViewController { } private func setupNativeCellRegistration() { - nativeCellRegistration = UICollectionView.CellRegistration { - [weak self] cell, indexPath, message in + nativeCellRegistration = UICollectionView.CellRegistration { + [weak self] wrapper, indexPath, message in + let cell = wrapper.messageView guard let self else { return } // Patch group messages without attachment password (legacy messages before fix). @@ -1445,9 +1457,9 @@ final class NativeMessageListController: UIViewController { // Update all visible cells for cell in collectionView.visibleCells { - guard let msgCell = cell as? NativeMessageCell else { continue } + guard let msgCell = messageCell(from: cell) else { continue } msgCell.setSelectionMode(enabled, animated: animated) - if enabled, let indexPath = collectionView.indexPath(for: msgCell), + if enabled, let indexPath = collectionView.indexPath(for: cell), let msgId = messageId(for: indexPath) { msgCell.setMessageSelected(selectedMessageIds.contains(msgId), animated: false) } @@ -1462,8 +1474,9 @@ final class NativeMessageListController: UIViewController { selectedMessageIds = ids for cell in collectionView.visibleCells { - guard let msgCell = cell as? NativeMessageCell, - let indexPath = collectionView.indexPath(for: msgCell), + guard let wrapper = cell as? NativeMessageCollectionCell, + let msgCell = messageCell(from: wrapper), + let indexPath = collectionView.indexPath(for: wrapper), let msgId = messageId(for: indexPath) else { continue } let wasSelected = oldIds.contains(msgId) let isSelected = ids.contains(msgId) @@ -1889,13 +1902,13 @@ final class NativeMessageListController: UIViewController { func animateHighlight(messageId: String?) { // Hide any previously highlighted cell for cell in collectionView.visibleCells { - (cell as? NativeMessageCell)?.hideHighlight() + messageCell(from: cell)?.hideHighlight() } guard let messageId, let snapshot = dataSource?.snapshot(), let itemIndex = snapshot.indexOfItem(messageId) else { return } let indexPath = IndexPath(item: itemIndex, section: 0) - guard let cell = collectionView.cellForItem(at: indexPath) as? NativeMessageCell else { return } + guard let cell = messageCell(at: indexPath) else { return } cell.showHighlight() } @@ -2214,7 +2227,7 @@ extension NativeMessageListController: ComposerViewDelegate { let player = VoiceMessagePlayer.shared guard let playingId = player.currentMessageId else { return } for cell in collectionView.visibleCells { - guard let messageCell = cell as? NativeMessageCell else { continue } + guard let messageCell = self.messageCell(from: cell) else { continue } // Match main voice view OR any forward item voice view let matchesMain = messageCell.currentMessageId == playingId let matchesForward = messageCell.hasForwardVoiceWithPlaybackId(playingId) @@ -2232,7 +2245,7 @@ extension NativeMessageListController: ComposerViewDelegate { private func updateAllVisibleVoiceCells() { let player = VoiceMessagePlayer.shared for cell in collectionView.visibleCells { - guard let messageCell = cell as? NativeMessageCell else { continue } + guard let messageCell = self.messageCell(from: cell) else { continue } let isThisMessage = messageCell.currentMessageId == player.currentMessageId || messageCell.hasForwardVoiceWithPlaybackId(player.currentMessageId) messageCell.updateVoicePlayback( @@ -2409,7 +2422,7 @@ extension NativeMessageListController: ComposerViewDelegate { collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false) collectionView.layoutIfNeeded() } - guard let cell = collectionView.cellForItem(at: indexPath) as? NativeMessageCell else { + guard let cell = messageCell(at: indexPath) else { return nil } return cell.voiceTransitionTargetFrame(in: window) ?? cell.bubbleFrameInWindow(window)