diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 36315b2..d8d9c91 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -138,6 +138,9 @@ final class NativeMessageCell: UICollectionViewCell { private let forwardAvatarView = UIView() private let forwardNameLabel = UILabel() + // Highlight overlay (scroll-to-message flash) + private let highlightOverlay = UIView() + // Swipe-to-reply private let replyCircleView = UIView() private let replyIconView = UIImageView() @@ -404,6 +407,13 @@ final class NativeMessageCell: UICollectionViewCell { forwardNameLabel.textColor = .white bubbleView.addSubview(forwardNameLabel) + // Highlight overlay — on top of all bubble content + highlightOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.12) + highlightOverlay.isUserInteractionEnabled = false + highlightOverlay.alpha = 0 + highlightOverlay.layer.cornerCurve = .continuous + bubbleView.addSubview(highlightOverlay) + // Swipe reply icon — circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier) replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12) replyCircleView.layer.cornerRadius = 17 // 34pt / 2 @@ -749,6 +759,10 @@ final class NativeMessageCell: UICollectionViewCell { outgoing: layout.isOutgoing, mergeType: layout.mergeType ) + // Highlight overlay — matches bubble bounds with inner corner radius + highlightOverlay.frame = bubbleView.bounds + highlightOverlay.layer.cornerRadius = 16 + // ── Vector shadow path (approximate shape, used only for shadow) ── bubbleLayer.frame = bubbleView.bounds let shapeRect = imageFrame @@ -1074,9 +1088,8 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Swipe to Reply @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) { - // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] if isSavedMessages || isSystemAccount { return } - let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) ?? false + let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar }) ?? false if isReplyBlocked { return } let translation = gesture.translation(in: contentView) @@ -1304,6 +1317,21 @@ final class NativeMessageCell: UICollectionViewCell { } } + // MARK: - Highlight (scroll-to-message flash) + + func showHighlight() { + highlightOverlay.alpha = 0 + UIView.animate(withDuration: 0.2) { + self.highlightOverlay.alpha = 1 + } + } + + func hideHighlight() { + UIView.animate(withDuration: 0.4) { + self.highlightOverlay.alpha = 0 + } + } + @objc private func replyQuoteTapped() { guard let replyMessageId, let actions else { return } actions.onScrollToMessage(replyMessageId) @@ -2052,6 +2080,7 @@ final class NativeMessageCell: UICollectionViewCell { resetPhotoTiles() replyContainer.isHidden = true replyMessageId = nil + highlightOverlay.alpha = 0 fileContainer.isHidden = true callArrowView.isHidden = true callBackButton.isHidden = true diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 43076cb..beeebfb 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1028,6 +1028,20 @@ final class NativeMessageListController: UIViewController { collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: animated) } + /// Telegram-style highlight flash on a message cell. + func animateHighlight(messageId: String?) { + // Hide any previously highlighted cell + for cell in collectionView.visibleCells { + (cell as? NativeMessageCell)?.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 } + cell.showHighlight() + } + /// Reconfigure visible cells without rebuilding the snapshot. func reconfigureVisibleCells() { var snapshot = dataSource.snapshot() @@ -1373,9 +1387,9 @@ struct NativeMessageListView: UIViewControllerRepresentable { controller.config.maxBubbleWidth = maxBubbleWidth configChanged = true } - if controller.config.highlightedMessageId != highlightedMessageId { + let highlightChanged = controller.config.highlightedMessageId != highlightedMessageId + if highlightChanged { controller.config.highlightedMessageId = highlightedMessageId - configChanged = true } if controller.config.firstUnreadMessageId != firstUnreadMessageId { controller.config.firstUnreadMessageId = firstUnreadMessageId @@ -1435,6 +1449,11 @@ struct NativeMessageListView: UIViewControllerRepresentable { } } + // Highlight animation (Telegram-style flash on scroll-to-message) + if highlightChanged { + controller.animateHighlight(messageId: highlightedMessageId) + } + // Empty state (iOS < 26 — UIKit-managed for keyboard animation parity) if let info = emptyChatInfo { controller.updateEmptyState(isEmpty: messages.isEmpty, info: info)