Чат: вертикальное центрирование bubble вложений, tap-to-download аватар и мгновенный показ call-attachment

This commit is contained in:
2026-03-29 15:29:13 +05:00
parent 6e927f8871
commit 3b26176875
218 changed files with 14952 additions and 237 deletions

View File

@@ -5,9 +5,7 @@ import SwiftUI
/// 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".
/// NO icon circle on left, title at left edge, arrow + duration below, phone icon right.
///
/// Preview format: duration seconds as plain int (0 = missed/rejected).
struct MessageCallView: View {
@@ -20,59 +18,44 @@ struct MessageCallView: View {
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)
// Left: title + arrow/duration Telegram: NO icon circle
VStack(alignment: .leading, spacing: 2) {
Text(titleText)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
Image(systemName: iconName)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
}
HStack(spacing: 4) {
Image(systemName: arrowIconName)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(arrowColor)
// 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))
}
Text(subtitleText)
.font(.system(size: 13))
.foregroundStyle(
isMissedOrRejected
? Color(hex: 0xFF4747)
: (outgoing ? .white.opacity(0.6) : RosettaColors.Adaptive.textSecondary)
)
}
}
Spacer(minLength: 8)
// Right: call-back button
// Right: phone icon only Telegram: NO circle background
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))
}
Image(systemName: "phone.fill")
.font(.system(size: 22))
.foregroundStyle(outgoing ? .white : Color(hex: 0x248AE6))
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.leading, 11)
.padding(.trailing, 10)
.padding(.vertical, 6)
}
// MARK: - Computed Properties
@@ -98,27 +81,13 @@ struct MessageCallView: View {
private var subtitleText: String {
if isMissedOrRejected {
return "Call was not answered"
return "Declined"
}
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"
}

View File

@@ -71,7 +71,6 @@ struct MessageFileView: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 220)
.contentShape(Rectangle())
.task {
checkCache()

View File

@@ -301,18 +301,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
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)
// Call-back button Telegram: just a phone icon, NO circle background
let callPhoneIcon = UIImageView(
image: UIImage(systemName: "phone.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium))
withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))
)
callPhoneIcon.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
callPhoneIcon.tintColor = .white
callPhoneIcon.contentMode = .center
callPhoneIcon.tag = 2002
callBackButton.addSubview(callPhoneIcon)
@@ -320,6 +314,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
callBackButton.isHidden = true
fileContainer.addSubview(callBackButton)
// Tap on entire file container triggers call-back for call bubbles
let fileTap = UITapGestureRecognizer(target: self, action: #selector(fileContainerTapped))
fileContainer.addGestureRecognizer(fileTap)
fileContainer.isUserInteractionEnabled = true
// Avatar image (circular, replaces icon for avatar type)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
@@ -329,6 +328,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(fileContainer)
// Listen for avatar download trigger (tap-to-download, Android parity)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAttachmentDownload(_:)),
name: .triggerAttachmentDownload, object: nil
)
// Forward header
forwardLabel.font = Self.forwardLabelFont
forwardLabel.text = "Forwarded message"
@@ -478,23 +483,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let isOutgoing = currentLayout?.isOutgoing ?? false
let isMissed = durationSec == 0
let isIncoming = !isOutgoing
avatarImageView.isHidden = true
fileIconView.isHidden = false
// 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",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
)
}
// Telegram: call bubbles have NO icon circle on the left
avatarImageView.isHidden = true
fileIconView.isHidden = true
// Title (16pt medium Telegram parity)
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
@@ -506,10 +498,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Duration with arrow
if isMissed {
fileSizeLabel.text = "Call was not answered"
fileSizeLabel.text = "Declined"
fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95)
} else {
fileSizeLabel.text = " " + Self.formattedDuration(seconds: durationSec)
fileSizeLabel.text = Self.formattedDuration(seconds: durationSec)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
}
@@ -524,6 +516,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
: UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033
callArrowView.isHidden = false
callBackButton.isHidden = false
// Call button color: outgoing = white, incoming = blue (Telegram accentControlColor)
let callPhoneView = callBackButton.viewWithTag(2002) as? UIImageView
if isOutgoing {
callPhoneView?.tintColor = .white
} else {
callPhoneView?.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1)
}
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
avatarImageView.isHidden = true
@@ -539,46 +539,69 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = "Avatar"
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
// Android parity: show cached image OR blurhash placeholder.
// NO auto-download user must tap to download (via .triggerAttachmentDownload).
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) {
avatarImageView.image = cached
avatarImageView.isHidden = false
fileIconView.isHidden = true
fileSizeLabel.text = "Shared profile photo"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
} else {
// Try blurhash placeholder
let isOutgoing = currentLayout?.isOutgoing ?? false
if isOutgoing {
// Own avatar already uploaded, just loading from disk
fileSizeLabel.text = "Shared profile photo"
} else {
// Incoming avatar needs download on tap (Android parity)
fileSizeLabel.text = "Tap to download"
}
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
// Show blurhash placeholder (decode async if not cached)
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")
}
// Async: decode blurhash + try disk cache (NO CDN download tap required)
let messageId = message.id
let attId = avatarAtt.id
Task.detached(priority: .userInitiated) {
// 1. Decode blurhash immediately (~2ms)
if !hash.isEmpty {
if let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) {
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
}
}
}
// 2. Try disk cache only (previously downloaded)
if let diskImage = AttachmentCache.shared.loadImage(forAttachmentId: attId) {
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
self.avatarImageView.image = diskImage
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo"
}
}
// CDN download is triggered by user tap via .triggerAttachmentDownload
}
}
} else {
avatarImageView.isHidden = true
@@ -721,32 +744,52 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
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
let fileContainerH = layout.fileFrame.height
// For file-only messages, fileContainer spans the ENTIRE bubble (fileH = bubbleH).
// Centering in fileContainerH gives visually perfect centering within the bubble.
// The timestamp is a separate view positioned at the bottom no collision risk
// because content is left-aligned and timestamp is right-aligned.
let centerableH = fileContainerH
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)
// Telegram-exact call layout: NO icon circle, text at left edge
// Source: ChatMessageCallBubbleContentNode.swift
fileIconView.isHidden = true
let callBtnSize: CGFloat = 36
let callBtnRight: CGFloat = 10
let textRight = callBtnRight + callBtnSize + 8
// Vertically center content above timestamp
let contentH: CGFloat = 36 // title(20) + gap(2) + subtitle(14)
let topY = max(0, (centerableH - contentH) / 2)
fileNameLabel.frame = CGRect(x: 11, y: topY, width: fileW - 11 - textRight, height: 20)
fileSizeLabel.frame = CGRect(x: 25, y: topY + 22, width: fileW - 25 - textRight, height: 14)
callArrowView.frame = CGRect(x: 12, y: topY + 25, width: 10, height: 10)
// Call button: vertically centered in same area
let btnY = max(0, (centerableH - callBtnSize) / 2)
callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: btnY, 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)
// Avatar layout: vertically centered icon (44pt) + title + description
let contentH: CGFloat = 44 // icon height dominates
let topY = max(0, (centerableH - contentH) / 2)
fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
avatarImageView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
let textTopY = topY + 4
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, 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)
// File layout: vertically centered icon + title + size
let contentH: CGFloat = 44 // icon height dominates
let topY = max(0, (centerableH - contentH) / 2)
fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44)
fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22)
let textTopY = topY + 4
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
avatarImageView.isHidden = true
}
}
@@ -819,6 +862,70 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024))
}
/// Downloads avatar from CDN, decrypts, caches to disk, and returns the image.
/// Shared logic with `MessageAvatarView.downloadAvatar()`.
private static func downloadAndCacheAvatar(
tag: String, attachmentId: String, storedPassword: String, senderKey: String
) async -> UIImage? {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
guard let image = decryptAvatarImage(encryptedString: encryptedString, passwords: passwords)
else { return nil }
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
// Android parity: save avatar to sender's profile after download
if let jpegData = image.jpegData(compressionQuality: 0.85) {
let base64 = jpegData.base64EncodedString()
await MainActor.run {
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
}
}
return image
} catch {
return nil
}
}
/// Tries each password candidate to decrypt avatar image data.
private static func decryptAvatarImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
) else { continue }
if let img = parseAvatarImageData(data) { return img }
}
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) else { continue }
if let img = parseAvatarImageData(data) { return img }
}
return nil
}
/// Parses avatar image data (data URI or raw base64 or raw bytes).
private static func parseAvatarImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8) {
if str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part),
let img = AttachmentCache.downsampledImage(from: imageData) {
return img
}
} else if let imageData = Data(base64Encoded: str),
let img = AttachmentCache.downsampledImage(from: imageData) {
return img
}
}
return AttachmentCache.downsampledImage(from: data)
}
// MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting(
@@ -899,6 +1006,59 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onCall(peerKey)
}
@objc private func handleAttachmentDownload(_ notif: Notification) {
guard let id = notif.object as? String,
let message,
let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
avatarAtt.id == id else { return }
// Already downloaded?
if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
// Download from CDN
let tag = AttachmentPreviewCodec.downloadTag(from: avatarAtt.preview)
guard !tag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else { return }
// Show loading state
fileSizeLabel.text = "Downloading..."
let messageId = message.id
let senderKey = message.fromPublicKey
Task.detached(priority: .userInitiated) {
let downloaded = await Self.downloadAndCacheAvatar(
tag: tag, attachmentId: id,
storedPassword: password, senderKey: senderKey
)
await MainActor.run { [weak self] in
guard let self, self.message?.id == messageId else { return }
if let downloaded {
self.avatarImageView.image = downloaded
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo"
} else {
self.fileSizeLabel.text = "Tap to retry"
}
}
}
}
@objc private func fileContainerTapped() {
guard let message, let actions else { return }
let isCallType = message.attachments.contains { $0.type == .call }
if isCallType {
// Tap anywhere on call bubble call back
let isOutgoing = currentLayout?.isOutgoing ?? false
let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey
actions.onCall(peerKey)
} else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) {
// Tap on avatar bubble trigger download (Android parity)
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: avatarAtt.id)
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
// Tap on file bubble trigger download/share
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: fileAtt.id)
}
}
@objc private func handlePhotoTileTap(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < photoAttachments.count,
let message,