Реализован медиа-коллаж telegram-like, с переполнением и распределением статусов по уровням.
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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) {
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||||
photoView.image = cached
|
setPhotoTileImage(cached, at: index, animated: false)
|
||||||
photoView.isHidden = false
|
placeholderView.isHidden = true
|
||||||
photoPlaceholderView.isHidden = true
|
indicator.stopAnimating()
|
||||||
photoActivityIndicator.stopAnimating()
|
indicator.isHidden = true
|
||||||
isPhotoDownloading = false
|
} else {
|
||||||
photoLoadTask?.cancel()
|
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
|
||||||
photoLoadTask = nil
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
photoView.image = Self.blurHashImage(from: attachment.preview)
|
let inset: CGFloat = 2
|
||||||
photoView.isHidden = false
|
let r: CGFloat = max(16 - inset, 0)
|
||||||
photoPlaceholderView.isHidden = photoView.image != nil
|
let s: CGFloat = max(8 - inset, 0)
|
||||||
if !isPhotoDownloading {
|
let rect = photoContainer.bounds
|
||||||
photoActivityIndicator.stopAnimating()
|
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) {
|
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
|
||||||
photoLoadTask?.cancel()
|
let spacing: CGFloat = 2
|
||||||
photoLoadTask = Task { [weak self] in
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user