Реализован медиа-коллаж telegram-like, с переполнением и распределением статусов по уровням.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
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<Void, Never>?
|
||||
private var photoDownloadTask: Task<Void, Never>?
|
||||
private var isPhotoDownloading = false
|
||||
private var photoAttachments: [MessageAttachment] = []
|
||||
private var totalPhotoAttachmentCount = 0
|
||||
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
|
||||
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
|
||||
private var downloadingAttachmentIds: Set<String> = []
|
||||
|
||||
// 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..<Self.maxVisiblePhotoTiles {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.isHidden = true
|
||||
photoContainer.addSubview(imageView)
|
||||
|
||||
photoActivityIndicator.color = .white
|
||||
photoActivityIndicator.hidesWhenStopped = true
|
||||
bubbleView.addSubview(photoActivityIndicator)
|
||||
let placeholderView = UIView()
|
||||
placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
||||
placeholderView.isHidden = true
|
||||
photoContainer.addSubview(placeholderView)
|
||||
|
||||
let photoTap = UITapGestureRecognizer(target: self, action: #selector(handlePhotoTap))
|
||||
photoView.addGestureRecognizer(photoTap)
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
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
|
||||
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||
@@ -388,6 +426,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// Bubble color
|
||||
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
|
||||
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
|
||||
|
||||
// Reply quote
|
||||
if let replyName {
|
||||
@@ -511,13 +550,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
|
||||
// Photo
|
||||
photoView.isHidden = !layout.hasPhoto
|
||||
photoPlaceholderView.isHidden = !layout.hasPhoto
|
||||
photoContainer.isHidden = !layout.hasPhoto
|
||||
if layout.hasPhoto {
|
||||
photoView.frame = layout.photoFrame
|
||||
photoPlaceholderView.frame = layout.photoFrame
|
||||
photoActivityIndicator.center = CGPoint(x: layout.photoFrame.midX, y: layout.photoFrame.midY)
|
||||
photoContainer.frame = layout.photoFrame
|
||||
layoutPhotoTiles()
|
||||
}
|
||||
bringStatusOverlayToFront()
|
||||
|
||||
// File
|
||||
fileContainer.isHidden = !layout.hasFile
|
||||
@@ -655,15 +693,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
actions.onRetry(message)
|
||||
}
|
||||
|
||||
@objc private func handlePhotoTap() {
|
||||
guard let message,
|
||||
let actions,
|
||||
let layout = currentLayout,
|
||||
layout.hasPhoto,
|
||||
let attachment = message.attachments.first(where: { $0.type == .image }) else {
|
||||
@objc private func handlePhotoTileTap(_ sender: UIButton) {
|
||||
guard sender.tag >= 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
|
||||
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)
|
||||
}
|
||||
|
||||
for index in 0..<photoTileImageViews.count {
|
||||
let isActiveTile = index < photoAttachments.count
|
||||
let imageView = photoTileImageViews[index]
|
||||
let placeholderView = photoTilePlaceholderViews[index]
|
||||
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) {
|
||||
photoView.image = cached
|
||||
photoView.isHidden = false
|
||||
photoPlaceholderView.isHidden = true
|
||||
photoActivityIndicator.stopAnimating()
|
||||
isPhotoDownloading = false
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
layoutPhotoOverflowOverlay()
|
||||
}
|
||||
|
||||
private func layoutPhotoTiles() {
|
||||
guard !photoAttachments.isEmpty else { return }
|
||||
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
|
||||
}
|
||||
|
||||
photoView.image = Self.blurHashImage(from: attachment.preview)
|
||||
photoView.isHidden = false
|
||||
photoPlaceholderView.isHidden = photoView.image != nil
|
||||
if !isPhotoDownloading {
|
||||
photoActivityIndicator.stopAnimating()
|
||||
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)
|
||||
}
|
||||
startPhotoLoadTask(attachmentId: attachment.id)
|
||||
}()
|
||||
|
||||
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 func startPhotoLoadTask(attachmentId: String) {
|
||||
photoLoadTask?.cancel()
|
||||
photoLoadTask = Task { [weak self] in
|
||||
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()
|
||||
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..<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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user