Реализован UI файлов, звонков и аватаров в пузырьках сообщений — Telegram iOS parity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
129
Rosetta/Features/Chats/ChatDetail/MessageCallView.swift
Normal file
129
Rosetta/Features/Chats/ChatDetail/MessageCallView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user