Реализован UI файлов, звонков и аватаров в пузырьках сообщений — Telegram iOS parity

This commit is contained in:
2026-03-29 01:57:45 +05:00
parent 16191ef197
commit 6e927f8871
25 changed files with 1354 additions and 296 deletions

View File

@@ -219,6 +219,14 @@ struct ChatDetailView: View {
}
cellActions.onRetry = { [self] msg in retryMessage(msg) }
cellActions.onRemove = { [self] msg in removeMessage(msg) }
cellActions.onCall = { [self] peerKey in
let peerTitle = dialog?.opponentTitle ?? route.title
let peerUsername = dialog?.opponentUsername ?? route.username
let result = CallManager.shared.startOutgoingCall(
toPublicKey: peerKey, title: peerTitle, username: peerUsername
)
if case .alreadyInCall = result { callErrorMessage = "You are already in another call." }
}
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {
@@ -708,28 +716,11 @@ private extension ChatDetailView {
}
/// Cached tiled pattern color computed once, reused across renders
private static let cachedTiledColor: Color? = {
guard let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage else { return nil }
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
return Color(uiColor: UIColor(patternImage: scaledImage))
}()
/// Tiled chat background with properly scaled tiles (200pt wide)
/// Default chat wallpaper full-screen scaled image.
private var tiledChatBackground: some View {
Group {
if let color = Self.cachedTiledColor {
color.opacity(0.18)
} else {
Color.clear
}
}
Image("ChatWallpaper")
.resizable()
.aspectRatio(contentMode: .fill)
}
// MARK: - Messages

View File

@@ -3,7 +3,11 @@ import UIKit
enum MediaBubbleCornerMaskFactory {
private static let mainRadius: CGFloat = 16
private static let mergedRadius: CGFloat = 8
private static let inset: CGFloat = 2
// Telegram: photo corners use the SAME radius as bubble (16pt).
// The 2pt gap is purely spatial (photoFrame offset), NOT radius reduction.
// Non-concentric circles with same radius + 2pt offset create a natural
// slightly wider gap at corners (~2.83pt at 45°) which matches Telegram.
private static let inset: CGFloat = 0
/// Full bubble mask INCLUDING tail shape used for photo-only messages
/// where the photo fills the entire bubble area.
@@ -86,20 +90,14 @@ enum MediaBubbleCornerMaskFactory {
metrics: metrics
)
var adjusted = base
if BubbleGeometryEngine.hasTail(for: mergeType) {
if outgoing {
adjusted.bottomRight = min(adjusted.bottomRight, mergedRadius)
} else {
adjusted.bottomLeft = min(adjusted.bottomLeft, mergedRadius)
}
}
// Telegram: photo corners use the SAME radii as the bubble body.
// No reduction at tail corner the bubble's raster image behind
// the 2pt gap handles the tail shape visually.
return (
topLeft: max(adjusted.topLeft - inset, 0),
topRight: max(adjusted.topRight - inset, 0),
bottomLeft: max(adjusted.bottomLeft - inset, 0),
bottomRight: max(adjusted.bottomRight - inset, 0)
topLeft: base.topLeft,
topRight: base.topRight,
bottomLeft: base.bottomLeft,
bottomRight: base.bottomRight
)
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
// MARK: - MessageCallView
/// Displays a call attachment inside a message bubble.
///
/// Telegram iOS parity: `ChatMessageCallBubbleContentNode.swift`
/// title (16pt medium), directional arrow, duration, call-back button.
///
/// Desktop parity: `MessageCall.tsx` phone icon, direction label, duration "M:SS".
///
/// Preview format: duration seconds as plain int (0 = missed/rejected).
struct MessageCallView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
let currentPublicKey: String
let actions: MessageCellActions
var body: some View {
HStack(spacing: 0) {
// Left: icon + info
HStack(spacing: 10) {
// Call icon circle (44×44 Telegram parity)
ZStack {
Circle()
.fill(iconBackgroundColor)
.frame(width: 44, height: 44)
Image(systemName: iconName)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
}
// Call metadata
VStack(alignment: .leading, spacing: 2) {
Text(titleText)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
// Status line with directional arrow
HStack(spacing: 4) {
Image(systemName: arrowIconName)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(arrowColor)
Text(subtitleText)
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(.white.opacity(0.6))
}
}
}
Spacer(minLength: 8)
// Right: call-back button
Button {
let peerKey = outgoing ? message.toPublicKey : message.fromPublicKey
actions.onCall(peerKey)
} label: {
ZStack {
Circle()
.fill(Color(hex: 0x248AE6).opacity(0.3))
.frame(width: 44, height: 44)
Image(systemName: "phone.fill")
.font(.system(size: 18))
.foregroundStyle(Color(hex: 0x248AE6))
}
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
// MARK: - Computed Properties
private var durationSeconds: Int {
AttachmentPreviewCodec.parseCallDurationSeconds(attachment.preview)
}
private var isMissedOrRejected: Bool {
durationSeconds == 0
}
private var isIncoming: Bool {
!outgoing
}
private var titleText: String {
if isMissedOrRejected {
return isIncoming ? "Missed Call" : "Cancelled Call"
}
return isIncoming ? "Incoming Call" : "Outgoing Call"
}
private var subtitleText: String {
if isMissedOrRejected {
return "Call was not answered"
}
let minutes = durationSeconds / 60
let seconds = durationSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
private var iconName: String {
if isMissedOrRejected {
return "phone.down.fill"
}
return isIncoming ? "phone.arrow.down.left.fill" : "phone.arrow.up.right.fill"
}
private var iconBackgroundColor: Color {
if isMissedOrRejected {
return Color(hex: 0xFF4747).opacity(0.85)
}
return Color.white.opacity(0.2)
}
private var arrowIconName: String {
isIncoming ? "arrow.down.left" : "arrow.up.right"
}
private var arrowColor: Color {
isMissedOrRejected ? Color(hex: 0xFF4747) : Color(hex: 0x36C033)
}
}

View File

@@ -13,4 +13,5 @@ final class MessageCellActions {
var onScrollToMessage: (String) -> Void = { _ in }
var onRetry: (ChatMessage) -> Void = { _ in }
var onRemove: (ChatMessage) -> Void = { _ in }
var onCall: (String) -> Void = { _ in } // peer public key
}

View File

@@ -31,7 +31,7 @@ struct MessageCellView: View, Equatable {
let hasTail = position == .single || position == .bottom
let visibleAttachments = message.attachments.filter {
$0.type == .image || $0.type == .file || $0.type == .avatar
$0.type == .image || $0.type == .file || $0.type == .avatar || $0.type == .call
}
Group {
@@ -317,6 +317,16 @@ struct MessageCellView: View, Equatable {
)
.padding(.horizontal, 6)
.padding(.top, 4)
case .call:
MessageCallView(
attachment: attachment,
message: message,
outgoing: outgoing,
currentPublicKey: currentPublicKey,
actions: actions
)
.padding(.horizontal, 4)
.padding(.top, 4)
default:
EmptyView()
}

View File

@@ -26,7 +26,7 @@ struct MessageFileView: View {
ZStack {
Circle()
.fill(outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF).opacity(0.2))
.frame(width: 40, height: 40)
.frame(width: 44, height: 44)
if isDownloading {
ProgressView()
@@ -34,11 +34,11 @@ struct MessageFileView: View {
.scaleEffect(0.8)
} else if isDownloaded {
Image(systemName: fileIcon)
.font(.system(size: 18))
.font(.system(size: 20))
.foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF))
} else {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 18))
.font(.system(size: 20))
.foregroundStyle(outgoing ? .white.opacity(0.7) : Color(hex: 0x008BFF).opacity(0.7))
}
}
@@ -46,21 +46,21 @@ struct MessageFileView: View {
// File metadata
VStack(alignment: .leading, spacing: 2) {
Text(fileName)
.font(.system(size: 14, weight: .medium))
.font(.system(size: 16, weight: .regular))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
.lineLimit(1)
if isDownloading {
Text("Downloading...")
.font(.system(size: 12))
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
} else if downloadError {
Text("File expired")
.font(.system(size: 12))
.font(.system(size: 13))
.foregroundStyle(RosettaColors.error)
} else {
Text(formattedFileSize)
.font(.system(size: 12))
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(
outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary
)

View File

@@ -21,8 +21,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
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 fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular)
private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
private static let bubbleMetrics = BubbleMetrics.telegram()
private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets
private static let sendingClockAnimationKey = "clockFrameAnimation"
@@ -66,7 +66,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Timestamp + delivery
private let statusBackgroundView = UIView()
private let statusGradientLayer = CAGradientLayer()
private let timestampLabel = UILabel()
private let checkSentView = UIImageView()
private let checkReadView = UIImageView()
@@ -85,19 +84,27 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileErrorViews: [UIImageView] = []
private var photoTileDownloadArrows: [UIView] = []
private var photoTileButtons: [UIButton] = []
private let photoUploadingOverlayView = UIView()
private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium)
private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel()
// File
// File / Call / Avatar (shared container)
private let fileContainer = UIView()
private let fileIconView = UIView()
private let fileIconSymbolView = UIImageView()
private let fileNameLabel = UILabel()
private let fileSizeLabel = UILabel()
// Call-specific
private let callArrowView = UIImageView()
private let callBackButton = UIButton(type: .custom)
// Avatar-specific
private let avatarImageView = UIImageView()
// Forward header
private let forwardLabel = UILabel()
private let forwardAvatarView = UIView()
@@ -160,18 +167,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(textLabel)
// Timestamp
statusBackgroundView.backgroundColor = .clear
statusBackgroundView.layer.cornerRadius = 7
// Telegram: solid pill UIColor(white:0, alpha:0.3), diameter 18 radius 9
statusBackgroundView.backgroundColor = UIColor(white: 0.0, alpha: 0.3)
statusBackgroundView.layer.cornerRadius = 9
statusBackgroundView.layer.cornerCurve = .continuous
statusBackgroundView.clipsToBounds = true
statusBackgroundView.isHidden = true
statusGradientLayer.colors = [
UIColor.black.withAlphaComponent(0.0).cgColor,
UIColor.black.withAlphaComponent(0.5).cgColor
]
statusGradientLayer.startPoint = CGPoint(x: 0, y: 0)
statusGradientLayer.endPoint = CGPoint(x: 1, y: 1)
statusBackgroundView.layer.insertSublayer(statusGradientLayer, at: 0)
bubbleView.addSubview(statusBackgroundView)
timestampLabel.font = Self.timestampFont
@@ -225,6 +226,25 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
errorView.isHidden = true
photoContainer.addSubview(errorView)
// Download arrow overlay shown when photo not yet downloaded
let downloadArrow = UIView()
downloadArrow.isHidden = true
downloadArrow.isUserInteractionEnabled = false
let arrowCircle = UIView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
arrowCircle.backgroundColor = UIColor.black.withAlphaComponent(0.5)
arrowCircle.layer.cornerRadius = 24
arrowCircle.tag = 1001
downloadArrow.addSubview(arrowCircle)
let arrowImage = UIImageView(
image: UIImage(systemName: "arrow.down",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
)
arrowImage.tintColor = .white
arrowImage.contentMode = .center
arrowImage.frame = arrowCircle.bounds
arrowCircle.addSubview(arrowImage)
photoContainer.addSubview(downloadArrow)
let button = UIButton(type: .custom)
button.tag = index
button.isHidden = true
@@ -235,6 +255,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator)
photoTileErrorViews.append(errorView)
photoTileDownloadArrows.append(downloadArrow)
photoTileButtons.append(button)
}
@@ -263,7 +284,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconView.layer.cornerRadius = 20
fileIconView.layer.cornerRadius = 22
fileIconSymbolView.tintColor = .white
fileIconSymbolView.contentMode = .scaleAspectFit
fileIconView.addSubview(fileIconSymbolView)
@@ -274,6 +295,38 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
fileSizeLabel.font = Self.fileSizeFont
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
fileContainer.addSubview(fileSizeLabel)
// Call arrow (small directional arrow left of duration)
callArrowView.contentMode = .scaleAspectFit
callArrowView.isHidden = true
fileContainer.addSubview(callArrowView)
// Call-back button (phone icon on right side)
let callCircle = UIView()
callCircle.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 0.3) // #248AE6 @ 0.3
callCircle.layer.cornerRadius = 22
callCircle.isUserInteractionEnabled = false
callCircle.tag = 2001
callBackButton.addSubview(callCircle)
let callPhoneIcon = UIImageView(
image: UIImage(systemName: "phone.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium))
)
callPhoneIcon.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
callPhoneIcon.contentMode = .center
callPhoneIcon.tag = 2002
callBackButton.addSubview(callPhoneIcon)
callBackButton.addTarget(self, action: #selector(callBackTapped), for: .touchUpInside)
callBackButton.isHidden = true
fileContainer.addSubview(callBackButton)
// Avatar image (circular, replaces icon for avatar type)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarImageView.layer.cornerRadius = 22
avatarImageView.isHidden = true
fileContainer.addSubview(avatarImageView)
bubbleView.addSubview(fileContainer)
// Forward header
@@ -423,40 +476,120 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
if let callAtt = message.attachments.first(where: { $0.type == .call }) {
let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview)
let isOutgoing = currentLayout?.isOutgoing ?? false
let isError = durationSec == 0
let isMissed = durationSec == 0
let isIncoming = !isOutgoing
avatarImageView.isHidden = true
fileIconView.isHidden = false
if isError {
fileIconView.backgroundColor = UIColor.systemRed.withAlphaComponent(0.85)
fileIconSymbolView.image = UIImage(systemName: "xmark")
fileNameLabel.text = isOutgoing ? "Rejected call" : "Missed call"
fileSizeLabel.text = "Call was not answered or was rejected"
fileSizeLabel.textColor = UIColor.systemRed.withAlphaComponent(0.95)
// Icon
if isMissed {
fileIconView.backgroundColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.85)
fileIconSymbolView.image = UIImage(
systemName: "phone.down.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
)
} else {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(
systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill"
systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
)
fileNameLabel.text = isOutgoing ? "Outgoing call" : "Incoming call"
fileSizeLabel.text = Self.formattedDuration(seconds: durationSec)
}
// Title (16pt medium Telegram parity)
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
if isMissed {
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
} else {
fileNameLabel.text = isIncoming ? "Incoming Call" : "Outgoing Call"
}
// Duration with arrow
if isMissed {
fileSizeLabel.text = "Call was not answered"
fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95)
} else {
fileSizeLabel.text = " " + Self.formattedDuration(seconds: durationSec)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
}
// Directional arrow (green/red)
let arrowName = isIncoming ? "arrow.down.left" : "arrow.up.right"
callArrowView.image = UIImage(
systemName: arrowName,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
)
callArrowView.tintColor = isMissed
? UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 1)
: UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033
callArrowView.isHidden = false
callBackButton.isHidden = false
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName
fileSizeLabel.text = ""
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = parsed.fileName
fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
} else if message.attachments.first(where: { $0.type == .avatar }) != nil {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
callArrowView.isHidden = true
callBackButton.isHidden = true
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "Avatar"
fileSizeLabel.text = ""
fileSizeLabel.text = "Shared profile photo"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
callArrowView.isHidden = true
callBackButton.isHidden = true
// Try to load cached avatar image or blurhash
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) {
avatarImageView.image = cached
avatarImageView.isHidden = false
fileIconView.isHidden = true
} else {
// Try blurhash placeholder
let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview)
if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) {
avatarImageView.image = blurImg
avatarImageView.isHidden = false
fileIconView.isHidden = true
} else if !hash.isEmpty {
// Decode blurhash on background
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
let messageId = message.id
Task.detached(priority: .userInitiated) {
guard let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) else { return }
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
self.avatarImageView.image = decoded
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
}
}
} else {
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
}
}
} else {
avatarImageView.isHidden = true
fileIconView.isHidden = false
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "File"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
callArrowView.isHidden = true
callBackButton.isHidden = true
}
} else {
fileContainer.isHidden = true
@@ -581,14 +714,41 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
bringStatusOverlayToFront()
// File
// File / Call / Avatar
fileContainer.isHidden = !layout.hasFile
if layout.hasFile {
fileContainer.frame = layout.fileFrame
fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40)
fileIconSymbolView.frame = CGRect(x: 9, y: 9, width: 22, height: 22)
fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17)
fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15)
let isCallType = message?.attachments.contains(where: { $0.type == .call }) ?? false
let fileW = layout.fileFrame.width
fileIconView.frame = CGRect(x: 9, y: 6, width: 44, height: 44)
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
let isAvatarType = message?.attachments.contains(where: { $0.type == .avatar }) ?? false
if isCallType {
// Call layout: icon + title/status + call-back button on right
let callBtnSize: CGFloat = 44
let callBtnRight: CGFloat = 8
let textRight = callBtnRight + callBtnSize + 4
fileNameLabel.frame = CGRect(x: 63, y: 8, width: fileW - 63 - textRight, height: 20)
fileSizeLabel.frame = CGRect(x: 78, y: 30, width: fileW - 78 - textRight, height: 16)
callArrowView.frame = CGRect(x: 63, y: 33, width: 12, height: 12)
callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: 8, width: callBtnSize, height: callBtnSize)
callBackButton.viewWithTag(2001)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize)
callBackButton.viewWithTag(2002)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize)
avatarImageView.isHidden = true
} else if isAvatarType {
// Avatar layout: circular image (44pt) + title + description
avatarImageView.frame = CGRect(x: 9, y: 14, width: 44, height: 44)
fileNameLabel.frame = CGRect(x: 63, y: 18, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: 39, width: fileW - 75, height: 16)
} else {
// File layout: icon + title + size
fileNameLabel.frame = CGRect(x: 63, y: 9, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: 30, width: fileW - 75, height: 16)
avatarImageView.isHidden = true
}
}
// Forward
@@ -652,6 +812,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return String(format: "%d:%02d", minutes, secs)
}
private static func formattedFileSize(bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) }
return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024))
}
// MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting(
@@ -725,6 +892,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onRetry(message)
}
@objc private func callBackTapped() {
guard let message, let actions else { return }
let isOutgoing = currentLayout?.isOutgoing ?? false
let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
actions.onCall(peerKey)
}
@objc private func handlePhotoTileTap(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < photoAttachments.count,
let message,
@@ -795,6 +969,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let placeholderView = photoTilePlaceholderViews[index]
let indicator = photoTileActivityIndicators[index]
let errorView = photoTileErrorViews[index]
let downloadArrow = photoTileDownloadArrows[index]
let button = photoTileButtons[index]
button.isHidden = !isActiveTile
@@ -806,6 +981,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = true
continue
}
@@ -817,6 +993,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = true
} else {
if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
setPhotoTileImage(blur, at: index, animated: false)
@@ -830,14 +1007,18 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = false
downloadArrow.isHidden = true
} else if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating()
indicator.isHidden = false
errorView.isHidden = true
downloadArrow.isHidden = true
} else {
// Not downloaded, not downloading show download arrow
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = true
downloadArrow.isHidden = false
}
startPhotoLoadTask(attachment: attachment)
}
@@ -861,6 +1042,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
x: frame.midX - 10, y: frame.midY - 10,
width: 20, height: 20
)
// Download arrow: full tile frame, circle centered
let arrow = photoTileDownloadArrows[index]
arrow.frame = frame
if let circle = arrow.viewWithTag(1001) {
circle.center = CGPoint(x: frame.width / 2, y: frame.height / 2)
}
}
photoUploadingOverlayView.frame = photoContainer.bounds
photoUploadingIndicator.center = CGPoint(
@@ -1120,6 +1307,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
}
}
@@ -1135,6 +1323,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTileActivityIndicators[tileIndex].stopAnimating()
photoTileActivityIndicators[tileIndex].isHidden = true
photoTileErrorViews[tileIndex].isHidden = false
photoTileDownloadArrows[tileIndex].isHidden = true
}
return
}
@@ -1146,6 +1335,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTileActivityIndicators[tileIndex].startAnimating()
photoTileActivityIndicators[tileIndex].isHidden = false
photoTileErrorViews[tileIndex].isHidden = true
photoTileDownloadArrows[tileIndex].isHidden = true
}
photoDownloadTasks[attachmentId] = Task { [weak self] in
@@ -1174,6 +1364,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
} catch {
await MainActor.run {
@@ -1188,6 +1379,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = false
self.photoTileDownloadArrows[tileIndex].isHidden = true
}
}
}
@@ -1225,6 +1417,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileDownloadArrows[index].isHidden = true
photoTileButtons[index].isHidden = true
photoTileButtons[index].layer.mask = nil
}
@@ -1362,10 +1555,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
width: contentRect.width + insets.left + insets.right,
height: contentRect.height + insets.top + insets.bottom
)
CATransaction.begin()
CATransaction.setDisableActions(true)
statusGradientLayer.frame = statusBackgroundView.bounds
CATransaction.commit()
}
private func bringStatusOverlayToFront() {
@@ -1429,6 +1618,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
resetPhotoTiles()
replyContainer.isHidden = true
fileContainer.isHidden = true
callArrowView.isHidden = true
callBackButton.isHidden = true
avatarImageView.image = nil
avatarImageView.isHidden = true
fileIconView.isHidden = false
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true