Фикс: добавлен вызов applyInsertionAnimations + плавное появление баблов как в Telegram

This commit is contained in:
2026-04-17 00:27:39 +05:00
parent bb7be99f44
commit 01399a4571
9 changed files with 1270 additions and 257 deletions

View File

@@ -74,6 +74,7 @@ struct MessageCellLayout: Sendable {
let forwardNameFrame: CGRect let forwardNameFrame: CGRect
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
let additionalForwardItems: [ForwardItemFrame] // items 2+ for multi-forward (Android parity)
// MARK: - Date Header (optional) // MARK: - Date Header (optional)
@@ -104,6 +105,29 @@ struct MessageCellLayout: Sendable {
} }
} }
/// Forward item info for multi-forward layout (Android parity).
struct ForwardItemInfo: Sendable {
let senderName: String
let senderKey: String
let caption: String
let chachaKeyPlain: String
let attachments: [ReplyAttachmentData]
let imageCount: Int
let fileCount: Int
let voiceCount: Int
}
/// Pre-calculated frame for a single additional forward item (items 2+).
struct ForwardItemFrame: Sendable {
let dividerY: CGFloat // Y of horizontal divider (bubble coords)
let headerFrame: CGRect // "Forwarded from" label frame
let avatarFrame: CGRect // Mini avatar frame
let nameFrame: CGRect // Sender name label frame
let textFrame: CGRect // Caption text frame
let photoFrame: CGRect // Photo collage frame
let info: ForwardItemInfo // Full data for rendering
}
// MARK: - Layout Calculation (Thread-Safe) // MARK: - Layout Calculation (Thread-Safe)
extension MessageCellLayout { extension MessageCellLayout {
@@ -139,6 +163,7 @@ extension MessageCellLayout {
let forwardSenderName: String // forward sender name (for dynamic min bubble width) let forwardSenderName: String // forward sender name (for dynamic min bubble width)
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
let allForwardItems: [ForwardItemInfo] // all forward items for multi-forward
let senderName: String // group sender name (for min bubble width) let senderName: String // group sender name (for min bubble width)
let isGroupAdmin: Bool // sender is group owner (admin badge takes extra space) let isGroupAdmin: Bool // sender is group owner (admin badge takes extra space)
} }
@@ -259,6 +284,7 @@ extension MessageCellLayout {
forwardNameFrame: .zero, forwardNameFrame: .zero,
forwardChachaKeyPlain: "", forwardChachaKeyPlain: "",
forwardAttachments: [], forwardAttachments: [],
additionalForwardItems: [],
showsDateHeader: config.showsDateHeader, showsDateHeader: config.showsDateHeader,
dateHeaderText: config.dateHeaderText, dateHeaderText: config.dateHeaderText,
dateHeaderHeight: dateH, dateHeaderHeight: dateH,
@@ -398,7 +424,8 @@ extension MessageCellLayout {
let replyBottomGap: CGFloat = 3 let replyBottomGap: CGFloat = 3
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0 let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
var photoH: CGFloat = 0 var photoH: CGFloat = 0
let forwardHeaderH: CGFloat = config.isForward ? 41 : 0 // Two-row: "Forwarded from" + [avatar] Name
let forwardHeaderH: CGFloat = config.isForward ? 38 : 0
var fileH: CGFloat = CGFloat(config.fileCount) * 52 var fileH: CGFloat = CGFloat(config.fileCount) * 52
+ CGFloat(config.callCount) * 42 + CGFloat(config.callCount) * 42
+ CGFloat(config.avatarCount) * 52 + CGFloat(config.avatarCount) * 52
@@ -585,14 +612,14 @@ extension MessageCellLayout {
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 { if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
bubbleH = max(bubbleH, 37) bubbleH = max(bubbleH, 37)
} }
// Forward header needs minimum width for "Forwarded from" + avatar + name // Forward header min width: "Forwarded from" label + [avatar] Name row
if config.isForward { if config.isForward {
let fwdLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular) let fwdLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
let headerW = ("Forwarded from" as NSString).size(withAttributes: [.font: fwdLabelFont]).width let headerW = ("Forwarded from" as NSString).size(withAttributes: [.font: fwdLabelFont]).width
let nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont]).width let nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont]).width
// Header: 10pt left + text + 10pt right // Row 1: 10pt + "Forwarded from" + 10pt
// Name: 10pt left + 16pt avatar + 4pt gap + name + 10pt right // Row 2: 10pt + 16pt avatar + 4pt + name + 10pt
let fwdMinW = ceil(max(headerW + 20, nameW + 40)) let fwdMinW = ceil(max(headerW + 20, nameW + 40))
bubbleW = max(bubbleW, min(fwdMinW, effectiveMaxBubbleWidth)) bubbleW = max(bubbleW, min(fwdMinW, effectiveMaxBubbleWidth))
} }
@@ -619,6 +646,61 @@ extension MessageCellLayout {
} }
} }
// Multi-forward: add height for additional items (Android parity).
// Item 0 is already included in bubbleH above. Items 1+ are appended.
// IMPORTANT: widen bubbleW FIRST (for sender names), THEN compute heights
// using the final bubbleW. Both passes must use the same width to avoid
// collage height mismatch between height estimation and frame positioning.
var additionalFwdH: CGFloat = 0
if config.isForward && config.allForwardItems.count > 1 {
// Step 1: Widen bubbleW for additional senders' two-row headers
let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
let fwdLabelW = ("Forwarded from" as NSString).size(
withAttributes: [.font: UIFont.systemFont(ofSize: 14, weight: .regular)]
).width
for i in 1..<config.allForwardItems.count {
let nameW = (config.allForwardItems[i].senderName as NSString).size(
withAttributes: [.font: fwdNameFont]
).width
let neededW = ceil(max(fwdLabelW + 20, nameW + 40))
bubbleW = max(bubbleW, min(neededW, effectiveMaxBubbleWidth))
}
// Step 2: Compute heights with final bubbleW
let fwdTextFont = UIFont.systemFont(ofSize: 17, weight: .regular)
let fwdMaxTextW = bubbleW - leftPad - rightPad
for i in 1..<config.allForwardItems.count {
let item = config.allForwardItems[i]
additionalFwdH += 8 // divider spacing
additionalFwdH += 38 // forward header (two-row)
if item.imageCount > 0 {
let ph = Self.collageHeight(
count: item.imageCount, width: bubbleW - 4,
maxHeight: mediaDimensions.maxHeight,
minHeight: mediaDimensions.minHeight
)
additionalFwdH += ph + 4 // photo + 2pt inset top/bottom
}
additionalFwdH += CGFloat(item.fileCount) * 52
additionalFwdH += CGFloat(item.voiceCount) * 44
let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
let textSize = (cleanCaption as NSString).boundingRect(
with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: fwdTextFont],
context: nil
).size
additionalFwdH += 2 + ceil(textSize.height) + 4
}
}
}
bubbleH += additionalFwdH
// Date header adds height above the bubble. // Date header adds height above the bubble.
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0 let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
@@ -765,9 +847,79 @@ extension MessageCellLayout {
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH) let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
let fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH) let fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH)
let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17) // Two-row header: "Forwarded from" on line 1, [avatar] Name on line 2
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16) let fwdHeaderFrame = CGRect(x: 10, y: 5, width: bubbleW - 20, height: 14)
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17) let fwdAvatarFrame = CGRect(x: 10, y: 20, width: 16, height: 16)
let fwdNameFrame = CGRect(x: 30, y: 20, width: bubbleW - 40, height: 17)
// Multi-forward: compute per-item frame positions (items 2+).
var additionalFwdLayouts: [ForwardItemFrame] = []
if config.isForward && config.allForwardItems.count > 1 {
let fwdTextFont = UIFont.systemFont(ofSize: 17, weight: .regular)
let fwdMaxTextW = bubbleW - leftPad - rightPad
// Where does the first item's content end?
var currentY: CGFloat
if !config.text.isEmpty {
currentY = textFrame.maxY + 4
} else if photoH > 0 {
currentY = photoFrame.maxY + 2
} else if fileH > 0 {
currentY = fileFrame.maxY
} else {
currentY = forwardHeaderH
}
for i in 1..<config.allForwardItems.count {
let item = config.allForwardItems[i]
let divY = currentY
currentY += 8
// Two-row header: "Forwarded from" + [avatar] Name
let hdrFrame = CGRect(x: 10, y: currentY + 5, width: bubbleW - 20, height: 14)
let avtFrame = CGRect(x: 10, y: currentY + 20, width: 16, height: 16)
let nmFrame = CGRect(x: 30, y: currentY + 20, width: bubbleW - 40, height: 17)
currentY += 38
var pFrame: CGRect = .zero
if item.imageCount > 0 {
let ph = Self.collageHeight(
count: item.imageCount, width: bubbleW - 4,
maxHeight: mediaDimensions.maxHeight,
minHeight: mediaDimensions.minHeight
)
pFrame = CGRect(x: 2, y: currentY + 2, width: bubbleW - 4, height: ph)
currentY += ph + 4
}
currentY += CGFloat(item.fileCount) * 52
currentY += CGFloat(item.voiceCount) * 44
var tFrame: CGRect = .zero
let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
let textSize = (cleanCaption as NSString).boundingRect(
with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: fwdTextFont],
context: nil
).size
tFrame = CGRect(x: leftPad, y: currentY + 2,
width: fwdMaxTextW, height: ceil(textSize.height))
currentY += 2 + ceil(textSize.height) + 4
}
additionalFwdLayouts.append(ForwardItemFrame(
dividerY: divY,
headerFrame: hdrFrame,
avatarFrame: avtFrame,
nameFrame: nmFrame,
textFrame: tFrame,
photoFrame: pFrame,
info: item
))
}
}
let layout = MessageCellLayout( let layout = MessageCellLayout(
totalHeight: totalH, totalHeight: totalH,
@@ -808,6 +960,7 @@ extension MessageCellLayout {
forwardNameFrame: fwdNameFrame, forwardNameFrame: fwdNameFrame,
forwardChachaKeyPlain: config.forwardChachaKeyPlain, forwardChachaKeyPlain: config.forwardChachaKeyPlain,
forwardAttachments: config.forwardAttachments, forwardAttachments: config.forwardAttachments,
additionalForwardItems: additionalFwdLayouts,
showsDateHeader: config.showsDateHeader, showsDateHeader: config.showsDateHeader,
dateHeaderText: config.dateHeaderText, dateHeaderText: config.dateHeaderText,
dateHeaderHeight: dateHeaderH, dateHeaderHeight: dateHeaderH,
@@ -1137,6 +1290,7 @@ extension MessageCellLayout {
var forwardSenderName = "" var forwardSenderName = ""
var forwardChachaKeyPlain = "" var forwardChachaKeyPlain = ""
var forwardAttachments: [ReplyAttachmentData] = [] var forwardAttachments: [ReplyAttachmentData] = []
var allForwardItems: [ForwardItemInfo] = []
if isForward, if isForward,
let att = message.attachments.first(where: { $0.type == .messages }), let att = message.attachments.first(where: { $0.type == .messages }),
let data = att.blob.data(using: .utf8), let data = att.blob.data(using: .utf8),
@@ -1160,6 +1314,32 @@ extension MessageCellLayout {
} else { } else {
forwardSenderName = DialogRepository.shared.dialogs[senderKey]?.opponentTitle ?? String(senderKey.prefix(8)) + "" forwardSenderName = DialogRepository.shared.dialogs[senderKey]?.opponentTitle ?? String(senderKey.prefix(8)) + ""
} }
// Build ForwardItemInfo for ALL items (multi-forward Android parity)
for reply in replies {
let rKey = reply.publicKey
let rName: String
if rKey == currentPublicKey {
rName = "You"
} else if rKey == opponentPublicKey {
rName = opponentTitle.isEmpty ? String(rKey.prefix(8)) + "" : opponentTitle
} else {
rName = DialogRepository.shared.dialogs[rKey]?.opponentTitle ?? String(rKey.prefix(8)) + ""
}
let rText = reply.message.trimmingCharacters(in: .whitespacesAndNewlines)
let rCaption = (!rText.isEmpty && !isGarbageOrEncrypted(rText))
? EmojiParser.replaceShortcodes(in: rText) : ""
allForwardItems.append(ForwardItemInfo(
senderName: rName,
senderKey: rKey,
caption: rCaption,
chachaKeyPlain: reply.chacha_key_plain,
attachments: reply.attachments,
imageCount: reply.attachments.filter { $0.type == 0 }.count,
fileCount: reply.attachments.filter { $0.type == 2 }.count,
voiceCount: reply.attachments.filter { $0.type == 5 }.count
))
}
} }
// Parse image dimensions from preview field (format: "tag::blurhash::WxH") // Parse image dimensions from preview field (format: "tag::blurhash::WxH")
@@ -1217,6 +1397,7 @@ extension MessageCellLayout {
forwardSenderName: forwardSenderName, forwardSenderName: forwardSenderName,
forwardChachaKeyPlain: forwardChachaKeyPlain, forwardChachaKeyPlain: forwardChachaKeyPlain,
forwardAttachments: forwardAttachments, forwardAttachments: forwardAttachments,
allForwardItems: allForwardItems,
senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "", senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "",
isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey) isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey)
) )

View File

@@ -407,54 +407,85 @@ final class SessionManager {
/// Sends current user's avatar to a chat as a message attachment. /// Sends current user's avatar to a chat as a message attachment.
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` loads avatar attaches as AVATAR type /// Desktop parity: `onClickCamera()` in `DialogInput.tsx` loads avatar attaches as AVATAR type
/// `prepareAttachmentsToSend()` encrypts blob uploads to transport sends PacketMessage. /// `prepareAttachmentsToSend()` encrypts blob uploads to transport sends PacketMessage.
func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws { /// Sends an avatar to a chat as a message attachment.
/// - Parameter avatarSourceKey: Key to load avatar from. `nil` = personal avatar (currentPublicKey).
/// Pass groupDialogKey to send the group's avatar instead.
func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "", avatarSourceKey: String? = nil) async throws {
guard let privKey = privateKeyHex, let hash = privateKeyHash else { guard let privKey = privateKeyHex, let hash = privateKeyHash else {
Self.logger.error("📤 Cannot send avatar — missing keys") Self.logger.error("📤 Cannot send avatar — missing keys")
throw CryptoError.decryptionFailed throw CryptoError.decryptionFailed
} }
let sourceKey = avatarSourceKey ?? currentPublicKey
// Load avatar from local storage as base64 (desktop: avatars[0].avatar) // Load avatar from local storage as base64 (desktop: avatars[0].avatar)
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: currentPublicKey) else { guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: sourceKey) else {
Self.logger.error("📤 No avatar to send") Self.logger.error("📤 No avatar to send (source=\(sourceKey.prefix(12))…)")
return return
} }
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
let timestamp = Int64(Date().timeIntervalSince1970 * 1000) let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! }) let randomPart = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
// Mark group avatar attachments with "ga_" prefix so UI can distinguish
// "Shared group photo" vs "Shared profile photo" in the same group chat.
let isGroupAvatarSource = avatarSourceKey != nil && DatabaseManager.isGroupDialogKey(avatarSourceKey!)
let attachmentId = isGroupAvatarSource ? "ga_\(randomPart)" : randomPart
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
// Android/Desktop parity: avatar messages have empty text. // Android/Desktop parity: avatar messages have empty text.
// Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty. // Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
// Sending " " (space) causes Desktop chat list to show nothing. // Group vs direct: different encryption paths (same as sendMessageWithAttachments).
let attachmentPassword: String
let encryptedContent: String
let outChachaKey: String
let outAesChachaKey: String
let targetKey: String
if isGroup {
targetKey = Self.normalizedGroupDialogIdentity(toPublicKey)
guard let groupKey = GroupRepository.shared.groupKey(
account: currentPublicKey,
privateKeyHex: privKey,
groupDialogKey: targetKey
) else {
throw CryptoError.invalidData("Missing group key for \(targetKey)")
}
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data("".utf8), password: groupKey
)
attachmentPassword = groupKey
outChachaKey = ""
outAesChachaKey = ""
} else {
targetKey = toPublicKey
let encrypted = try MessageCrypto.encryptOutgoing( let encrypted = try MessageCrypto.encryptOutgoing(
plaintext: "", plaintext: "",
recipientPublicKeyHex: toPublicKey recipientPublicKeyHex: toPublicKey
) )
// Attachment password: HEX encoding of raw 56-byte key+nonce. // Attachment password: HEX encoding of raw 56-byte key+nonce.
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex'). attachmentPassword = encrypted.plainKeyAndNonce.hexString
// HEX is lossless for all byte values (no U+FFFD data loss). encryptedContent = encrypted.content
let attachmentPassword = encrypted.plainKeyAndNonce.hexString outChachaKey = encrypted.chachaKey
// aesChachaKey = Latin-1 encoding (matches desktop sync chain: // aesChachaKey = Latin-1 encoding (matches desktop sync chain)
// Buffer.from(decryptedString, 'binary') takes low byte of each char).
// NEVER use WHATWG UTF-8 for aesChachaKey U+FFFD round-trips as 0xFD, not original byte.
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed throw CryptoError.encryptionFailed
} }
let aesChachaPayload = Data(latin1ForSync.utf8)
outAesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload, password: privKey
)
}
// Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...") // Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...")
// not just raw base64. Desktop's AvatarProvider stores and sends data URIs.
let dataURI = "data:image/jpeg;base64,\(avatarBase64)" let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
let avatarData = Data(dataURI.utf8) let avatarData = Data(dataURI.utf8)
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
avatarData, avatarData, password: attachmentPassword
password: attachmentPassword
) )
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly // Cache avatar locally BEFORE upload so outgoing avatar shows instantly
// (same pattern as sendMessageWithAttachments AttachmentCache.saveImage before upload). let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: sourceKey)
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
if let avatarImage { if let avatarImage {
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId) AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
} }
@@ -462,70 +493,69 @@ final class SessionManager {
// BlurHash for preview (computed before upload so optimistic UI has it) // BlurHash for preview (computed before upload so optimistic UI has it)
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? "" let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
let aesChachaPayload = Data(latin1ForSync.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload,
password: privKey
)
// Build packet with avatar attachment preview will be updated with tag after upload // Build packet with avatar attachment preview will be updated with tag after upload
var packet = PacketMessage() var packet = PacketMessage()
packet.fromPublicKey = currentPublicKey packet.fromPublicKey = currentPublicKey
packet.toPublicKey = toPublicKey packet.toPublicKey = targetKey
packet.content = encrypted.content packet.content = encryptedContent
packet.chachaKey = encrypted.chachaKey packet.chachaKey = outChachaKey
packet.timestamp = timestamp packet.timestamp = timestamp
packet.privateKey = hash packet.privateKey = hash
packet.messageId = messageId packet.messageId = messageId
packet.aesChachaKey = aesChachaKey packet.aesChachaKey = outAesChachaKey
packet.attachments = [ packet.attachments = [
MessageAttachment( MessageAttachment(
id: attachmentId, id: attachmentId,
preview: blurhash, // Will be updated with "tag::blurhash" after upload preview: blurhash,
blob: "", blob: "",
type: .avatar type: .avatar
), ),
] ]
// Ensure dialog exists // Ensure dialog exists
let existingDialog = DialogRepository.shared.dialogs[toPublicKey] let dialogKey = isGroup ? targetKey : toPublicKey
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") let existingDialog = DialogRepository.shared.dialogs[dialogKey]
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") let groupMetadata = isGroup
? GroupRepository.shared.groupMetadata(account: currentPublicKey, groupDialogKey: targetKey)
: nil
let title = !opponentTitle.isEmpty
? opponentTitle
: (existingDialog?.opponentTitle.isEmpty == false
? (existingDialog?.opponentTitle ?? "")
: (groupMetadata?.title ?? ""))
let username = !opponentUsername.isEmpty
? opponentUsername
: (existingDialog?.opponentUsername.isEmpty == false
? (existingDialog?.opponentUsername ?? "")
: (groupMetadata?.description ?? ""))
DialogRepository.shared.ensureDialog( DialogRepository.shared.ensureDialog(
opponentKey: toPublicKey, opponentKey: dialogKey, title: title, username: username, myPublicKey: currentPublicKey
title: title,
username: username,
myPublicKey: currentPublicKey
) )
// Optimistic UI show message IMMEDIATELY (before upload) // Optimistic UI show message IMMEDIATELY (before upload)
let storedPassword = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
MessageRepository.shared.upsertFromMessagePacket( MessageRepository.shared.upsertFromMessagePacket(
packet, myPublicKey: currentPublicKey, decryptedText: "", packet, myPublicKey: currentPublicKey, decryptedText: "",
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, attachmentPassword: storedPassword,
fromSync: false fromSync: false,
dialogIdentityOverride: dialogKey
) )
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey) DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
// Upload encrypted blob to transport server in background (desktop: uploadFile) // Upload encrypted blob to transport server
let upload: (tag: String, server: String) let upload: (tag: String, server: String)
do { do {
upload = try await attachmentFlowTransport.uploadFile( upload = try await attachmentFlowTransport.uploadFile(
id: attachmentId, id: attachmentId, content: Data(encryptedBlob.utf8)
content: Data(encryptedBlob.utf8)
) )
} catch { } catch {
// Upload failed mark as error
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: dialogKey, status: .error)
Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)") Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
throw error throw error
} }
// Desktop parity: preview = pure blurhash (no tag prefix). // Desktop parity: preview = pure blurhash, CDN tag in transportTag
// Desktop MessageAvatar.tsx passes preview directly to blurhash decoder
// including the "tag::" prefix causes "blurhash length mismatch" errors.
// CDN tag is stored in transportTag for download.
packet.attachments = [ packet.attachments = [
MessageAttachment( MessageAttachment(
id: attachmentId, id: attachmentId,
@@ -538,19 +568,23 @@ final class SessionManager {
] ]
// Saved Messages mark delivered locally but STILL send to server // Saved Messages mark delivered locally but STILL send to server
// for cross-device avatar sync. Other devices receive via sync and
// update their local avatar cache.
if toPublicKey == currentPublicKey { if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
// Send to server for multi-device sync (unlike text Saved Messages)
packetFlowSender.sendPacket(packet) packetFlowSender.sendPacket(packet)
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)") Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)")
return return
} }
packetFlowSender.sendPacket(packet) packetFlowSender.sendPacket(packet)
// Server doesn't ACK group messages mark delivered immediately
if isGroup {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: dialogKey, status: .delivered)
} else {
registerOutgoingRetry(for: packet) registerOutgoingRetry(for: packet)
}
MessageRepository.shared.persistNow() MessageRepository.shared.persistNow()
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)") Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)")
} }

View File

@@ -46,6 +46,7 @@ struct ChatDetailView: View {
@State private var replyingToMessage: ChatMessage? @State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false @State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage? @State private var forwardingMessage: ChatMessage?
@State private var forwardingMessages: [ChatMessage] = []
@State private var pendingGroupInvite: String? @State private var pendingGroupInvite: String?
@State private var pendingGroupInviteTitle: String? @State private var pendingGroupInviteTitle: String?
@State private var mentionChatRoute: ChatRoute? @State private var mentionChatRoute: ChatRoute?
@@ -390,10 +391,18 @@ struct ChatDetailView: View {
.sheet(isPresented: $showForwardPicker) { .sheet(isPresented: $showForwardPicker) {
ForwardChatPickerView { targetRoutes in ForwardChatPickerView { targetRoutes in
showForwardPicker = false showForwardPicker = false
guard let message = forwardingMessage else { return } let msgs: [ChatMessage]
if !forwardingMessages.isEmpty {
msgs = forwardingMessages
forwardingMessages = []
} else if let single = forwardingMessage {
msgs = [single]
forwardingMessage = nil forwardingMessage = nil
} else {
return
}
for route in targetRoutes { for route in targetRoutes {
forwardMessage(message, to: route) forwardMessages(msgs, to: route)
} }
} }
} }
@@ -890,9 +899,8 @@ private extension ChatDetailView {
let selected = messages let selected = messages
.filter { selectedMessageIds.contains($0.id) } .filter { selectedMessageIds.contains($0.id) }
.sorted { $0.timestamp < $1.timestamp } .sorted { $0.timestamp < $1.timestamp }
guard let first = selected.first else { return } guard !selected.isEmpty else { return }
// For now: forward first selected message, exit selection forwardingMessages = selected
forwardingMessage = first
showForwardPicker = true showForwardPicker = true
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false } withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false }
selectedMessageIds.removeAll() selectedMessageIds.removeAll()
@@ -1445,12 +1453,11 @@ private extension ChatDetailView {
// MARK: - Forward // MARK: - Forward
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) { /// Batch-forwards multiple messages as a SINGLE packet (Android parity).
// Android parity: unwrap nested forwards. /// All selected messages one JSON array in one .messages attachment.
// If the message being forwarded is itself a forward, extract the inner func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
// forwarded messages and re-forward them directly (flatten). var allForwardData: [ReplyMessageData] = []
let forwardDataList: [ReplyMessageData] for message in messages {
let replyAttachment = message.attachments.first(where: { $0.type == .messages }) let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
@@ -1458,16 +1465,12 @@ private extension ChatDetailView {
let att = replyAttachment, let att = replyAttachment,
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)), let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
!innerMessages.isEmpty { !innerMessages.isEmpty {
// Unwrap: forward the original messages, not the wrapper allForwardData.append(contentsOf: innerMessages)
forwardDataList = innerMessages
} else { } else {
// Regular message forward as-is allForwardData.append(buildReplyData(from: message))
forwardDataList = [buildReplyData(from: message)] }
} }
// Desktop commit aaa4b42: no re-upload needed.
// chacha_key_plain in ReplyMessageData carries the original key,
// so the recipient can decrypt original CDN blobs directly.
let targetKey = targetRoute.publicKey let targetKey = targetRoute.publicKey
let targetTitle = targetRoute.title let targetTitle = targetRoute.title
let targetUsername = targetRoute.username let targetUsername = targetRoute.username
@@ -1476,13 +1479,13 @@ private extension ChatDetailView {
do { do {
try await SessionManager.shared.sendMessageWithReply( try await SessionManager.shared.sendMessageWithReply(
text: "", text: "",
replyMessages: forwardDataList, replyMessages: allForwardData,
toPublicKey: targetKey, toPublicKey: targetKey,
opponentTitle: targetTitle, opponentTitle: targetTitle,
opponentUsername: targetUsername opponentUsername: targetUsername
) )
} catch { } catch {
sendError = "Failed to forward message" sendError = "Failed to forward messages"
} }
} }
} }

View File

@@ -172,7 +172,7 @@ final class ChatDetailViewController: UIViewController {
willShow viewController: UIViewController, willShow viewController: UIViewController,
animated: Bool animated: Bool
) { ) {
let hide = viewController === self let hide = viewController === self || viewController is OpponentProfileViewController
navigationController.setNavigationBarHidden(hide, animated: animated) navigationController.setNavigationBarHidden(hide, animated: animated)
} }
@@ -736,7 +736,8 @@ final class ChatDetailViewController: UIViewController {
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView) hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
navigationController?.pushViewController(hosting, animated: true) navigationController?.pushViewController(hosting, animated: true)
} else if !route.isSystemAccount { } else if !route.isSystemAccount {
// Peer profile UIKit wrapper (Phase 1), nav bar managed by the VC itself // Peer profile pure UIKit, nav bar hidden (custom back button)
navigationController?.setNavigationBarHidden(true, animated: false)
let profileVC = OpponentProfileViewController(route: route) let profileVC = OpponentProfileViewController(route: route)
navigationController?.pushViewController(profileVC, animated: true) navigationController?.pushViewController(profileVC, animated: true)
} }
@@ -864,9 +865,7 @@ final class ChatDetailViewController: UIViewController {
guard let self else { return } guard let self else { return }
self.dismiss(animated: true) self.dismiss(animated: true)
for route in targetRoutes { for route in targetRoutes {
for message in messagesToForward { self.forwardMessages(messagesToForward, to: route)
self.forwardMessage(message, to: route)
}
} }
} }
let hosting = UIHostingController(rootView: picker) let hosting = UIHostingController(rootView: picker)
@@ -1320,6 +1319,20 @@ final class ChatDetailViewController: UIViewController {
} }
private func sendAvatarToChat() { private func sendAvatarToChat() {
if route.isGroup {
let cached = GroupRepository.shared.cachedMembers(
account: currentPublicKey,
groupDialogKey: route.publicKey
)
if cached?.adminKey == currentPublicKey {
showAvatarActionSheet()
return
}
}
performSendAvatar()
}
private func performSendAvatar() {
Task { @MainActor in Task { @MainActor in
do { do {
try await SessionManager.shared.sendAvatar( try await SessionManager.shared.sendAvatar(
@@ -1333,6 +1346,38 @@ final class ChatDetailViewController: UIViewController {
} }
} }
private func showAvatarActionSheet() {
let hasGroupAvatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Send My Avatar", style: .default) { [weak self] _ in
self?.performSendAvatar()
})
if hasGroupAvatar {
sheet.addAction(UIAlertAction(title: "Share Group Avatar", style: .default) { [weak self] _ in
self?.shareGroupAvatar()
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
DispatchQueue.main.async { [weak self] in
self?.present(sheet, animated: true)
}
}
private func shareGroupAvatar() {
Task { @MainActor in
do {
try await SessionManager.shared.sendAvatar(
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username,
avatarSourceKey: route.publicKey
)
} catch {
showAlert(title: "Send Error", message: error.localizedDescription)
}
}
}
private func handleComposerUserTyping() { private func handleComposerUserTyping() {
guard !route.isSavedMessages, !route.isSystemAccount else { return } guard !route.isSavedMessages, !route.isSystemAccount else { return }
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey) SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
@@ -1442,14 +1487,18 @@ final class ChatDetailViewController: UIViewController {
// MARK: - Forward // MARK: - Forward
private func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) { /// Batch-forwards multiple messages as a SINGLE packet (Android parity).
// Unwrap forwarded messages if the message itself is a forward (.messages attachment) /// All selected messages one JSON array in one .messages attachment.
var forwardDataList: [ReplyMessageData] private func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
var allForwardData: [ReplyMessageData] = []
for message in messages {
// Unwrap nested forwards (flatten)
if let msgAtt = message.attachments.first(where: { $0.type == .messages }), if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty { let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
forwardDataList = innerMessages allForwardData.append(contentsOf: innerMessages)
} else { } else {
forwardDataList = [buildReplyData(from: message)] allForwardData.append(buildReplyData(from: message))
}
} }
let targetKey = targetRoute.publicKey let targetKey = targetRoute.publicKey
@@ -1460,7 +1509,7 @@ final class ChatDetailViewController: UIViewController {
do { do {
try await SessionManager.shared.sendMessageWithReply( try await SessionManager.shared.sendMessageWithReply(
text: "", text: "",
replyMessages: forwardDataList, replyMessages: allForwardData,
toPublicKey: targetKey, toPublicKey: targetKey,
opponentTitle: targetTitle, opponentTitle: targetTitle,
opponentUsername: targetUsername opponentUsername: targetUsername

View File

@@ -66,7 +66,9 @@ struct MessageAvatarView: View {
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(RosettaColors.error) .foregroundStyle(RosettaColors.error)
} else if avatarImage != nil { } else if avatarImage != nil {
Text("Shared profile photo.") Text(attachment.id.hasPrefix("ga_")
? "Shared group photo."
: "Shared profile photo.")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary) .foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.lineLimit(2) .lineLimit(2)
@@ -262,11 +264,14 @@ struct MessageAvatarView: View {
if let downloadedImage { if let downloadedImage {
avatarImage = downloadedImage avatarImage = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
// Android parity: save avatar to sender's profile after download // Desktop parity: in group chats save as group avatar,
let senderKey = message.fromPublicKey // in personal chats save as sender's avatar
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
? message.toPublicKey
: message.fromPublicKey
if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) { if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) {
let base64 = jpegData.base64EncodedString() let base64 = jpegData.base64EncodedString()
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey) AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: avatarKey)
} }
} else { } else {
downloadError = true downloadError = true

View File

@@ -204,13 +204,16 @@ final class NativeMessageCell: UICollectionViewCell {
// Avatar-specific // Avatar-specific
private let avatarImageView = UIImageView() private let avatarImageView = UIImageView()
// Forward header // Forward header (item 0)
private let forwardLabel = UILabel() private let forwardLabel = UILabel()
private let forwardAvatarView = UIView() private let forwardAvatarView = UIView()
private let forwardAvatarInitialLabel = UILabel() private let forwardAvatarInitialLabel = UILabel()
private let forwardAvatarImageView = UIImageView() private let forwardAvatarImageView = UIImageView()
private let forwardNameLabel = UILabel() private let forwardNameLabel = UILabel()
// Additional forward items (items 1+, Android parity)
private var additionalForwardViews: [ForwardItemSubview] = []
// Group sender info (Telegram parity) // Group sender info (Telegram parity)
private let senderNameLabel = UILabel() private let senderNameLabel = UILabel()
private let senderAdminIconView = UIImageView() private let senderAdminIconView = UIImageView()
@@ -829,15 +832,16 @@ final class NativeMessageCell: UICollectionViewCell {
replyContainer.isHidden = true replyContainer.isHidden = true
} }
// Forward // Forward two-row: "Forwarded from" + [avatar] **Name**
if let forwardSenderName { if let forwardSenderName {
forwardLabel.isHidden = false forwardLabel.isHidden = false
forwardAvatarView.isHidden = false forwardAvatarView.isHidden = false
forwardNameLabel.isHidden = false forwardNameLabel.isHidden = false
forwardNameLabel.text = forwardSenderName
// Telegram: same accentTextColor for both title and name
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
forwardLabel.text = "Forwarded from"
forwardLabel.textColor = accent forwardLabel.textColor = accent
forwardNameLabel.text = forwardSenderName
forwardNameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
forwardNameLabel.textColor = accent forwardNameLabel.textColor = accent
// Avatar: real photo if available, otherwise initial + color // Avatar: real photo if available, otherwise initial + color
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) { if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
@@ -866,6 +870,26 @@ final class NativeMessageCell: UICollectionViewCell {
forwardNameLabel.isHidden = true forwardNameLabel.isHidden = true
} }
// Additional forward items (items 2+, Android parity)
if let layout = currentLayout {
let items = layout.additionalForwardItems
// Ensure enough subviews exist
while additionalForwardViews.count < items.count {
let sv = ForwardItemSubview()
bubbleView.insertSubview(sv, belowSubview: highlightOverlay)
additionalForwardViews.append(sv)
}
let isOut = layout.isOutgoing
for (i, itemFrame) in items.enumerated() {
let sv = additionalForwardViews[i]
sv.isHidden = false
sv.configure(info: itemFrame.info, isOutgoing: isOut)
}
for i in items.count..<additionalForwardViews.count {
additionalForwardViews[i].isHidden = true
}
}
// Photo (regular or forwarded) // Photo (regular or forwarded)
if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty { if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty {
configureForwardedPhotos() configureForwardedPhotos()
@@ -1136,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
avatarImageView.image = cached avatarImageView.image = cached
avatarImageView.isHidden = false avatarImageView.isHidden = false
fileIconView.isHidden = true fileIconView.isHidden = true
fileSizeLabel.text = "Shared profile photo" fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
} else { } else {
if isOutgoing { if isOutgoing {
// Own avatar already uploaded, just loading from disk // Own avatar already uploaded, just loading from disk
fileSizeLabel.text = "Shared profile photo" fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
} else { } else {
// Incoming avatar needs download on tap (Android parity) // Incoming avatar needs download on tap (Android parity)
fileSizeLabel.text = "Tap to download" fileSizeLabel.text = "Tap to download"
@@ -1184,7 +1208,7 @@ final class NativeMessageCell: UICollectionViewCell {
self.avatarImageView.image = diskImage self.avatarImageView.image = diskImage
self.avatarImageView.isHidden = false self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo" self.fileSizeLabel.text = attId.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
} }
} }
// CDN download is triggered by user tap via .triggerAttachmentDownload // CDN download is triggered by user tap via .triggerAttachmentDownload
@@ -1571,7 +1595,7 @@ final class NativeMessageCell: UICollectionViewCell {
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28) groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
} }
// Forward // Forward two-row: "Forwarded from" + [avatar] Name
if layout.isForward { if layout.isForward {
forwardLabel.frame = layout.forwardHeaderFrame forwardLabel.frame = layout.forwardHeaderFrame
forwardAvatarView.frame = layout.forwardAvatarFrame forwardAvatarView.frame = layout.forwardAvatarFrame
@@ -1579,6 +1603,21 @@ final class NativeMessageCell: UICollectionViewCell {
forwardAvatarInitialLabel.frame = avatarBounds forwardAvatarInitialLabel.frame = avatarBounds
forwardAvatarImageView.frame = avatarBounds forwardAvatarImageView.frame = avatarBounds
forwardNameLabel.frame = layout.forwardNameFrame forwardNameLabel.frame = layout.forwardNameFrame
// Additional forward items (items 2+)
for (i, itemFrame) in layout.additionalForwardItems.enumerated() {
if i < additionalForwardViews.count {
let subview = additionalForwardViews[i]
subview.isHidden = false
subview.applyFrames(itemFrame: itemFrame, bubbleWidth: layout.bubbleSize.width)
}
}
// Hide excess subviews
for i in layout.additionalForwardItems.count..<additionalForwardViews.count {
additionalForwardViews[i].isHidden = true
}
} else {
for sv in additionalForwardViews { sv.isHidden = true }
} }
// Telegram-style failed delivery badge outside bubble (slide + fade). // Telegram-style failed delivery badge outside bubble (slide + fade).
@@ -1677,6 +1716,9 @@ final class NativeMessageCell: UICollectionViewCell {
forwardLabel.frame.origin.y += senderNameShift forwardLabel.frame.origin.y += senderNameShift
forwardAvatarView.frame.origin.y += senderNameShift forwardAvatarView.frame.origin.y += senderNameShift
forwardNameLabel.frame.origin.y += senderNameShift forwardNameLabel.frame.origin.y += senderNameShift
for sv in additionalForwardViews where !sv.isHidden {
sv.frame.origin.y += senderNameShift
}
} }
// Re-apply bubble image with tail protrusion after height expansion // Re-apply bubble image with tail protrusion after height expansion
let expandedImageFrame: CGRect let expandedImageFrame: CGRect
@@ -2248,7 +2290,7 @@ final class NativeMessageCell: UICollectionViewCell {
self.avatarImageView.image = downloaded self.avatarImageView.image = downloaded
self.avatarImageView.isHidden = false self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true self.fileIconView.isHidden = true
self.fileSizeLabel.text = "Shared profile photo" self.fileSizeLabel.text = id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
// Trigger refresh of sender avatar circles in visible cells // Trigger refresh of sender avatar circles in visible cells
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.Name("avatarDidUpdate"), object: nil name: Notification.Name("avatarDidUpdate"), object: nil
@@ -3465,6 +3507,7 @@ final class NativeMessageCell: UICollectionViewCell {
forwardLabel.isHidden = true forwardLabel.isHidden = true
forwardAvatarView.isHidden = true forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true forwardNameLabel.isHidden = true
for sv in additionalForwardViews { sv.isHidden = true }
senderNameLabel.isHidden = true senderNameLabel.isHidden = true
senderAdminIconView.isHidden = true senderAdminIconView.isHidden = true
senderAvatarContainer.isHidden = true senderAvatarContainer.isHidden = true
@@ -3710,3 +3753,125 @@ final class BubblePathCache {
return path return path
} }
} }
// MARK: - ForwardItemSubview (additional forward items, Android parity)
/// Renders a single additional forwarded message inside a multi-forward bubble.
/// Contains: divider line, "Forwarded from" header, avatar, sender name, caption text.
final class ForwardItemSubview: UIView {
private static let headerFont = UIFont.systemFont(ofSize: 14, weight: .regular)
private static let nameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
let dividerLine = UIView()
let headerLabel = UILabel()
let avatarView = UIView()
let avatarInitialLabel = UILabel()
let avatarImageView = UIImageView()
let nameLabel = UILabel()
let textLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.12)
addSubview(dividerLine)
headerLabel.font = Self.headerFont
headerLabel.text = "Forwarded from"
addSubview(headerLabel)
avatarView.layer.cornerRadius = 8
avatarView.clipsToBounds = true
addSubview(avatarView)
avatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
avatarInitialLabel.textColor = .white
avatarInitialLabel.textAlignment = .center
avatarView.addSubview(avatarInitialLabel)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarView.addSubview(avatarImageView)
nameLabel.font = Self.nameFont
addSubview(nameLabel)
textLabel.font = Self.textFont
textLabel.numberOfLines = 0
addSubview(textLabel)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(info: ForwardItemInfo, isOutgoing: Bool) {
let accent: UIColor = isOutgoing ? .white : UIColor(red: 0.14, green: 0.54, blue: 0.9, alpha: 1)
headerLabel.text = "Forwarded from"
headerLabel.textColor = accent
nameLabel.isHidden = false
nameLabel.text = info.senderName
nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
nameLabel.textColor = accent
dividerLine.isHidden = true // spacing only, no visible line
textLabel.textColor = isOutgoing
? .white
: UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .darkText
}
let cleanCaption = info.caption.trimmingCharacters(in: .whitespacesAndNewlines)
textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption
textLabel.isHidden = cleanCaption.isEmpty
// Avatar: real photo if available, otherwise initial + color
let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5,
0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2]
if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) {
avatarImageView.image = avatarImage
avatarImageView.isHidden = false
avatarInitialLabel.isHidden = true
avatarView.backgroundColor = .clear
} else {
avatarImageView.image = nil
avatarImageView.isHidden = true
avatarInitialLabel.isHidden = false
avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased()
let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey)
let hex = hexes[colorIndex % hexes.count]
avatarView.backgroundColor = UIColor(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: 1
)
}
}
func applyFrames(itemFrame: ForwardItemFrame, bubbleWidth: CGFloat) {
// Compute bounding box: from divider top to max of all content
let topY = itemFrame.dividerY
var bottomY = max(itemFrame.headerFrame.maxY, itemFrame.nameFrame.maxY)
if itemFrame.avatarFrame.height > 0 { bottomY = max(bottomY, itemFrame.avatarFrame.maxY) }
if itemFrame.textFrame.height > 0 { bottomY = max(bottomY, itemFrame.textFrame.maxY) }
if itemFrame.photoFrame.height > 0 { bottomY = max(bottomY, itemFrame.photoFrame.maxY) }
// Set parent frame positions this subview in bubble coords
frame = CGRect(x: 0, y: topY, width: bubbleWidth, height: bottomY - topY)
// All children in LOCAL coords (subtract topY)
let dy = topY
dividerLine.frame = CGRect(x: itemFrame.headerFrame.origin.x,
y: 0,
width: itemFrame.headerFrame.width,
height: 1)
headerLabel.frame = itemFrame.headerFrame.offsetBy(dx: 0, dy: -dy)
avatarView.frame = itemFrame.avatarFrame.offsetBy(dx: 0, dy: -dy)
avatarInitialLabel.frame = avatarView.bounds
avatarImageView.frame = avatarView.bounds
nameLabel.frame = itemFrame.nameFrame.offsetBy(dx: 0, dy: -dy)
if itemFrame.textFrame.height > 0 {
textLabel.frame = itemFrame.textFrame.offsetBy(dx: 0, dy: -dy)
}
}
}

View File

@@ -1466,14 +1466,6 @@ final class NativeMessageListController: UIViewController {
&& newIds.count <= 3 && newIds.count <= 3
&& messages.last?.id != oldNewestId && messages.last?.id != oldNewestId
if !newIds.isEmpty {
let hasPending = pendingVoiceCollapse != nil
print("[VOICE_ANIM] update() — newIds=\(newIds.count) isInteractive=\(isInteractive) hasCompletedInitialLoad=\(hasCompletedInitialLoad) newestChanged=\(messages.last?.id != oldNewestId) pendingVoice=\(hasPending)")
if let pending = pendingVoiceCollapse {
print("[VOICE_ANIM] pendingMessageId=\(pending.messageId) matchesNew=\(newIds.contains(pending.messageId))")
}
}
// Capture visible cell positions BEFORE applying snapshot (for position animation) // Capture visible cell positions BEFORE applying snapshot (for position animation)
var oldPositions: [String: CGFloat] = [:] var oldPositions: [String: CGFloat] = [:]
// Capture pill positions for matching spring animation // Capture pill positions for matching spring animation
@@ -1503,22 +1495,36 @@ final class NativeMessageListController: UIViewController {
} }
} }
// Layout calculation: sync for first load, async for subsequent updates. // Layout calculation: sync for first load and interactive inserts,
// async for bulk updates.
// Interactive inserts (3 new messages) MUST be sync to avoid delayed
// reconfigureVisibleCells() from calculateLayoutsAsync killing animations.
if layoutCache.isEmpty { if layoutCache.isEmpty {
// First load: synchronous to avoid blank cells // First load: synchronous to avoid blank cells
calculateLayouts() calculateLayouts()
} else if isInteractive {
// Interactive insert (1-3 messages): sync layout so no delayed reconfigure
var dirtyIds = newIds
for i in messages.indices where newIds.contains(messages[i].id) {
if i > 0 { dirtyIds.insert(messages[i - 1].id) }
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
}
calculateLayouts(dirtyIds: dirtyIds)
} else if !newIds.isEmpty && newIds.count <= 20 { } else if !newIds.isEmpty && newIds.count <= 20 {
// Incremental: only new messages + neighbors, on background // Incremental non-interactive: async on background
var dirtyIds = newIds var dirtyIds = newIds
for i in messages.indices where newIds.contains(messages[i].id) { for i in messages.indices where newIds.contains(messages[i].id) {
if i > 0 { dirtyIds.insert(messages[i - 1].id) } if i > 0 { dirtyIds.insert(messages[i - 1].id) }
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) } if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
} }
calculateLayoutsAsync(dirtyIds: dirtyIds) calculateLayoutsAsync(dirtyIds: dirtyIds)
} else { } else if !newIds.isEmpty {
// Bulk update (pagination, sync): async full recalculation // Bulk update (pagination, sync): async full recalculation
calculateLayoutsAsync() calculateLayoutsAsync()
} }
// else: newIds is empty no new messages, skip layout recalculation.
// Prevents Combine debounce duplicate from killing insertion animations
// via calculateLayoutsAsync reconfigureVisibleCells dataSource.apply.
var snapshot = NSDiffableDataSourceSnapshot<Int, String>() var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0]) snapshot.appendSections([0])
@@ -1568,9 +1574,7 @@ final class NativeMessageListController: UIViewController {
hideSkeletonAnimated() hideSkeletonAnimated()
} }
// Apply Telegram-style insertion animations after layout settles
if isInteractive { if isInteractive {
collectionView.layoutIfNeeded()
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions) applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
// Animate date pills with same spring as cells // Animate date pills with same spring as cells
@@ -1599,10 +1603,7 @@ final class NativeMessageListController: UIViewController {
} }
// Voice send: ensure cell is visible and animated, then fire collapse. // Voice send: ensure cell is visible and animated, then fire collapse.
// Runs AFTER all insertion animations so it doesn't interfere.
if let match = voiceCorrelationMatch { if let match = voiceCorrelationMatch {
print("[VOICE_ANIM] correlation matched! messageId=\(match.messageId)")
// Scroll to bottom first so the voice cell is in the viewport // Scroll to bottom first so the voice cell is in the viewport
collectionView.setContentOffset( collectionView.setContentOffset(
CGPoint(x: 0, y: -collectionView.contentInset.top), CGPoint(x: 0, y: -collectionView.contentInset.top),
@@ -1610,39 +1611,9 @@ final class NativeMessageListController: UIViewController {
) )
collectionView.layoutIfNeeded() collectionView.layoutIfNeeded()
// Add dedicated animation for the voice cell if applyInsertionAnimations // Voice cell animation is handled by UIKit's animatingDifferences: true.
// missed it (e.g., cell was off-screen or isInteractive was false) // No additional animation needed here.
if let itemIndex = dataSource.snapshot().indexOfItem(match.messageId) {
let ip = IndexPath(item: itemIndex, section: 0)
let cell = collectionView.cellForItem(at: ip)
print("[VOICE_ANIM] itemIndex=\(itemIndex) cell=\(cell != nil) cellHeight=\(cell?.bounds.height ?? -1) hasSlideAnim=\(cell?.layer.animation(forKey: "insertionSlide") != nil)")
if let cell = cell {
// Only add animation if not already animating (avoid double-animation)
if cell.layer.animation(forKey: "insertionSlide") == nil {
let slideOffset = -cell.bounds.height * 1.2
let slide = CASpringAnimation(keyPath: "position.y")
slide.fromValue = slideOffset
slide.toValue = 0.0
slide.isAdditive = true
slide.stiffness = 555.0
slide.damping = 47.0
slide.mass = 1.0
slide.initialVelocity = 0
slide.duration = slide.settlingDuration
slide.fillMode = .backwards
cell.layer.add(slide, forKey: "insertionSlide")
let alpha = CABasicAnimation(keyPath: "opacity")
alpha.fromValue = 0.0
alpha.toValue = 1.0
alpha.duration = 0.12
alpha.fillMode = .backwards
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
}
}
}
print("[VOICE_SEND] correlation match — collapsing with animation")
match.collapseAction() match.collapseAction()
} }
@@ -1653,55 +1624,30 @@ final class NativeMessageListController: UIViewController {
updateScrollToBottomBadge() updateScrollToBottomBadge()
} }
/// Telegram-style message insertion animation (iOS 26+ parity). /// Telegram-identical insertion animation:
/// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s). /// - New cells: contentView alpha 01 over 0.2s (matches ChatMessageBubbleItemNode.animateInsertion)
/// Existing messages: spring position animation from old Y to new Y. /// - Existing cells: spring position animation on vertical delta (cells shift up smoothly)
/// All position animations use CASpringAnimation (stiffness=555, damping=47). /// Uses contentView.alpha (UIView.animate) instead of cell.layer CABasicAnimation because
/// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex. /// reconfigureVisibleCells replaces content configuration but does NOT reset contentView.alpha.
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) { private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
let visibleIds = Set(collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) })
let newVisible = newIds.intersection(visibleIds)
let newMissing = newIds.subtracting(visibleIds)
if !newIds.isEmpty {
print("[VOICE_ANIM] applyInsertionAnimations — newIds=\(newIds.count) visible=\(newVisible.count) missing=\(newMissing.count) visibleIPs=\(collectionView.indexPathsForVisibleItems.count)")
}
for ip in collectionView.indexPathsForVisibleItems { for ip in collectionView.indexPathsForVisibleItems {
guard let cellId = dataSource.itemIdentifier(for: ip), guard let cellId = dataSource.itemIdentifier(for: ip),
let cell = collectionView.cellForItem(at: ip) else { continue } let cell = collectionView.cellForItem(at: ip) else { continue }
if newIds.contains(cellId) { if newIds.contains(cellId) {
// NEW cell: slide up from below + alpha fade // Telegram: subnodes alpha 01 over 0.2s (animateInsertion/animateAdded)
// In inverted CV: negative offset = below on screen cell.contentView.alpha = 0
let slideOffset = -cell.bounds.height * 1.2 UIView.animate(withDuration: 0.2) {
print("[VOICE_ANIM] animating new cell id=\(cellId.prefix(8)) height=\(cell.bounds.height) slideOffset=\(slideOffset)") cell.contentView.alpha = 1
}
let slide = CASpringAnimation(keyPath: "position.y")
slide.fromValue = slideOffset
slide.toValue = 0.0
slide.isAdditive = true
slide.stiffness = 555.0
slide.damping = 47.0
slide.mass = 1.0
slide.initialVelocity = 0
slide.duration = slide.settlingDuration
slide.fillMode = .backwards
cell.layer.add(slide, forKey: "insertionSlide")
// Alpha fade: 0 1 (Telegram-parity: fast fade)
let alpha = CABasicAnimation(keyPath: "opacity")
alpha.fromValue = 0.0
alpha.toValue = 1.0
alpha.duration = 0.12
alpha.fillMode = .backwards
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
} else if let oldY = oldPositions[cellId] { } else if let oldY = oldPositions[cellId] {
// EXISTING cell: spring from old position to new position // Existing cell shifted animate position delta with spring
let delta = oldY - cell.layer.position.y let newY = cell.layer.position.y
guard abs(delta) > 0.5 else { continue } let dy = oldY - newY
guard abs(dy) > 0.5 else { continue }
let move = CASpringAnimation(keyPath: "position.y") let move = CASpringAnimation(keyPath: "position.y")
move.fromValue = delta move.fromValue = dy
move.toValue = 0.0 move.toValue = 0.0
move.isAdditive = true move.isAdditive = true
move.stiffness = 555.0 move.stiffness = 555.0
@@ -2242,14 +2188,12 @@ extension NativeMessageListController: ComposerViewDelegate {
} }
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) { func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
print("[VOICE_SEND] composerDidFinishRecording — sendImmediately=\(sendImmediately) deferred=\(composer.voiceSendNeedsDeferred) url=\(composer.lastRecordedURL?.lastPathComponent ?? "nil")")
collectionView.keyboardDismissMode = .interactive collectionView.keyboardDismissMode = .interactive
updateScrollToBottomButtonConstraints() updateScrollToBottomButtonConstraints()
guard sendImmediately, guard sendImmediately,
let url = composer.lastRecordedURL, let url = composer.lastRecordedURL,
let data = try? Data(contentsOf: url) else { let data = try? Data(contentsOf: url) else {
print("[VOICE_SEND] composerDidFinishRecording — GUARD FAILED")
// Guard fail while overlay may still be showing force immediate collapse // Guard fail while overlay may still be showing force immediate collapse
if composer.voiceSendNeedsDeferred { if composer.voiceSendNeedsDeferred {
composer.performDeferredVoiceSendCollapse() composer.performDeferredVoiceSendCollapse()
@@ -2270,7 +2214,6 @@ extension NativeMessageListController: ComposerViewDelegate {
// Safety timer: if cell doesn't appear within 600ms, force collapse // Safety timer: if cell doesn't appear within 600ms, force collapse
let timer = DispatchWorkItem { [weak self] in let timer = DispatchWorkItem { [weak self] in
guard let self, let pending = self.pendingVoiceCollapse else { return } guard let self, let pending = self.pendingVoiceCollapse else { return }
print("[VOICE_SEND] safety timer fired — forcing collapse")
self.pendingVoiceCollapse = nil self.pendingVoiceCollapse = nil
pending.collapseAction() pending.collapseAction()
} }
@@ -2287,7 +2230,6 @@ extension NativeMessageListController: ComposerViewDelegate {
let title = config.opponentTitle let title = config.opponentTitle
let username = config.opponentUsername let username = config.opponentUsername
Task { @MainActor in Task { @MainActor in
print("[VOICE_SEND] sendMessageWithAttachments START — messageId=\(messageId)")
_ = try? await SessionManager.shared.sendMessageWithAttachments( _ = try? await SessionManager.shared.sendMessageWithAttachments(
text: "", text: "",
attachments: [pending], attachments: [pending],
@@ -2296,7 +2238,6 @@ extension NativeMessageListController: ComposerViewDelegate {
opponentUsername: username, opponentUsername: username,
messageId: messageId messageId: messageId
) )
print("[VOICE_SEND] sendMessageWithAttachments DONE")
} }
} }
@@ -2326,13 +2267,11 @@ extension NativeMessageListController: ComposerViewDelegate {
private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) { private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) {
guard let window = view.window else { guard let window = view.window else {
print("[VOICE_SEND] resolveVoiceTargetFrame — no window, removing snapshot")
snapshot.removeFromSuperview() snapshot.removeFromSuperview()
return return
} }
let maxAttempts = 12 let maxAttempts = 12
guard attempt <= maxAttempts else { guard attempt <= maxAttempts else {
print("[VOICE_SEND] resolveVoiceTargetFrame — MAX ATTEMPTS reached, fading out snapshot")
UIView.animate(withDuration: 0.16, animations: { UIView.animate(withDuration: 0.16, animations: {
snapshot.alpha = 0 snapshot.alpha = 0
snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
@@ -2344,16 +2283,11 @@ extension NativeMessageListController: ComposerViewDelegate {
let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window) let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window)
guard let targetFrame else { guard let targetFrame else {
if attempt == 0 || attempt == 5 || attempt == 10 {
print("[VOICE_SEND] resolveVoiceTargetFrame — attempt \(attempt), cell not found, retrying")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot) self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot)
} }
return return
} }
print("[VOICE_SEND] resolveVoiceTargetFrame — FOUND target at attempt \(attempt), frame=\(targetFrame)")
UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) { UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) {
snapshot.frame = targetFrame snapshot.frame = targetFrame
snapshot.layer.cornerRadius = 12 snapshot.layer.cornerRadius = 12

View File

@@ -505,5 +505,10 @@ private struct IOS18ScrollTracker<Content: View>: View {
} }
} }
.onScrollPhaseChange { _, p in scrollPhase = p } .onScrollPhaseChange { _, p in scrollPhase = p }
.onChange(of: isLargeHeader) { wasLarge, isLarge in
if isLarge && !wasLarge {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
} }
} }

View File

@@ -1,47 +1,121 @@
import UIKit import UIKit
import SwiftUI import SwiftUI
import Combine
/// Thin UIKit wrapper around SwiftUI OpponentProfileView. /// Pure UIKit peer profile screen. Phase 2: data + interactivity.
/// Phase 1 of incremental migration: handles nav bar + swipe-back natively. final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
/// The SwiftUI content is embedded as a child UIHostingController. ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate {
// MARK: - Properties
private let route: ChatRoute private let route: ChatRoute
private let showMessageButton: Bool var showMessageButton = false
private let viewModel: PeerProfileViewModel
private var cancellables = Set<AnyCancellable>()
private var addedSwipeBackGesture = false private var addedSwipeBackGesture = false
private var selectedTab = 0
private var copiedField: String?
private var isMuted = false
// MARK: - Subviews
private let scrollView = UIScrollView()
private let contentView = UIView()
private let backButton = ChatDetailBackButton()
// Header
private let avatarContainer = UIView()
private var avatarHosting: UIHostingController<AvatarView>?
private let nameLabel = UILabel()
private let subtitleLabel = UILabel()
// Action buttons
private var actionButtonViews: [(container: UIControl, icon: UIImageView, label: UILabel)] = []
// Info card
private let infoCard = UIView()
// Tab bar
private let tabBar = ProfileTabBarView(titles: ["Media", "Files", "Links", "Groups"])
// Media grid
private var mediaCollectionView: UICollectionView!
// List containers
private let filesContainer = UIView()
private let linksContainer = UIView()
private let groupsContainer = UIView()
// Empty state
private let emptyIcon = UIImageView()
private let emptyLabel = UILabel()
// MARK: - Constants
private let hPad: CGFloat = 16
private let avatarSize: CGFloat = 100
private let buttonSpacing: CGFloat = 6
private let buttonCornerRadius: CGFloat = 15
private let cardCornerRadius: CGFloat = 11
private let sectionFill = UIColor { $0.userInterfaceStyle == .dark
? UIColor(red: 28/255, green: 28/255, blue: 29/255, alpha: 1)
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
}
private let separatorColor = UIColor { $0.userInterfaceStyle == .dark
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
: UIColor(red: 0x3C/255, green: 0x3C/255, blue: 0x43/255, alpha: 0.36)
}
private let primaryBlue = UIColor(red: 0x24/255, green: 0x8A/255, blue: 0xE6/255, alpha: 1)
private let textPrimary = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
private let textSecondary = UIColor { $0.userInterfaceStyle == .dark
? UIColor(red: 0x8D/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
: UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
}
private let onlineColor = UIColor(red: 0x34/255, green: 0xC7/255, blue: 0x59/255, alpha: 1)
// MARK: - Init
init(route: ChatRoute, showMessageButton: Bool = false) { init(route: ChatRoute, showMessageButton: Bool = false) {
self.route = route self.route = route
self.showMessageButton = showMessageButton self.showMessageButton = showMessageButton
self.viewModel = PeerProfileViewModel(dialogKey: route.publicKey)
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
// MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white } view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
navigationController?.setNavigationBarHidden(true, animated: false)
// Embed existing SwiftUI view as child
var profileView = OpponentProfileView(route: route)
profileView.showMessageButton = showMessageButton
let hosting = UIHostingController(rootView: profileView)
hosting.view.backgroundColor = .clear
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.frame = view.bounds
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hosting.didMove(toParent: self)
// Hide system back button SwiftUI .toolbar handles its own
navigationItem.hidesBackButton = true navigationItem.hidesBackButton = true
setupScrollView()
setupAvatar()
setupLabels()
setupActionButtons()
setupInfoCard()
setupTabBar()
setupMediaGrid()
setupListContainers()
setupEmptyState()
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
view.addSubview(backButton)
isMuted = DialogRepository.shared.dialogs[route.publicKey]?.isMuted ?? false
bindViewModel()
viewModel.startObservingOnline()
viewModel.loadSharedContent()
viewModel.loadCommonGroups()
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
// Show nav bar (SwiftUI .toolbarBackground(.hidden) makes it invisible) navigationController?.setNavigationBarHidden(true, animated: false)
navigationController?.setNavigationBarHidden(false, animated: false)
let clear = UINavigationBarAppearance() let clear = UINavigationBarAppearance()
clear.configureWithTransparentBackground() clear.configureWithTransparentBackground()
clear.shadowColor = .clear clear.shadowColor = .clear
@@ -54,27 +128,590 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
setupFullWidthSwipeBack() setupFullWidthSwipeBack()
} }
// MARK: - Full-Width Swipe Back override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutAll()
}
// MARK: - ViewModel Binding
private func bindViewModel() {
viewModel.$isOnline
.receive(on: DispatchQueue.main)
.sink { [weak self] online in
self?.subtitleLabel.text = online ? "online" : "offline"
}
.store(in: &cancellables)
let dataChanged = viewModel.$mediaItems
.combineLatest(viewModel.$fileItems, viewModel.$linkItems, viewModel.$commonGroups)
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
dataChanged
.sink { [weak self] _ in self?.rebuildTabContent() }
.store(in: &cancellables)
}
private func rebuildTabContent() {
mediaCollectionView.reloadData()
view.setNeedsLayout()
}
// MARK: - Setup
private func setupScrollView() {
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
view.addSubview(scrollView)
scrollView.addSubview(contentView)
}
private func setupAvatar() {
contentView.addSubview(avatarContainer)
let dialog = DialogRepository.shared.dialogs[route.publicKey]
let dn = resolveDisplayName(dialog: dialog)
let image = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
let av = AvatarView(
initials: RosettaColors.initials(name: dn, publicKey: route.publicKey),
colorIndex: RosettaColors.avatarColorIndex(for: dn, publicKey: route.publicKey),
size: avatarSize, isOnline: false, isSavedMessages: route.isSavedMessages, image: image
)
let hosting = UIHostingController(rootView: av)
hosting.view.backgroundColor = .clear
addChild(hosting)
avatarContainer.addSubview(hosting.view)
hosting.didMove(toParent: self)
avatarHosting = hosting
}
private func setupLabels() {
let dialog = DialogRepository.shared.dialogs[route.publicKey]
nameLabel.text = resolveDisplayName(dialog: dialog)
nameLabel.font = .systemFont(ofSize: 17, weight: .semibold)
nameLabel.textColor = textPrimary
nameLabel.textAlignment = .center
contentView.addSubview(nameLabel)
subtitleLabel.text = (dialog?.isOnline ?? false) ? "online" : "offline"
subtitleLabel.font = .systemFont(ofSize: 16)
subtitleLabel.textColor = textSecondary
subtitleLabel.textAlignment = .center
contentView.addSubview(subtitleLabel)
}
private func setupActionButtons() {
var defs: [(icon: String, title: String, action: Selector)] = []
if showMessageButton || route.isSavedMessages {
defs.append(("bubble.left.fill", "Message", #selector(messageTapped)))
}
if !route.isSavedMessages {
defs.append(("phone.fill", "Call", #selector(callTapped)))
defs.append((isMuted ? "bell.slash.fill" : "bell.fill", isMuted ? "Unmute" : "Mute", #selector(muteTapped)))
}
defs.append(("magnifyingglass", "Search", #selector(searchTapped)))
defs.append(("ellipsis", "More", #selector(moreTapped)))
for def in defs {
let control = UIControl()
control.backgroundColor = sectionFill
control.layer.cornerRadius = buttonCornerRadius
control.layer.cornerCurve = .continuous
control.addTarget(self, action: def.action, for: .touchUpInside)
let iv = UIImageView(image: UIImage(systemName: def.icon))
iv.contentMode = .scaleAspectFit
iv.tintColor = primaryBlue
iv.isUserInteractionEnabled = false
control.addSubview(iv)
let lbl = UILabel()
lbl.text = def.title
lbl.font = .systemFont(ofSize: 11)
lbl.textAlignment = .center
lbl.textColor = primaryBlue
lbl.isUserInteractionEnabled = false
control.addSubview(lbl)
contentView.addSubview(control)
actionButtonViews.append((control, iv, lbl))
}
}
private func setupInfoCard() {
infoCard.backgroundColor = sectionFill
infoCard.layer.cornerRadius = cardCornerRadius
infoCard.layer.cornerCurve = .continuous
contentView.addSubview(infoCard)
}
private func setupTabBar() {
tabBar.delegate = self
contentView.addSubview(tabBar)
}
private func setupMediaGrid() {
let layout = UICollectionViewCompositionalLayout { _, _ in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1.0 / 3.0), heightDimension: .fractionalWidth(1.0 / 3.0)))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0 / 3.0)),
repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(1)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 1
return section
}
mediaCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
mediaCollectionView.register(ProfileMediaCell.self, forCellWithReuseIdentifier: "m")
mediaCollectionView.dataSource = self
mediaCollectionView.delegate = self
mediaCollectionView.backgroundColor = .clear
mediaCollectionView.isScrollEnabled = false
mediaCollectionView.isHidden = true
contentView.addSubview(mediaCollectionView)
}
private func setupListContainers() {
for c in [filesContainer, linksContainer, groupsContainer] {
c.backgroundColor = sectionFill
c.layer.cornerRadius = cardCornerRadius
c.layer.cornerCurve = .continuous
c.isHidden = true
contentView.addSubview(c)
}
}
private func setupEmptyState() {
emptyIcon.tintColor = textSecondary.withAlphaComponent(0.5)
emptyIcon.contentMode = .scaleAspectFit
contentView.addSubview(emptyIcon)
emptyLabel.font = .systemFont(ofSize: 15, weight: .medium)
emptyLabel.textColor = textSecondary
emptyLabel.textAlignment = .center
contentView.addSubview(emptyLabel)
}
// MARK: - Layout
private func layoutAll() {
let w = view.bounds.width
let safeTop = view.safeAreaInsets.top
scrollView.frame = view.bounds
contentView.frame.size.width = w
backButton.frame = CGRect(x: 7, y: safeTop - 10, width: 44, height: 44)
var y: CGFloat = safeTop - 8
// Avatar
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
avatarHosting?.view.frame = avatarContainer.bounds
y += avatarSize + 12
// Name
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
y += nameH + 1
// Subtitle
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
y += 20 + 20
// Buttons
let btnCount = CGFloat(actionButtonViews.count)
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
c.frame = CGRect(x: hPad + CGFloat(i) * (btnW + buttonSpacing), y: y, width: btnW, height: 58)
iv.frame = CGRect(x: (btnW - 24) / 2, y: 7, width: 24, height: 26)
lbl.frame = CGRect(x: 0, y: 37, width: btnW, height: 16)
}
y += 58 + 16
// Info card
let infoH = layoutInfoCard(width: w - hPad * 2)
infoCard.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: infoH)
y += infoH + 20
// Tab bar
tabBar.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: 38)
y += 38 + 12
// Tab content
y = layoutTabContent(at: y, width: w)
contentView.frame.size.height = y
scrollView.contentSize = CGSize(width: w, height: y)
}
private func layoutInfoCard(width: CGFloat) -> CGFloat {
infoCard.subviews.forEach { $0.removeFromSuperview() }
let dialog = DialogRepository.shared.dialogs[route.publicKey]
let username = dialog?.opponentUsername ?? route.username
var cy: CGFloat = 0
if !username.isEmpty {
cy += makeInfoRow(in: infoCard, at: cy, width: width,
label: "username", value: "@\(username)", rawValue: username, fieldId: "username")
let div = UIView()
div.backgroundColor = separatorColor
div.frame = CGRect(x: 16, y: cy, width: width - 16, height: 1 / UIScreen.main.scale)
infoCard.addSubview(div)
}
cy += makeInfoRow(in: infoCard, at: cy, width: width,
label: "public key", value: route.publicKey, rawValue: route.publicKey, fieldId: "publicKey")
return cy
}
private func makeInfoRow(in card: UIView, at y: CGFloat, width: CGFloat,
label: String, value: String, rawValue: String, fieldId: String) -> CGFloat {
let row = UIControl()
row.frame = CGRect(x: 0, y: y, width: width, height: 66)
row.addAction(UIAction { [weak self] _ in
UIPasteboard.general.string = rawValue
self?.showCopied(fieldId: fieldId)
}, for: .touchUpInside)
let titleLbl = UILabel()
titleLbl.text = label
titleLbl.font = .systemFont(ofSize: 14)
titleLbl.textColor = textSecondary
titleLbl.frame = CGRect(x: 16, y: 12, width: width - 32, height: 18)
titleLbl.isUserInteractionEnabled = false
row.addSubview(titleLbl)
let isCopied = copiedField == fieldId
let valueLbl = UILabel()
valueLbl.text = isCopied ? "Copied" : value
valueLbl.font = .systemFont(ofSize: 17)
valueLbl.textColor = isCopied ? onlineColor : primaryBlue
valueLbl.lineBreakMode = .byTruncatingMiddle
valueLbl.frame = CGRect(x: 16, y: 32, width: width - 32, height: 22)
valueLbl.isUserInteractionEnabled = false
valueLbl.tag = fieldId.hashValue
row.addSubview(valueLbl)
card.addSubview(row)
return 66
}
private func showCopied(fieldId: String) {
copiedField = fieldId
let infoH = layoutInfoCard(width: infoCard.bounds.width)
infoCard.frame.size.height = infoH
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.5))
guard copiedField == fieldId else { return }
copiedField = nil
let h = layoutInfoCard(width: infoCard.bounds.width)
infoCard.frame.size.height = h
}
}
// MARK: - Tab Content Layout
private func layoutTabContent(at startY: CGFloat, width: CGFloat) -> CGFloat {
mediaCollectionView.isHidden = true
filesContainer.isHidden = true
linksContainer.isHidden = true
groupsContainer.isHidden = true
emptyIcon.isHidden = true
emptyLabel.isHidden = true
var y = startY
switch selectedTab {
case 0: // Media
if viewModel.mediaItems.isEmpty {
y = layoutEmpty(at: y, width: width, icon: "photo.on.rectangle", title: "No Media Yet")
} else {
mediaCollectionView.isHidden = false
let rows = ceil(Double(viewModel.mediaItems.count) / 3.0)
let itemH = (width - 2) / 3
let gridH = CGFloat(rows) * itemH + max(0, CGFloat(rows - 1))
mediaCollectionView.frame = CGRect(x: 0, y: y, width: width, height: gridH)
y += gridH + 40
}
case 1: // Files
if viewModel.fileItems.isEmpty {
y = layoutEmpty(at: y, width: width, icon: "doc", title: "No Files Yet")
} else {
filesContainer.isHidden = false
let h = layoutFileRows(width: width - hPad * 2)
filesContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
y += h + 40
}
case 2: // Links
if viewModel.linkItems.isEmpty {
y = layoutEmpty(at: y, width: width, icon: "link", title: "No Links Yet")
} else {
linksContainer.isHidden = false
let h = layoutLinkRows(width: width - hPad * 2)
linksContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
y += h + 40
}
case 3: // Groups
if viewModel.commonGroups.isEmpty {
y = layoutEmpty(at: y, width: width, icon: "person.2", title: "No Groups in Common")
} else {
groupsContainer.isHidden = false
let h = layoutGroupRows(width: width - hPad * 2)
groupsContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
y += h + 40
}
default: break
}
return y
}
private func layoutEmpty(at y: CGFloat, width: CGFloat, icon: String, title: String) -> CGFloat {
emptyIcon.isHidden = false
emptyLabel.isHidden = false
emptyIcon.image = UIImage(systemName: icon)
emptyLabel.text = title
emptyIcon.frame = CGRect(x: (width - 50) / 2, y: y + 20, width: 50, height: 50)
emptyLabel.frame = CGRect(x: 30, y: y + 82, width: width - 60, height: 20)
return y + 142
}
private func layoutFileRows(width: CGFloat) -> CGFloat {
filesContainer.subviews.forEach { $0.removeFromSuperview() }
var cy: CGFloat = 0
for (i, file) in viewModel.fileItems.enumerated() {
let rowH: CGFloat = 60
let row = UIView(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
let iconBg = UIView(frame: CGRect(x: 16, y: 10, width: 40, height: 40))
iconBg.backgroundColor = primaryBlue.withAlphaComponent(0.12)
iconBg.layer.cornerRadius = 10
row.addSubview(iconBg)
let icon = UIImageView(image: UIImage(systemName: "doc.fill"))
icon.tintColor = primaryBlue
icon.contentMode = .scaleAspectFit
icon.frame = CGRect(x: 9, y: 9, width: 22, height: 22)
iconBg.addSubview(icon)
let name = UILabel(frame: CGRect(x: 68, y: 12, width: width - 84, height: 20))
name.text = file.fileName
name.font = .systemFont(ofSize: 16)
name.textColor = textPrimary
row.addSubview(name)
let sub = UILabel(frame: CGRect(x: 68, y: 34, width: width - 84, height: 18))
sub.text = file.subtitle
sub.font = .systemFont(ofSize: 13)
sub.textColor = textSecondary
row.addSubview(sub)
filesContainer.addSubview(row)
cy += rowH
if i < viewModel.fileItems.count - 1 {
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
sep.backgroundColor = separatorColor
filesContainer.addSubview(sep)
}
}
return cy
}
private func layoutLinkRows(width: CGFloat) -> CGFloat {
linksContainer.subviews.forEach { $0.removeFromSuperview() }
var cy: CGFloat = 0
for (i, link) in viewModel.linkItems.enumerated() {
let rowH: CGFloat = 60
let row = UIControl(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
row.addAction(UIAction { _ in
if let url = URL(string: link.url) { UIApplication.shared.open(url) }
}, for: .touchUpInside)
let badge = UILabel(frame: CGRect(x: 16, y: 10, width: 40, height: 40))
badge.text = String(link.displayHost.prefix(1)).uppercased()
badge.font = .systemFont(ofSize: 18, weight: .bold)
badge.textColor = .white
badge.textAlignment = .center
badge.backgroundColor = primaryBlue
badge.layer.cornerRadius = 10
badge.clipsToBounds = true
row.addSubview(badge)
let host = UILabel(frame: CGRect(x: 68, y: 12, width: width - 84, height: 20))
host.text = link.displayHost
host.font = .systemFont(ofSize: 16, weight: .medium)
host.textColor = textPrimary
row.addSubview(host)
let ctx = UILabel(frame: CGRect(x: 68, y: 34, width: width - 84, height: 18))
ctx.text = link.context
ctx.font = .systemFont(ofSize: 13)
ctx.textColor = textSecondary
ctx.numberOfLines = 2
row.addSubview(ctx)
linksContainer.addSubview(row)
cy += rowH
if i < viewModel.linkItems.count - 1 {
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
sep.backgroundColor = separatorColor
linksContainer.addSubview(sep)
}
}
return cy
}
private func layoutGroupRows(width: CGFloat) -> CGFloat {
groupsContainer.subviews.forEach { $0.removeFromSuperview() }
var cy: CGFloat = 0
for (i, group) in viewModel.commonGroups.enumerated() {
let rowH: CGFloat = 56
let row = UIView(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
let av = AvatarView(
initials: RosettaColors.initials(name: group.title, publicKey: group.dialogKey),
colorIndex: RosettaColors.avatarColorIndex(for: group.title, publicKey: group.dialogKey),
size: 40, isOnline: false, image: group.avatar)
let h = UIHostingController(rootView: av)
h.view.backgroundColor = .clear
h.view.frame = CGRect(x: 16, y: 8, width: 40, height: 40)
row.addSubview(h.view)
let title = UILabel(frame: CGRect(x: 68, y: (rowH - 22) / 2, width: width - 84, height: 22))
title.text = group.title
title.font = .systemFont(ofSize: 17)
title.textColor = textPrimary
row.addSubview(title)
groupsContainer.addSubview(row)
cy += rowH
if i < viewModel.commonGroups.count - 1 {
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
sep.backgroundColor = separatorColor
groupsContainer.addSubview(sep)
}
}
return cy
}
// MARK: - UICollectionView (Media Grid)
func collectionView(_ cv: UICollectionView, numberOfItemsInSection section: Int) -> Int {
viewModel.mediaItems.count
}
func collectionView(_ cv: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = cv.dequeueReusableCell(withReuseIdentifier: "m", for: indexPath) as! ProfileMediaCell
cell.configure(with: viewModel.mediaItems[indexPath.item])
return cell
}
func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let viewable = viewModel.mediaItems.map {
ViewableImageInfo(attachmentId: $0.attachmentId, messageId: $0.messageId, senderName: $0.senderName,
timestamp: Date(timeIntervalSince1970: Double($0.timestamp) / 1000.0), caption: $0.caption)
}
ImageViewerPresenter.shared.present(state: ImageViewerState(images: viewable, initialIndex: indexPath.item, sourceFrame: .zero))
}
// MARK: - ProfileTabBarDelegate
func tabBar(_ tabBar: ProfileTabBarView, didSelectTabAt index: Int) {
selectedTab = index
view.setNeedsLayout()
}
// MARK: - Actions
@objc private func backTapped() { navigationController?.popViewController(animated: true) }
@objc private func callTapped() {
let dialog = DialogRepository.shared.dialogs[route.publicKey]
let name = dialog?.opponentTitle ?? route.title
let user = dialog?.opponentUsername ?? route.username
Task { @MainActor in
_ = CallManager.shared.startOutgoingCall(toPublicKey: route.publicKey, title: name, username: user)
}
}
@objc private func muteTapped() {
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
isMuted.toggle()
// Find mute button and update
for (_, iv, lbl) in actionButtonViews where lbl.text == "Mute" || lbl.text == "Unmute" {
iv.image = UIImage(systemName: isMuted ? "bell.slash.fill" : "bell.fill")
lbl.text = isMuted ? "Unmute" : "Mute"
}
}
@objc private func searchTapped() { navigationController?.popViewController(animated: true) }
@objc private func moreTapped() {
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if !route.isSavedMessages {
sheet.addAction(UIAlertAction(title: "Block User", style: .destructive))
}
sheet.addAction(UIAlertAction(title: "Clear Chat History", style: .destructive))
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(sheet, animated: true)
}
@objc private func messageTapped() {
if route.isSavedMessages {
navigationController?.popViewController(animated: true)
} else {
let chatVC = ChatDetailViewController(route: route)
navigationController?.pushViewController(chatVC, animated: true)
}
}
// MARK: - Helpers
private func resolveDisplayName(dialog: Dialog?) -> String {
if let d = dialog, !d.opponentTitle.isEmpty { return d.opponentTitle }
if !route.title.isEmpty { return route.title }
if let d = dialog, !d.opponentUsername.isEmpty { return "@\(d.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
// MARK: - Swipe Back
private func setupFullWidthSwipeBack() { private func setupFullWidthSwipeBack() {
guard !addedSwipeBackGesture else { return } guard !addedSwipeBackGesture else { return }
addedSwipeBackGesture = true addedSwipeBackGesture = true
guard let nav = navigationController, guard let nav = navigationController,
let edgeGesture = nav.interactivePopGestureRecognizer, let edge = nav.interactivePopGestureRecognizer,
let targets = edgeGesture.value(forKey: "targets") as? NSArray, let targets = edge.value(forKey: "targets") as? NSArray, targets.count > 0 else { return }
targets.count > 0 else { return } edge.isEnabled = true
let pan = UIPanGestureRecognizer()
edgeGesture.isEnabled = true pan.setValue(targets, forKey: "targets")
let fullWidthGesture = UIPanGestureRecognizer() pan.delegate = self
fullWidthGesture.setValue(targets, forKey: "targets") nav.view.addGestureRecognizer(pan)
fullWidthGesture.delegate = self
nav.view.addGestureRecognizer(fullWidthGesture)
} }
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizerShouldBegin(_ gr: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } guard let p = gr as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: pan.view) let v = p.velocity(in: p.view)
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y) return v.x > 0 && abs(v.x) > abs(v.y)
}
}
// MARK: - Media Cell
private final class ProfileMediaCell: UICollectionViewCell {
private let iv = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
contentView.addSubview(iv)
contentView.backgroundColor = UIColor(white: 0.15, alpha: 1)
}
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() { super.layoutSubviews(); iv.frame = contentView.bounds }
override func prepareForReuse() { super.prepareForReuse(); iv.image = nil }
func configure(with item: SharedMediaItem) {
if let img = AttachmentCache.shared.loadImage(forAttachmentId: item.attachmentId) { iv.image = img }
else if !item.blurhash.isEmpty, let blur = BlurHashDecoder.decode(blurHash: item.blurhash, width: 32, height: 32) { iv.image = blur }
} }
} }