Чат: вертикальное центрирование bubble вложений, tap-to-download аватар и мгновенный показ call-attachment
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ struct MessageFileView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: 220)
|
||||
.contentShape(Rectangle())
|
||||
.task {
|
||||
checkCache()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user