diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 0d3b291..a4d6d8b 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -167,6 +167,7 @@ extension MessageCellLayout { // ── STEP 2: Meta-info dimensions ── let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) let hasStatusIcon = config.isOutgoing && !isOutgoingFailed + let isMediaOnly = config.imageCount > 0 && config.text.isEmpty let statusWidth: CGFloat = hasStatusIcon ? floor(floor(font.pointSize * 13.0 / 17.0)) : 0 @@ -210,13 +211,19 @@ extension MessageCellLayout { // Tiny floor just to prevent zero-width collapse. // Telegram does NOT force a large minW — short messages get tight bubbles. let minW: CGFloat = 40 + let mediaBubbleMaxWidth = min( + effectiveMaxBubbleWidth, + max(220, UIScreen.main.bounds.width * 0.78) + ) var bubbleW: CGFloat - var bubbleH: CGFloat = replyH + forwardHeaderH + photoH + fileH + var bubbleH: CGFloat = replyH + forwardHeaderH + fileH if config.imageCount > 0 { - // Photo: full width - bubbleW = effectiveMaxBubbleWidth + // Media bubbles should not stretch edge-to-edge; keep Telegram-like cap. + bubbleW = mediaBubbleMaxWidth + photoH = Self.collageHeight(count: config.imageCount, width: bubbleW - 8) + bubbleH += photoH if !config.text.isEmpty { bubbleH += topPad + textMeasurement.size.height + bottomPad if photoH > 0 { bubbleH += 6 } @@ -286,8 +293,10 @@ extension MessageCellLayout { // checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued) // tsFrame.maxX = checkFrame.minX - timeGap // checkFrame.minX = bubbleW - rightPad - checkW - let statusEndX = bubbleW - rightPad - let statusEndY = bubbleH - bottomPad + let metadataRightInset: CGFloat = isMediaOnly ? 6 : rightPad + let metadataBottomInset: CGFloat = isMediaOnly ? 6 : bottomPad + let statusEndX = bubbleW - metadataRightInset + let statusEndY = bubbleH - metadataBottomInset let tsFrame: CGRect if config.isOutgoing { diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 0fe5378..2630f16 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -128,7 +128,10 @@ struct ChatDetailView: View { private var maxBubbleWidth: CGFloat { let w = UIScreen.main.bounds.width - return w <= 500 ? w - 36 : w * 0.85 + if w <= 500 { + return max(240, min(w * 0.78, w - 86)) + } + return min(w * 0.72, 520) } /// Visual chat content: messages list + gradient overlays + background. diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index e649d95..837f66a 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -23,7 +23,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) - private static let statusBubbleInsets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) + private static let statusBubbleInsets = UIEdgeInsets(top: 3, left: 7, bottom: 3, right: 7) private static let sendingClockAnimationKey = "clockFrameAnimation" // MARK: - Telegram Check Images (CGContext — ported from PresentationThemeEssentialGraphics.swift) @@ -127,6 +127,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor) private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor) private static let errorIcon = generateErrorIcon(color: .systemRed) + private static let maxVisiblePhotoTiles = 5 private static let blurHashCache: NSCache = { let cache = NSCache() cache.countLimit = 200 @@ -157,10 +158,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private let replyNameLabel = UILabel() private let replyTextLabel = UILabel() - // Photo - private let photoView = UIImageView() - private let photoPlaceholderView = UIView() - private let photoActivityIndicator = UIActivityIndicatorView(style: .medium) + // Photo collage (up to 5 tiles) + private let photoContainer = UIView() + private var photoTileImageViews: [UIImageView] = [] + private var photoTilePlaceholderViews: [UIView] = [] + private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] + private var photoTileButtons: [UIButton] = [] + private let photoOverflowOverlayView = UIView() + private let photoOverflowLabel = UILabel() // File private let fileContainer = UIView() @@ -185,10 +190,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var isDeliveryFailedVisible = false private var wasSentCheckVisible = false private var wasReadCheckVisible = false - private var photoAttachmentId: String? - private var photoLoadTask: Task? - private var photoDownloadTask: Task? - private var isPhotoDownloading = false + private var photoAttachments: [MessageAttachment] = [] + private var totalPhotoAttachmentCount = 0 + private var photoLoadTasks: [String: Task] = [:] + private var photoDownloadTasks: [String: Task] = [:] + private var downloadingAttachmentIds: Set = [] // MARK: - Init @@ -252,21 +258,53 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel replyContainer.addSubview(replyTextLabel) bubbleView.addSubview(replyContainer) - // Photo - photoView.contentMode = .scaleAspectFill - photoView.clipsToBounds = true - photoView.isUserInteractionEnabled = true - bubbleView.addSubview(photoView) + // Photo collage + photoContainer.backgroundColor = .clear + photoContainer.clipsToBounds = true + bubbleView.addSubview(photoContainer) - photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1) - bubbleView.addSubview(photoPlaceholderView) + for index in 0..= 0, sender.tag < photoAttachments.count, + let message, + let actions else { return } + let attachment = photoAttachments[sender.tag] if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil { actions.onImageTap(attachment.id) return @@ -674,58 +711,253 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private func configurePhoto(for message: ChatMessage) { guard let layout = currentLayout, layout.hasPhoto else { - photoAttachmentId = nil - photoLoadTask?.cancel() - photoLoadTask = nil - photoDownloadTask?.cancel() - photoDownloadTask = nil - isPhotoDownloading = false - photoActivityIndicator.stopAnimating() - photoView.image = nil - photoView.isHidden = true - photoPlaceholderView.isHidden = true + resetPhotoTiles() return } - guard let attachment = message.attachments.first(where: { $0.type == .image }) else { - photoAttachmentId = nil - photoLoadTask?.cancel() - photoLoadTask = nil - photoDownloadTask?.cancel() - photoDownloadTask = nil - isPhotoDownloading = false - photoActivityIndicator.stopAnimating() - photoView.image = nil - photoView.isHidden = true - photoPlaceholderView.isHidden = false + let allPhotoAttachments = message.attachments.filter { $0.type == .image } + totalPhotoAttachmentCount = allPhotoAttachments.count + photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles)) + guard !photoAttachments.isEmpty else { + resetPhotoTiles() return } - photoAttachmentId = attachment.id - - if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { - photoView.image = cached - photoView.isHidden = false - photoPlaceholderView.isHidden = true - photoActivityIndicator.stopAnimating() - isPhotoDownloading = false - photoLoadTask?.cancel() - photoLoadTask = nil - return + let activeIds = Set(photoAttachments.map(\.id)) + for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) { + task.cancel() + photoLoadTasks.removeValue(forKey: attachmentId) + } + for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) { + task.cancel() + photoDownloadTasks.removeValue(forKey: attachmentId) + downloadingAttachmentIds.remove(attachmentId) } - photoView.image = Self.blurHashImage(from: attachment.preview) - photoView.isHidden = false - photoPlaceholderView.isHidden = photoView.image != nil - if !isPhotoDownloading { - photoActivityIndicator.stopAnimating() + for index in 0.. [CGRect] { + let spacing: CGFloat = 2 + let width = bounds.width + let height = bounds.height + guard count > 0, width > 0, height > 0 else { return [] } + + switch count { + case 1: + return [bounds] + case 2: + let cellWidth = (width - spacing) / 2 + return [ + CGRect(x: 0, y: 0, width: cellWidth, height: height), + CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: height) + ] + case 3: + let rightWidth = width * 0.34 + let leftWidth = width - spacing - rightWidth + let rightTopHeight = (height - spacing) / 2 + let rightBottomHeight = height - rightTopHeight - spacing + return [ + CGRect(x: 0, y: 0, width: leftWidth, height: height), + CGRect(x: leftWidth + spacing, y: 0, width: rightWidth, height: rightTopHeight), + CGRect(x: leftWidth + spacing, y: rightTopHeight + spacing, width: rightWidth, height: rightBottomHeight) + ] + case 4: + let cellWidth = (width - spacing) / 2 + let topHeight = (height - spacing) / 2 + let bottomHeight = height - topHeight - spacing + return [ + CGRect(x: 0, y: 0, width: cellWidth, height: topHeight), + CGRect(x: cellWidth + spacing, y: 0, width: cellWidth, height: topHeight), + CGRect(x: 0, y: topHeight + spacing, width: cellWidth, height: bottomHeight), + CGRect(x: cellWidth + spacing, y: topHeight + spacing, width: cellWidth, height: bottomHeight) + ] + default: + let topCellWidth = (width - spacing) / 2 + let bottomCellWidth = (width - spacing * 2) / 3 + var topHeight = min(topCellWidth * 0.85, 176) + var bottomHeight = min(bottomCellWidth * 0.85, 144) + let expectedHeight = topHeight + spacing + bottomHeight + if expectedHeight > 0 { + let scale = height / expectedHeight + topHeight *= scale + bottomHeight *= scale + } + return [ + CGRect(x: 0, y: 0, width: topCellWidth, height: topHeight), + CGRect(x: topCellWidth + spacing, y: 0, width: topCellWidth, height: topHeight), + CGRect(x: 0, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight), + CGRect(x: bottomCellWidth + spacing, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight), + CGRect(x: (bottomCellWidth + spacing) * 2, y: topHeight + spacing, width: bottomCellWidth, height: bottomHeight) + ] + } + } + + private func setPhotoTileImage(_ image: UIImage?, at index: Int, animated: Bool) { + guard index >= 0, index < photoTileImageViews.count else { return } + let imageView = photoTileImageViews[index] + let update = { + imageView.image = image + } + if animated, image != nil, imageView.image != nil { + UIView.transition( + with: imageView, + duration: 0.18, + options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction], + animations: update + ) + } else { + update() + } + imageView.isHidden = image == nil + } + + private func layoutPhotoOverflowOverlay(frames: [CGRect]? = nil) { + let overflowCount = totalPhotoAttachmentCount - photoAttachments.count + guard overflowCount > 0, + !photoAttachments.isEmpty else { + photoOverflowOverlayView.isHidden = true + photoOverflowLabel.isHidden = true + photoOverflowLabel.text = nil + return + } + + let lastVisibleIndex = photoAttachments.count - 1 + guard lastVisibleIndex >= 0, lastVisibleIndex < photoTileImageViews.count else { + photoOverflowOverlayView.isHidden = true + photoOverflowLabel.isHidden = true + photoOverflowLabel.text = nil + return + } + + let frame: CGRect + if let frames, lastVisibleIndex < frames.count { + frame = frames[lastVisibleIndex] + } else { + frame = photoTileImageViews[lastVisibleIndex].frame + } + + guard frame.width > 0, frame.height > 0 else { + photoOverflowOverlayView.isHidden = true + photoOverflowLabel.isHidden = true + photoOverflowLabel.text = nil + return + } + + photoOverflowOverlayView.frame = frame + photoOverflowOverlayView.isHidden = false + photoOverflowLabel.isHidden = false + photoOverflowLabel.text = "+\(overflowCount)" + photoOverflowLabel.font = UIFont.systemFont( + ofSize: max(18, min(frame.height * 0.34, 34)), + weight: .semibold + ) + photoOverflowLabel.frame = photoOverflowOverlayView.bounds + } + + private func tileIndex(for attachmentId: String) -> Int? { + photoAttachments.firstIndex(where: { $0.id == attachmentId }) + } + + private func startPhotoLoadTask(attachment: MessageAttachment) { + if photoLoadTasks[attachment.id] != nil { return } + let attachmentId = attachment.id + photoLoadTasks[attachmentId] = Task { [weak self] in await ImageLoadLimiter.shared.acquire() let loaded = await Task.detached(priority: .userInitiated) { await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) @@ -733,18 +965,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel await ImageLoadLimiter.shared.release() guard !Task.isCancelled else { return } await MainActor.run { - guard let self, self.photoAttachmentId == attachmentId, let loaded else { return } - self.photoView.image = loaded - self.photoView.isHidden = false - self.photoPlaceholderView.isHidden = true - self.photoActivityIndicator.stopAnimating() - self.isPhotoDownloading = false + guard let self else { return } + self.photoLoadTasks.removeValue(forKey: attachmentId) + guard let tileIndex = self.tileIndex(for: attachmentId), + tileIndex < self.photoTileImageViews.count, + let loaded else { + return + } + self.setPhotoTileImage(loaded, at: tileIndex, animated: true) + self.photoTilePlaceholderViews[tileIndex].isHidden = true + self.photoTileActivityIndicators[tileIndex].stopAnimating() + self.photoTileActivityIndicators[tileIndex].isHidden = true } } } private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) { - guard !isPhotoDownloading else { return } + if photoDownloadTasks[attachment.id] != nil { return } let tag = Self.extractTag(from: attachment.preview) guard !tag.isEmpty, let storedPassword = message.attachmentPassword, @@ -752,43 +989,78 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel return } - isPhotoDownloading = true - photoActivityIndicator.startAnimating() - photoDownloadTask?.cancel() let attachmentId = attachment.id - let preview = attachment.preview + downloadingAttachmentIds.insert(attachmentId) + if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count { + photoTileActivityIndicators[tileIndex].startAnimating() + photoTileActivityIndicators[tileIndex].isHidden = false + } - photoDownloadTask = Task { [weak self] in + photoDownloadTasks[attachmentId] = Task { [weak self] in do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords) await MainActor.run { - guard let self, self.photoAttachmentId == attachmentId else { return } + guard let self else { return } + self.photoDownloadTasks.removeValue(forKey: attachmentId) + self.downloadingAttachmentIds.remove(attachmentId) + guard let tileIndex = self.tileIndex(for: attachmentId), + tileIndex < self.photoTileImageViews.count else { + return + } if let image { AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) - self.photoView.image = image - self.photoView.isHidden = false - self.photoPlaceholderView.isHidden = true - } else { - self.photoView.image = Self.blurHashImage(from: preview) - self.photoView.isHidden = false - self.photoPlaceholderView.isHidden = self.photoView.image != nil + self.setPhotoTileImage(image, at: tileIndex, animated: true) + self.photoTilePlaceholderViews[tileIndex].isHidden = true } - self.photoActivityIndicator.stopAnimating() - self.isPhotoDownloading = false + self.photoTileActivityIndicators[tileIndex].stopAnimating() + self.photoTileActivityIndicators[tileIndex].isHidden = true } } catch { await MainActor.run { - guard let self, self.photoAttachmentId == attachmentId else { return } - self.photoActivityIndicator.stopAnimating() - self.isPhotoDownloading = false + guard let self else { return } + self.photoDownloadTasks.removeValue(forKey: attachmentId) + self.downloadingAttachmentIds.remove(attachmentId) + guard let tileIndex = self.tileIndex(for: attachmentId), + tileIndex < self.photoTileActivityIndicators.count else { + return + } + self.photoTileActivityIndicators[tileIndex].stopAnimating() + self.photoTileActivityIndicators[tileIndex].isHidden = true } } } } + private func resetPhotoTiles() { + photoAttachments.removeAll() + totalPhotoAttachmentCount = 0 + for task in photoLoadTasks.values { + task.cancel() + } + photoLoadTasks.removeAll() + for task in photoDownloadTasks.values { + task.cancel() + } + photoDownloadTasks.removeAll() + downloadingAttachmentIds.removeAll() + photoContainer.layer.mask = nil + photoOverflowOverlayView.isHidden = true + photoOverflowLabel.isHidden = true + photoOverflowLabel.text = nil + + for index in 0.. String { let parts = preview.components(separatedBy: "::") return parts.first ?? preview @@ -873,21 +1145,35 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) { if isSentVisible && !wasSentCheckVisible { - let pop = CABasicAnimation(keyPath: "transform.scale") - pop.fromValue = NSNumber(value: Float(1.3)) - pop.toValue = NSNumber(value: Float(1.0)) - pop.duration = 0.1 - pop.timingFunction = CAMediaTimingFunction(name: .easeOut) - checkSentView.layer.add(pop, forKey: "checkPop") + checkSentView.alpha = 0 + checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9) + UIView.animate( + withDuration: 0.16, + delay: 0, + options: [.curveEaseOut, .beginFromCurrentState] + ) { + self.checkSentView.alpha = 1 + self.checkSentView.transform = .identity + } + } else if !isSentVisible { + checkSentView.alpha = 1 + checkSentView.transform = .identity } if isReadVisible && !wasReadCheckVisible { - let pop = CABasicAnimation(keyPath: "transform.scale") - pop.fromValue = NSNumber(value: Float(1.3)) - pop.toValue = NSNumber(value: Float(1.0)) - pop.duration = 0.1 - pop.timingFunction = CAMediaTimingFunction(name: .easeOut) - checkReadView.layer.add(pop, forKey: "checkPop") + checkReadView.alpha = 0 + checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9) + UIView.animate( + withDuration: 0.16, + delay: 0.02, + options: [.curveEaseOut, .beginFromCurrentState] + ) { + self.checkReadView.alpha = 1 + self.checkReadView.transform = .identity + } + } else if !isReadVisible { + checkReadView.alpha = 1 + checkReadView.transform = .identity } wasSentCheckVisible = isSentVisible @@ -919,6 +1205,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel ) } + private func bringStatusOverlayToFront() { + bubbleView.bringSubviewToFront(statusBackgroundView) + bubbleView.bringSubviewToFront(timestampLabel) + bubbleView.bringSubviewToFront(checkSentView) + bubbleView.bringSubviewToFront(checkReadView) + bubbleView.bringSubviewToFront(clockFrameView) + bubbleView.bringSubviewToFront(clockMinView) + } + // MARK: - Reuse override func prepareForReuse() { @@ -931,8 +1226,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel timestampLabel.text = nil checkSentView.image = nil checkSentView.isHidden = true + checkSentView.alpha = 1 + checkSentView.transform = .identity checkReadView.image = nil checkReadView.isHidden = true + checkReadView.alpha = 1 + checkReadView.transform = .identity clockFrameView.image = nil clockFrameView.isHidden = true clockMinView.image = nil @@ -940,21 +1239,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel wasSentCheckVisible = false wasReadCheckVisible = false statusBackgroundView.isHidden = true - photoAttachmentId = nil - photoLoadTask?.cancel() - photoLoadTask = nil - photoDownloadTask?.cancel() - photoDownloadTask = nil - isPhotoDownloading = false - photoActivityIndicator.stopAnimating() - photoView.image = nil + resetPhotoTiles() replyContainer.isHidden = true fileContainer.isHidden = true forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true - photoView.isHidden = true - photoPlaceholderView.isHidden = true + photoContainer.isHidden = true bubbleView.transform = .identity replyIconView.alpha = 0 deliveryFailedButton.isHidden = true