diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index a4d6d8b..7856a87 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -211,9 +211,21 @@ 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 mediaWidthFraction: CGFloat + let mediaAbsoluteCap: CGFloat + if config.imageCount == 1 { + mediaWidthFraction = 0.64 + mediaAbsoluteCap = 288 + } else if config.imageCount == 2 { + mediaWidthFraction = 0.67 + mediaAbsoluteCap = 300 + } else { + mediaWidthFraction = 0.7 + mediaAbsoluteCap = 312 + } let mediaBubbleMaxWidth = min( effectiveMaxBubbleWidth, - max(220, UIScreen.main.bounds.width * 0.78) + min(mediaAbsoluteCap, max(200, UIScreen.main.bounds.width * mediaWidthFraction)) ) var bubbleW: CGFloat @@ -390,14 +402,14 @@ extension MessageCellLayout { /// Photo collage height — same formulas as C++ MessageLayout & PhotoCollageView.swift. private static func collageHeight(count: Int, width: CGFloat) -> CGFloat { guard count > 0 else { return 0 } - if count == 1 { return min(width * 0.75, 320) } + if count == 1 { return max(180, min(width * 0.93, 340)) } if count == 2 { let cellW = (width - 2) / 2 - return min(cellW * 1.2, 320) + return min(cellW * 1.28, 330) } if count == 3 { let leftW = width * 0.66 - return min(leftW * 1.1, 320) + return min(leftW * 1.16, 330) } if count == 4 { let cellW = (width - 2) / 2 diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 2630f16..9e4d47f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -129,9 +129,9 @@ struct ChatDetailView: View { private var maxBubbleWidth: CGFloat { let w = UIScreen.main.bounds.width if w <= 500 { - return max(240, min(w * 0.78, w - 86)) + return max(224, min(w * 0.72, w - 104)) } - return min(w * 0.72, 520) + return min(w * 0.66, 460) } /// 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 837f66a..f27ec60 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -163,7 +163,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var photoTileImageViews: [UIImageView] = [] private var photoTilePlaceholderViews: [UIView] = [] private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] + private var photoTileErrorViews: [UIImageView] = [] private var photoTileButtons: [UIButton] = [] + private let photoUploadingOverlayView = UIView() + private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium) private let photoOverflowOverlayView = UIView() private let photoOverflowLabel = UILabel() @@ -195,6 +198,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var photoLoadTasks: [String: Task] = [:] private var photoDownloadTasks: [String: Task] = [:] private var downloadingAttachmentIds: Set = [] + private var failedAttachmentIds: Set = [] // MARK: - Init @@ -230,8 +234,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel bubbleView.addSubview(textLabel) // Timestamp - statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32) - statusBackgroundView.layer.cornerRadius = 6 + statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3) + statusBackgroundView.layer.cornerRadius = 7 + statusBackgroundView.layer.cornerCurve = .continuous statusBackgroundView.isHidden = true bubbleView.addSubview(statusBackgroundView) @@ -281,6 +286,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel indicator.isHidden = true photoContainer.addSubview(indicator) + let errorView = UIImageView(image: Self.errorIcon) + errorView.contentMode = .center + errorView.isHidden = true + photoContainer.addSubview(errorView) + let button = UIButton(type: .custom) button.tag = index button.isHidden = true @@ -290,9 +300,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTileImageViews.append(imageView) photoTilePlaceholderViews.append(placeholderView) photoTileActivityIndicators.append(indicator) + photoTileErrorViews.append(errorView) photoTileButtons.append(button) } + photoUploadingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.22) + photoUploadingOverlayView.isHidden = true + photoUploadingOverlayView.isUserInteractionEnabled = false + photoContainer.addSubview(photoUploadingOverlayView) + + photoUploadingIndicator.color = .white + photoUploadingIndicator.hidesWhenStopped = true + photoUploadingIndicator.isHidden = true + photoContainer.addSubview(photoUploadingIndicator) + photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45) photoOverflowOverlayView.layer.cornerCurve = .continuous photoOverflowOverlayView.layer.cornerRadius = 0 @@ -516,11 +537,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel bubbleLayer.shadowPath = bubbleLayer.path bubbleOutlineLayer.frame = bubbleView.bounds bubbleOutlineLayer.path = bubbleLayer.path - if layout.hasTail { + let hasPhotoContent = layout.hasPhoto + if hasPhotoContent { + bubbleLayer.shadowOpacity = 0.04 + bubbleLayer.shadowRadius = 0.4 + bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2) + bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor + } else if layout.hasTail { // Tail path is appended as a second subpath; stroking it produces // a visible seam at the junction. Keep fill-only for tailed bubbles. + bubbleLayer.shadowOpacity = 0.12 + bubbleLayer.shadowRadius = 0.6 + bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } else { + bubbleLayer.shadowOpacity = 0.12 + bubbleLayer.shadowRadius = 0.6 + bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent( layout.isOutgoing ? 0.16 : 0.22 ).cgColor @@ -732,6 +765,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel task.cancel() photoDownloadTasks.removeValue(forKey: attachmentId) downloadingAttachmentIds.remove(attachmentId) + failedAttachmentIds.remove(attachmentId) } for index in 0.. Int? { photoAttachments.firstIndex(where: { $0.id == attachmentId }) } @@ -972,10 +1050,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel let loaded else { return } + self.failedAttachmentIds.remove(attachmentId) self.setPhotoTileImage(loaded, at: tileIndex, animated: true) self.photoTilePlaceholderViews[tileIndex].isHidden = true self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileErrorViews[tileIndex].isHidden = true } } } @@ -986,14 +1066,22 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel guard !tag.isEmpty, let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { + failedAttachmentIds.insert(attachment.id) + if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count { + photoTileActivityIndicators[tileIndex].stopAnimating() + photoTileActivityIndicators[tileIndex].isHidden = true + photoTileErrorViews[tileIndex].isHidden = false + } return } let attachmentId = attachment.id + failedAttachmentIds.remove(attachmentId) downloadingAttachmentIds.insert(attachmentId) if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count { photoTileActivityIndicators[tileIndex].startAnimating() photoTileActivityIndicators[tileIndex].isHidden = false + photoTileErrorViews[tileIndex].isHidden = true } photoDownloadTasks[attachmentId] = Task { [weak self] in @@ -1011,9 +1099,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel return } if let image { + self.failedAttachmentIds.remove(attachmentId) AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) self.setPhotoTileImage(image, at: tileIndex, animated: true) self.photoTilePlaceholderViews[tileIndex].isHidden = true + self.photoTileErrorViews[tileIndex].isHidden = true + } else { + self.failedAttachmentIds.insert(attachmentId) + self.photoTileErrorViews[tileIndex].isHidden = false } self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true @@ -1023,12 +1116,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel guard let self else { return } self.photoDownloadTasks.removeValue(forKey: attachmentId) self.downloadingAttachmentIds.remove(attachmentId) + self.failedAttachmentIds.insert(attachmentId) guard let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileActivityIndicators.count else { return } self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileErrorViews[tileIndex].isHidden = false } } } @@ -1046,7 +1141,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } photoDownloadTasks.removeAll() downloadingAttachmentIds.removeAll() + failedAttachmentIds.removeAll() photoContainer.layer.mask = nil + updatePhotoUploadingOverlay(isVisible: false) photoOverflowOverlayView.isHidden = true photoOverflowLabel.isHidden = true photoOverflowLabel.text = nil @@ -1057,6 +1154,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTilePlaceholderViews[index].isHidden = true photoTileActivityIndicators[index].stopAnimating() photoTileActivityIndicators[index].isHidden = true + photoTileErrorViews[index].isHidden = true photoTileButtons[index].isHidden = true } } @@ -1271,7 +1369,7 @@ extension NativeMessageCell: UIGestureRecognizerDelegate { final class BubblePathCache { static let shared = BubblePathCache() - private let pathVersion = 7 + private let pathVersion = 8 private var cache: [String: CGPath] = [:] func path( @@ -1296,7 +1394,7 @@ final class BubblePathCache { private func makeBubblePath( in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool ) -> CGPath { - let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6 + let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6 // Body rect let bodyRect: CGRect