Реализован медиа-коллаж telegram-like, с переполнением и распределением статусов по уровням.

This commit is contained in:
2026-03-28 00:40:35 +05:00
parent e03e3685e7
commit d706ef6d62
3 changed files with 429 additions and 126 deletions

View File

@@ -167,6 +167,7 @@ extension MessageCellLayout {
// STEP 2: Meta-info dimensions // STEP 2: Meta-info dimensions
let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) let tsSize = measureText("00:00", maxWidth: 60, font: tsFont)
let hasStatusIcon = config.isOutgoing && !isOutgoingFailed let hasStatusIcon = config.isOutgoing && !isOutgoingFailed
let isMediaOnly = config.imageCount > 0 && config.text.isEmpty
let statusWidth: CGFloat = hasStatusIcon let statusWidth: CGFloat = hasStatusIcon
? floor(floor(font.pointSize * 13.0 / 17.0)) ? floor(floor(font.pointSize * 13.0 / 17.0))
: 0 : 0
@@ -210,13 +211,19 @@ extension MessageCellLayout {
// Tiny floor just to prevent zero-width collapse. // Tiny floor just to prevent zero-width collapse.
// Telegram does NOT force a large minW short messages get tight bubbles. // Telegram does NOT force a large minW short messages get tight bubbles.
let minW: CGFloat = 40 let minW: CGFloat = 40
let mediaBubbleMaxWidth = min(
effectiveMaxBubbleWidth,
max(220, UIScreen.main.bounds.width * 0.78)
)
var bubbleW: CGFloat var bubbleW: CGFloat
var bubbleH: CGFloat = replyH + forwardHeaderH + photoH + fileH var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
if config.imageCount > 0 { if config.imageCount > 0 {
// Photo: full width // Media bubbles should not stretch edge-to-edge; keep Telegram-like cap.
bubbleW = effectiveMaxBubbleWidth bubbleW = mediaBubbleMaxWidth
photoH = Self.collageHeight(count: config.imageCount, width: bubbleW - 8)
bubbleH += photoH
if !config.text.isEmpty { if !config.text.isEmpty {
bubbleH += topPad + textMeasurement.size.height + bottomPad bubbleH += topPad + textMeasurement.size.height + bottomPad
if photoH > 0 { bubbleH += 6 } if photoH > 0 { bubbleH += 6 }
@@ -286,8 +293,10 @@ extension MessageCellLayout {
// checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued) // checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued)
// tsFrame.maxX = checkFrame.minX - timeGap // tsFrame.maxX = checkFrame.minX - timeGap
// checkFrame.minX = bubbleW - rightPad - checkW // checkFrame.minX = bubbleW - rightPad - checkW
let statusEndX = bubbleW - rightPad let metadataRightInset: CGFloat = isMediaOnly ? 6 : rightPad
let statusEndY = bubbleH - bottomPad let metadataBottomInset: CGFloat = isMediaOnly ? 6 : bottomPad
let statusEndX = bubbleW - metadataRightInset
let statusEndY = bubbleH - metadataBottomInset
let tsFrame: CGRect let tsFrame: CGRect
if config.isOutgoing { if config.isOutgoing {

View File

@@ -128,7 +128,10 @@ struct ChatDetailView: View {
private var maxBubbleWidth: CGFloat { private var maxBubbleWidth: CGFloat {
let w = UIScreen.main.bounds.width 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. /// Visual chat content: messages list + gradient overlays + background.

View File

@@ -23,7 +23,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) 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" private static let sendingClockAnimationKey = "clockFrameAnimation"
// MARK: - Telegram Check Images (CGContext ported from PresentationThemeEssentialGraphics.swift) // 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 mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor) private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
private static let errorIcon = generateErrorIcon(color: .systemRed) private static let errorIcon = generateErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
private static let blurHashCache: NSCache<NSString, UIImage> = { private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>() let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200 cache.countLimit = 200
@@ -157,10 +158,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private let replyNameLabel = UILabel() private let replyNameLabel = UILabel()
private let replyTextLabel = UILabel() private let replyTextLabel = UILabel()
// Photo // Photo collage (up to 5 tiles)
private let photoView = UIImageView() private let photoContainer = UIView()
private let photoPlaceholderView = UIView() private var photoTileImageViews: [UIImageView] = []
private let photoActivityIndicator = UIActivityIndicatorView(style: .medium) private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileButtons: [UIButton] = []
private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel()
// File // File
private let fileContainer = UIView() private let fileContainer = UIView()
@@ -185,10 +190,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var isDeliveryFailedVisible = false private var isDeliveryFailedVisible = false
private var wasSentCheckVisible = false private var wasSentCheckVisible = false
private var wasReadCheckVisible = false private var wasReadCheckVisible = false
private var photoAttachmentId: String? private var photoAttachments: [MessageAttachment] = []
private var photoLoadTask: Task<Void, Never>? private var totalPhotoAttachmentCount = 0
private var photoDownloadTask: Task<Void, Never>? private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var isPhotoDownloading = false private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
// MARK: - Init // MARK: - Init
@@ -252,21 +258,53 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
replyContainer.addSubview(replyTextLabel) replyContainer.addSubview(replyTextLabel)
bubbleView.addSubview(replyContainer) bubbleView.addSubview(replyContainer)
// Photo // Photo collage
photoView.contentMode = .scaleAspectFill photoContainer.backgroundColor = .clear
photoView.clipsToBounds = true photoContainer.clipsToBounds = true
photoView.isUserInteractionEnabled = true bubbleView.addSubview(photoContainer)
bubbleView.addSubview(photoView)
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1) for index in 0..<Self.maxVisiblePhotoTiles {
bubbleView.addSubview(photoPlaceholderView) let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isHidden = true
photoContainer.addSubview(imageView)
photoActivityIndicator.color = .white let placeholderView = UIView()
photoActivityIndicator.hidesWhenStopped = true placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
bubbleView.addSubview(photoActivityIndicator) placeholderView.isHidden = true
photoContainer.addSubview(placeholderView)
let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap)) let indicator = UIActivityIndicatorView(style: .medium)
photoView.addGestureRecognizer(photoTap) indicator.color = .white
indicator.hidesWhenStopped = true
indicator.isHidden = true
photoContainer.addSubview(indicator)
let button = UIButton(type: .custom)
button.tag = index
button.isHidden = true
button.addTarget(self, action: #selector(handlePhotoTileTap(_:)), for: .touchUpInside)
photoContainer.addSubview(button)
photoTileImageViews.append(imageView)
photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator)
photoTileButtons.append(button)
}
photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
photoOverflowOverlayView.layer.cornerCurve = .continuous
photoOverflowOverlayView.layer.cornerRadius = 0
photoOverflowOverlayView.isHidden = true
photoOverflowOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoOverflowOverlayView)
photoOverflowLabel.font = UIFont.systemFont(ofSize: 26, weight: .semibold)
photoOverflowLabel.textColor = .white
photoOverflowLabel.textAlignment = .center
photoOverflowLabel.isHidden = true
photoOverflowOverlayView.addSubview(photoOverflowLabel)
// File // File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
@@ -388,6 +426,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Bubble color // Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote // Reply quote
if let replyName { if let replyName {
@@ -511,13 +550,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
} }
// Photo // Photo
photoView.isHidden = !layout.hasPhoto photoContainer.isHidden = !layout.hasPhoto
photoPlaceholderView.isHidden = !layout.hasPhoto
if layout.hasPhoto { if layout.hasPhoto {
photoView.frame = layout.photoFrame photoContainer.frame = layout.photoFrame
photoPlaceholderView.frame = layout.photoFrame layoutPhotoTiles()
photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY)
} }
bringStatusOverlayToFront()
// File // File
fileContainer.isHidden = !layout.hasFile fileContainer.isHidden = !layout.hasFile
@@ -655,15 +693,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onRetry(message) actions.onRetry(message)
} }
@objc private func handlePhotoTap() { @objc private func handlePhotoTileTap(_ sender: UIButton) {
guard let message, guard sender.tag >= 0, sender.tag < photoAttachments.count,
let actions, let message,
let layout = currentLayout, let actions else {
layout.hasPhoto,
let attachment = message.attachments.first(where: { $0.type == .image }) else {
return return
} }
let attachment = photoAttachments[sender.tag]
if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil { if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id) actions.onImageTap(attachment.id)
return return
@@ -674,58 +711,253 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func configurePhoto(for message: ChatMessage) { private func configurePhoto(for message: ChatMessage) {
guard let layout = currentLayout, layout.hasPhoto else { guard let layout = currentLayout, layout.hasPhoto else {
photoAttachmentId = nil resetPhotoTiles()
photoLoadTask?.cancel()
photoLoadTask = nil
photoDownloadTask?.cancel()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
photoView.isHidden = true
photoPlaceholderView.isHidden = true
return return
} }
guard let attachment = message.attachments.first(where: { $0.type == .image }) else { let allPhotoAttachments = message.attachments.filter { $0.type == .image }
photoAttachmentId = nil totalPhotoAttachmentCount = allPhotoAttachments.count
photoLoadTask?.cancel() photoAttachments = Array(allPhotoAttachments.prefix(Self.maxVisiblePhotoTiles))
photoLoadTask = nil guard !photoAttachments.isEmpty else {
photoDownloadTask?.cancel() resetPhotoTiles()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
photoView.isHidden = true
photoPlaceholderView.isHidden = false
return return
} }
photoAttachmentId = attachment.id let activeIds = Set(photoAttachments.map(\.id))
for (attachmentId, task) in photoLoadTasks where !activeIds.contains(attachmentId) {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { task.cancel()
photoView.image = cached photoLoadTasks.removeValue(forKey: attachmentId)
photoView.isHidden = false }
photoPlaceholderView.isHidden = true for (attachmentId, task) in photoDownloadTasks where !activeIds.contains(attachmentId) {
photoActivityIndicator.stopAnimating() task.cancel()
isPhotoDownloading = false photoDownloadTasks.removeValue(forKey: attachmentId)
photoLoadTask?.cancel() downloadingAttachmentIds.remove(attachmentId)
photoLoadTask = nil
return
} }
photoView.image = Self.blurHashImage(from: attachment.preview) for index in 0..<photoTileImageViews.count {
photoView.isHidden = false let isActiveTile = index < photoAttachments.count
photoPlaceholderView.isHidden = photoView.image != nil let imageView = photoTileImageViews[index]
if !isPhotoDownloading { let placeholderView = photoTilePlaceholderViews[index]
photoActivityIndicator.stopAnimating() let indicator = photoTileActivityIndicators[index]
let button = photoTileButtons[index]
button.isHidden = !isActiveTile
guard isActiveTile else {
imageView.image = nil
imageView.isHidden = true
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
continue
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
indicator.stopAnimating()
indicator.isHidden = true
} else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
placeholderView.isHidden = imageView.image != nil
if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating()
indicator.isHidden = false
} else {
indicator.stopAnimating()
indicator.isHidden = true
}
startPhotoLoadTask(attachment: attachment)
}
} }
startPhotoLoadTask(attachmentId: attachment.id) layoutPhotoOverflowOverlay()
} }
private func startPhotoLoadTask(attachmentId: String) { private func layoutPhotoTiles() {
photoLoadTask?.cancel() guard !photoAttachments.isEmpty else { return }
photoLoadTask = Task { [weak self] in updatePhotoContainerMask()
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
photoTileImageViews[index].frame = frame
photoTilePlaceholderViews[index].frame = frame
photoTileButtons[index].frame = frame
photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY)
}
layoutPhotoOverflowOverlay(frames: frames)
}
private func updatePhotoContainerMask() {
guard let layout = currentLayout else {
photoContainer.layer.mask = nil
return
}
let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(8 - inset, 0)
let rect = photoContainer.bounds
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch layout.position {
case .single: return (r, r, r, r)
case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom: return layout.isOutgoing ? (r, s, r, r) : (s, r, r, r)
}
}()
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
let path = UIBezierPath()
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
path.addArc(withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
path.addArc(withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
path.addArc(withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
path.addArc(withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
path.close()
let mask = CAShapeLayer()
mask.frame = rect
mask.path = path.cgPath
photoContainer.layer.mask = mask
}
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [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() await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) { let loaded = await Task.detached(priority: .userInitiated) {
await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
@@ -733,18 +965,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
await ImageLoadLimiter.shared.release() await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard let self, self.photoAttachmentId == attachmentId, let loaded else { return } guard let self else { return }
self.photoView.image = loaded self.photoLoadTasks.removeValue(forKey: attachmentId)
self.photoView.isHidden = false guard let tileIndex = self.tileIndex(for: attachmentId),
self.photoPlaceholderView.isHidden = true tileIndex < self.photoTileImageViews.count,
self.photoActivityIndicator.stopAnimating() let loaded else {
self.isPhotoDownloading = false 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) { private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) {
guard !isPhotoDownloading else { return } if photoDownloadTasks[attachment.id] != nil { return }
let tag = Self.extractTag(from: attachment.preview) let tag = Self.extractTag(from: attachment.preview)
guard !tag.isEmpty, guard !tag.isEmpty,
let storedPassword = message.attachmentPassword, let storedPassword = message.attachmentPassword,
@@ -752,43 +989,78 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return return
} }
isPhotoDownloading = true
photoActivityIndicator.startAnimating()
photoDownloadTask?.cancel()
let attachmentId = attachment.id 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 { do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords) let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords)
await MainActor.run { 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 { if let image {
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
self.photoView.image = image self.setPhotoTileImage(image, at: tileIndex, animated: true)
self.photoView.isHidden = false self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoPlaceholderView.isHidden = true
} else {
self.photoView.image = Self.blurHashImage(from: preview)
self.photoView.isHidden = false
self.photoPlaceholderView.isHidden = self.photoView.image != nil
} }
self.photoActivityIndicator.stopAnimating() self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.isPhotoDownloading = false self.photoTileActivityIndicators[tileIndex].isHidden = true
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
guard let self, self.photoAttachmentId == attachmentId else { return } guard let self else { return }
self.photoActivityIndicator.stopAnimating() self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.isPhotoDownloading = false 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..<photoTileImageViews.count {
photoTileImageViews[index].image = nil
photoTileImageViews[index].isHidden = true
photoTilePlaceholderViews[index].isHidden = true
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileButtons[index].isHidden = true
}
}
private static func extractTag(from preview: String) -> String { private static func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::") let parts = preview.components(separatedBy: "::")
return parts.first ?? preview return parts.first ?? preview
@@ -873,21 +1145,35 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) { private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
if isSentVisible && !wasSentCheckVisible { if isSentVisible && !wasSentCheckVisible {
let pop = CABasicAnimation(keyPath: "transform.scale") checkSentView.alpha = 0
pop.fromValue = NSNumber(value: Float(1.3)) checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
pop.toValue = NSNumber(value: Float(1.0)) UIView.animate(
pop.duration = 0.1 withDuration: 0.16,
pop.timingFunction = CAMediaTimingFunction(name: .easeOut) delay: 0,
checkSentView.layer.add(pop, forKey: "checkPop") options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkSentView.alpha = 1
self.checkSentView.transform = .identity
}
} else if !isSentVisible {
checkSentView.alpha = 1
checkSentView.transform = .identity
} }
if isReadVisible && !wasReadCheckVisible { if isReadVisible && !wasReadCheckVisible {
let pop = CABasicAnimation(keyPath: "transform.scale") checkReadView.alpha = 0
pop.fromValue = NSNumber(value: Float(1.3)) checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
pop.toValue = NSNumber(value: Float(1.0)) UIView.animate(
pop.duration = 0.1 withDuration: 0.16,
pop.timingFunction = CAMediaTimingFunction(name: .easeOut) delay: 0.02,
checkReadView.layer.add(pop, forKey: "checkPop") options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkReadView.alpha = 1
self.checkReadView.transform = .identity
}
} else if !isReadVisible {
checkReadView.alpha = 1
checkReadView.transform = .identity
} }
wasSentCheckVisible = isSentVisible 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 // MARK: - Reuse
override func prepareForReuse() { override func prepareForReuse() {
@@ -931,8 +1226,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
timestampLabel.text = nil timestampLabel.text = nil
checkSentView.image = nil checkSentView.image = nil
checkSentView.isHidden = true checkSentView.isHidden = true
checkSentView.alpha = 1
checkSentView.transform = .identity
checkReadView.image = nil checkReadView.image = nil
checkReadView.isHidden = true checkReadView.isHidden = true
checkReadView.alpha = 1
checkReadView.transform = .identity
clockFrameView.image = nil clockFrameView.image = nil
clockFrameView.isHidden = true clockFrameView.isHidden = true
clockMinView.image = nil clockMinView.image = nil
@@ -940,21 +1239,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
wasSentCheckVisible = false wasSentCheckVisible = false
wasReadCheckVisible = false wasReadCheckVisible = false
statusBackgroundView.isHidden = true statusBackgroundView.isHidden = true
photoAttachmentId = nil resetPhotoTiles()
photoLoadTask?.cancel()
photoLoadTask = nil
photoDownloadTask?.cancel()
photoDownloadTask = nil
isPhotoDownloading = false
photoActivityIndicator.stopAnimating()
photoView.image = nil
replyContainer.isHidden = true replyContainer.isHidden = true
fileContainer.isHidden = true fileContainer.isHidden = true
forwardLabel.isHidden = true forwardLabel.isHidden = true
forwardAvatarView.isHidden = true forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true forwardNameLabel.isHidden = true
photoView.isHidden = true photoContainer.isHidden = true
photoPlaceholderView.isHidden = true
bubbleView.transform = .identity bubbleView.transform = .identity
replyIconView.alpha = 0 replyIconView.alpha = 0
deliveryFailedButton.isHidden = true deliveryFailedButton.isHidden = true