Фикс: добавлен вызов applyInsertionAnimations + плавное появление баблов как в Telegram
This commit is contained in:
@@ -74,6 +74,7 @@ struct MessageCellLayout: Sendable {
|
||||
let forwardNameFrame: CGRect
|
||||
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
||||
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
|
||||
let additionalForwardItems: [ForwardItemFrame] // items 2+ for multi-forward (Android parity)
|
||||
|
||||
// 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)
|
||||
|
||||
extension MessageCellLayout {
|
||||
@@ -139,6 +163,7 @@ extension MessageCellLayout {
|
||||
let forwardSenderName: String // forward sender name (for dynamic min bubble width)
|
||||
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
||||
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 isGroupAdmin: Bool // sender is group owner (admin badge takes extra space)
|
||||
}
|
||||
@@ -259,6 +284,7 @@ extension MessageCellLayout {
|
||||
forwardNameFrame: .zero,
|
||||
forwardChachaKeyPlain: "",
|
||||
forwardAttachments: [],
|
||||
additionalForwardItems: [],
|
||||
showsDateHeader: config.showsDateHeader,
|
||||
dateHeaderText: config.dateHeaderText,
|
||||
dateHeaderHeight: dateH,
|
||||
@@ -398,7 +424,8 @@ extension MessageCellLayout {
|
||||
let replyBottomGap: CGFloat = 3
|
||||
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 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
|
||||
+ CGFloat(config.callCount) * 42
|
||||
+ CGFloat(config.avatarCount) * 52
|
||||
@@ -585,14 +612,14 @@ extension MessageCellLayout {
|
||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
|
||||
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 {
|
||||
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 nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont]).width
|
||||
// Header: 10pt left + text + 10pt right
|
||||
// Name: 10pt left + 16pt avatar + 4pt gap + name + 10pt right
|
||||
// Row 1: 10pt + "Forwarded from" + 10pt
|
||||
// Row 2: 10pt + 16pt avatar + 4pt + name + 10pt
|
||||
let fwdMinW = ceil(max(headerW + 20, nameW + 40))
|
||||
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.
|
||||
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 fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH)
|
||||
|
||||
let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17)
|
||||
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16)
|
||||
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17)
|
||||
// Two-row header: "Forwarded from" on line 1, [avatar] Name on line 2
|
||||
let fwdHeaderFrame = CGRect(x: 10, y: 5, width: bubbleW - 20, height: 14)
|
||||
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(
|
||||
totalHeight: totalH,
|
||||
@@ -808,6 +960,7 @@ extension MessageCellLayout {
|
||||
forwardNameFrame: fwdNameFrame,
|
||||
forwardChachaKeyPlain: config.forwardChachaKeyPlain,
|
||||
forwardAttachments: config.forwardAttachments,
|
||||
additionalForwardItems: additionalFwdLayouts,
|
||||
showsDateHeader: config.showsDateHeader,
|
||||
dateHeaderText: config.dateHeaderText,
|
||||
dateHeaderHeight: dateHeaderH,
|
||||
@@ -1137,6 +1290,7 @@ extension MessageCellLayout {
|
||||
var forwardSenderName = ""
|
||||
var forwardChachaKeyPlain = ""
|
||||
var forwardAttachments: [ReplyAttachmentData] = []
|
||||
var allForwardItems: [ForwardItemInfo] = []
|
||||
if isForward,
|
||||
let att = message.attachments.first(where: { $0.type == .messages }),
|
||||
let data = att.blob.data(using: .utf8),
|
||||
@@ -1160,6 +1314,32 @@ extension MessageCellLayout {
|
||||
} else {
|
||||
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")
|
||||
@@ -1217,6 +1397,7 @@ extension MessageCellLayout {
|
||||
forwardSenderName: forwardSenderName,
|
||||
forwardChachaKeyPlain: forwardChachaKeyPlain,
|
||||
forwardAttachments: forwardAttachments,
|
||||
allForwardItems: allForwardItems,
|
||||
senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "",
|
||||
isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey)
|
||||
)
|
||||
|
||||
@@ -407,54 +407,85 @@ final class SessionManager {
|
||||
/// Sends current user's avatar to a chat as a message attachment.
|
||||
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
|
||||
/// → `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 {
|
||||
Self.logger.error("📤 Cannot send avatar — missing keys")
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
|
||||
let sourceKey = avatarSourceKey ?? currentPublicKey
|
||||
// Load avatar from local storage as base64 (desktop: avatars[0].avatar)
|
||||
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: currentPublicKey) else {
|
||||
Self.logger.error("📤 No avatar to send")
|
||||
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: sourceKey) else {
|
||||
Self.logger.error("📤 No avatar to send (source=\(sourceKey.prefix(12))…)")
|
||||
return
|
||||
}
|
||||
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
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.
|
||||
// Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
|
||||
// Sending " " (space) causes Desktop chat list to show nothing.
|
||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||
plaintext: "",
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
// Group vs direct: different encryption paths (same as sendMessageWithAttachments).
|
||||
let attachmentPassword: String
|
||||
let encryptedContent: String
|
||||
let outChachaKey: String
|
||||
let outAesChachaKey: String
|
||||
let targetKey: String
|
||||
|
||||
// Attachment password: HEX encoding of raw 56-byte key+nonce.
|
||||
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
|
||||
// HEX is lossless for all byte values (no U+FFFD data loss).
|
||||
let attachmentPassword = encrypted.plainKeyAndNonce.hexString
|
||||
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(
|
||||
plaintext: "",
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
// Attachment password: HEX encoding of raw 56-byte key+nonce.
|
||||
attachmentPassword = encrypted.plainKeyAndNonce.hexString
|
||||
encryptedContent = encrypted.content
|
||||
outChachaKey = encrypted.chachaKey
|
||||
|
||||
// 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 {
|
||||
throw CryptoError.encryptionFailed
|
||||
// aesChachaKey = Latin-1 encoding (matches desktop sync chain)
|
||||
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
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...")
|
||||
// not just raw base64. Desktop's AvatarProvider stores and sends data URIs.
|
||||
let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
|
||||
let avatarData = Data(dataURI.utf8)
|
||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
avatarData,
|
||||
password: attachmentPassword
|
||||
avatarData, password: attachmentPassword
|
||||
)
|
||||
|
||||
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
|
||||
// (same pattern as sendMessageWithAttachments — AttachmentCache.saveImage before upload).
|
||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
|
||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: sourceKey)
|
||||
if let avatarImage {
|
||||
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
|
||||
}
|
||||
@@ -462,70 +493,69 @@ final class SessionManager {
|
||||
// BlurHash for preview (computed before upload so optimistic UI has it)
|
||||
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
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
packet.content = encrypted.content
|
||||
packet.chachaKey = encrypted.chachaKey
|
||||
packet.toPublicKey = targetKey
|
||||
packet.content = encryptedContent
|
||||
packet.chachaKey = outChachaKey
|
||||
packet.timestamp = timestamp
|
||||
packet.privateKey = hash
|
||||
packet.messageId = messageId
|
||||
packet.aesChachaKey = aesChachaKey
|
||||
packet.aesChachaKey = outAesChachaKey
|
||||
packet.attachments = [
|
||||
MessageAttachment(
|
||||
id: attachmentId,
|
||||
preview: blurhash, // Will be updated with "tag::blurhash" after upload
|
||||
preview: blurhash,
|
||||
blob: "",
|
||||
type: .avatar
|
||||
),
|
||||
]
|
||||
|
||||
// Ensure dialog exists
|
||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
||||
let dialogKey = isGroup ? targetKey : toPublicKey
|
||||
let existingDialog = DialogRepository.shared.dialogs[dialogKey]
|
||||
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(
|
||||
opponentKey: toPublicKey,
|
||||
title: title,
|
||||
username: username,
|
||||
myPublicKey: currentPublicKey
|
||||
opponentKey: dialogKey, title: title, username: username, myPublicKey: currentPublicKey
|
||||
)
|
||||
|
||||
// Optimistic UI — show message IMMEDIATELY (before upload)
|
||||
let storedPassword = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: "",
|
||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
||||
fromSync: false
|
||||
attachmentPassword: storedPassword,
|
||||
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)
|
||||
do {
|
||||
upload = try await attachmentFlowTransport.uploadFile(
|
||||
id: attachmentId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
id: attachmentId, content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
} catch {
|
||||
// Upload failed — mark as 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)")
|
||||
throw error
|
||||
}
|
||||
|
||||
// Desktop parity: preview = pure blurhash (no tag prefix).
|
||||
// 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.
|
||||
// Desktop parity: preview = pure blurhash, CDN tag in transportTag
|
||||
packet.attachments = [
|
||||
MessageAttachment(
|
||||
id: attachmentId,
|
||||
@@ -538,19 +568,23 @@ final class SessionManager {
|
||||
]
|
||||
|
||||
// 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 {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, 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)
|
||||
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)")
|
||||
return
|
||||
}
|
||||
|
||||
packetFlowSender.sendPacket(packet)
|
||||
registerOutgoingRetry(for: 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)
|
||||
}
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)")
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ struct ChatDetailView: View {
|
||||
@State private var replyingToMessage: ChatMessage?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var forwardingMessage: ChatMessage?
|
||||
@State private var forwardingMessages: [ChatMessage] = []
|
||||
@State private var pendingGroupInvite: String?
|
||||
@State private var pendingGroupInviteTitle: String?
|
||||
@State private var mentionChatRoute: ChatRoute?
|
||||
@@ -390,10 +391,18 @@ struct ChatDetailView: View {
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
ForwardChatPickerView { targetRoutes in
|
||||
showForwardPicker = false
|
||||
guard let message = forwardingMessage else { return }
|
||||
forwardingMessage = nil
|
||||
let msgs: [ChatMessage]
|
||||
if !forwardingMessages.isEmpty {
|
||||
msgs = forwardingMessages
|
||||
forwardingMessages = []
|
||||
} else if let single = forwardingMessage {
|
||||
msgs = [single]
|
||||
forwardingMessage = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
for route in targetRoutes {
|
||||
forwardMessage(message, to: route)
|
||||
forwardMessages(msgs, to: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,9 +899,8 @@ private extension ChatDetailView {
|
||||
let selected = messages
|
||||
.filter { selectedMessageIds.contains($0.id) }
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
guard let first = selected.first else { return }
|
||||
// For now: forward first selected message, exit selection
|
||||
forwardingMessage = first
|
||||
guard !selected.isEmpty else { return }
|
||||
forwardingMessages = selected
|
||||
showForwardPicker = true
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false }
|
||||
selectedMessageIds.removeAll()
|
||||
@@ -1445,29 +1453,24 @@ private extension ChatDetailView {
|
||||
|
||||
// MARK: - Forward
|
||||
|
||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
// Android parity: unwrap nested forwards.
|
||||
// If the message being forwarded is itself a forward, extract the inner
|
||||
// forwarded messages and re-forward them directly (flatten).
|
||||
let forwardDataList: [ReplyMessageData]
|
||||
/// Batch-forwards multiple messages as a SINGLE packet (Android parity).
|
||||
/// All selected messages → one JSON array in one .messages attachment.
|
||||
func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
|
||||
var allForwardData: [ReplyMessageData] = []
|
||||
for message in messages {
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
if isForward,
|
||||
let att = replyAttachment,
|
||||
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
|
||||
!innerMessages.isEmpty {
|
||||
// Unwrap: forward the original messages, not the wrapper
|
||||
forwardDataList = innerMessages
|
||||
} else {
|
||||
// Regular message — forward as-is
|
||||
forwardDataList = [buildReplyData(from: message)]
|
||||
if isForward,
|
||||
let att = replyAttachment,
|
||||
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
|
||||
!innerMessages.isEmpty {
|
||||
allForwardData.append(contentsOf: innerMessages)
|
||||
} else {
|
||||
allForwardData.append(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 targetTitle = targetRoute.title
|
||||
let targetUsername = targetRoute.username
|
||||
@@ -1476,13 +1479,13 @@ private extension ChatDetailView {
|
||||
do {
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: "",
|
||||
replyMessages: forwardDataList,
|
||||
replyMessages: allForwardData,
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetTitle,
|
||||
opponentUsername: targetUsername
|
||||
)
|
||||
} catch {
|
||||
sendError = "Failed to forward message"
|
||||
sendError = "Failed to forward messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ final class ChatDetailViewController: UIViewController {
|
||||
willShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
let hide = viewController === self
|
||||
let hide = viewController === self || viewController is OpponentProfileViewController
|
||||
navigationController.setNavigationBarHidden(hide, animated: animated)
|
||||
}
|
||||
|
||||
@@ -736,7 +736,8 @@ final class ChatDetailViewController: UIViewController {
|
||||
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
|
||||
navigationController?.pushViewController(hosting, animated: true)
|
||||
} 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)
|
||||
navigationController?.pushViewController(profileVC, animated: true)
|
||||
}
|
||||
@@ -864,9 +865,7 @@ final class ChatDetailViewController: UIViewController {
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true)
|
||||
for route in targetRoutes {
|
||||
for message in messagesToForward {
|
||||
self.forwardMessage(message, to: route)
|
||||
}
|
||||
self.forwardMessages(messagesToForward, to: route)
|
||||
}
|
||||
}
|
||||
let hosting = UIHostingController(rootView: picker)
|
||||
@@ -1320,6 +1319,20 @@ final class ChatDetailViewController: UIViewController {
|
||||
}
|
||||
|
||||
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
|
||||
do {
|
||||
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() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
@@ -1442,14 +1487,18 @@ final class ChatDetailViewController: UIViewController {
|
||||
|
||||
// MARK: - Forward
|
||||
|
||||
private func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
// Unwrap forwarded messages if the message itself is a forward (.messages attachment)
|
||||
var forwardDataList: [ReplyMessageData]
|
||||
if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
|
||||
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
|
||||
forwardDataList = innerMessages
|
||||
} else {
|
||||
forwardDataList = [buildReplyData(from: message)]
|
||||
/// Batch-forwards multiple messages as a SINGLE packet (Android parity).
|
||||
/// All selected messages → one JSON array in one .messages attachment.
|
||||
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 }),
|
||||
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
|
||||
allForwardData.append(contentsOf: innerMessages)
|
||||
} else {
|
||||
allForwardData.append(buildReplyData(from: message))
|
||||
}
|
||||
}
|
||||
|
||||
let targetKey = targetRoute.publicKey
|
||||
@@ -1460,7 +1509,7 @@ final class ChatDetailViewController: UIViewController {
|
||||
do {
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: "",
|
||||
replyMessages: forwardDataList,
|
||||
replyMessages: allForwardData,
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetTitle,
|
||||
opponentUsername: targetUsername
|
||||
|
||||
@@ -66,7 +66,9 @@ struct MessageAvatarView: View {
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
} else if avatarImage != nil {
|
||||
Text("Shared profile photo.")
|
||||
Text(attachment.id.hasPrefix("ga_")
|
||||
? "Shared group photo."
|
||||
: "Shared profile photo.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(2)
|
||||
@@ -262,11 +264,14 @@ struct MessageAvatarView: View {
|
||||
if let downloadedImage {
|
||||
avatarImage = downloadedImage
|
||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||
// Android parity: save avatar to sender's profile after download
|
||||
let senderKey = message.fromPublicKey
|
||||
// Desktop parity: in group chats save as group avatar,
|
||||
// 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) {
|
||||
let base64 = jpegData.base64EncodedString()
|
||||
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
|
||||
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: avatarKey)
|
||||
}
|
||||
} else {
|
||||
downloadError = true
|
||||
|
||||
@@ -204,13 +204,16 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Avatar-specific
|
||||
private let avatarImageView = UIImageView()
|
||||
|
||||
// Forward header
|
||||
// Forward header (item 0)
|
||||
private let forwardLabel = UILabel()
|
||||
private let forwardAvatarView = UIView()
|
||||
private let forwardAvatarInitialLabel = UILabel()
|
||||
private let forwardAvatarImageView = UIImageView()
|
||||
private let forwardNameLabel = UILabel()
|
||||
|
||||
// Additional forward items (items 1+, Android parity)
|
||||
private var additionalForwardViews: [ForwardItemSubview] = []
|
||||
|
||||
// Group sender info (Telegram parity)
|
||||
private let senderNameLabel = UILabel()
|
||||
private let senderAdminIconView = UIImageView()
|
||||
@@ -829,15 +832,16 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
replyContainer.isHidden = true
|
||||
}
|
||||
|
||||
// Forward
|
||||
// Forward — two-row: "Forwarded from" + [avatar] **Name**
|
||||
if let forwardSenderName {
|
||||
forwardLabel.isHidden = false
|
||||
forwardAvatarView.isHidden = false
|
||||
forwardNameLabel.isHidden = false
|
||||
forwardNameLabel.text = forwardSenderName
|
||||
// Telegram: same accentTextColor for both title and name
|
||||
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
|
||||
forwardLabel.text = "Forwarded from"
|
||||
forwardLabel.textColor = accent
|
||||
forwardNameLabel.text = forwardSenderName
|
||||
forwardNameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
forwardNameLabel.textColor = accent
|
||||
// Avatar: real photo if available, otherwise initial + color
|
||||
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
|
||||
@@ -866,6 +870,26 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
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)
|
||||
if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty {
|
||||
configureForwardedPhotos()
|
||||
@@ -1136,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
avatarImageView.image = cached
|
||||
avatarImageView.isHidden = false
|
||||
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
|
||||
} else {
|
||||
if isOutgoing {
|
||||
// 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 {
|
||||
// Incoming avatar — needs download on tap (Android parity)
|
||||
fileSizeLabel.text = "Tap to download"
|
||||
@@ -1184,7 +1208,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
self.avatarImageView.image = diskImage
|
||||
self.avatarImageView.isHidden = false
|
||||
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
|
||||
@@ -1571,7 +1595,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
|
||||
}
|
||||
|
||||
// Forward
|
||||
// Forward — two-row: "Forwarded from" + [avatar] Name
|
||||
if layout.isForward {
|
||||
forwardLabel.frame = layout.forwardHeaderFrame
|
||||
forwardAvatarView.frame = layout.forwardAvatarFrame
|
||||
@@ -1579,6 +1603,21 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardAvatarInitialLabel.frame = avatarBounds
|
||||
forwardAvatarImageView.frame = avatarBounds
|
||||
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).
|
||||
@@ -1677,6 +1716,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardLabel.frame.origin.y += senderNameShift
|
||||
forwardAvatarView.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
|
||||
let expandedImageFrame: CGRect
|
||||
@@ -2248,7 +2290,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
self.avatarImageView.image = downloaded
|
||||
self.avatarImageView.isHidden = false
|
||||
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
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||
@@ -3465,6 +3507,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardLabel.isHidden = true
|
||||
forwardAvatarView.isHidden = true
|
||||
forwardNameLabel.isHidden = true
|
||||
for sv in additionalForwardViews { sv.isHidden = true }
|
||||
senderNameLabel.isHidden = true
|
||||
senderAdminIconView.isHidden = true
|
||||
senderAvatarContainer.isHidden = true
|
||||
@@ -3710,3 +3753,125 @@ final class BubblePathCache {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1466,14 +1466,6 @@ final class NativeMessageListController: UIViewController {
|
||||
&& newIds.count <= 3
|
||||
&& 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)
|
||||
var oldPositions: [String: CGFloat] = [:]
|
||||
// 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 {
|
||||
// First load: synchronous to avoid blank cells
|
||||
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 {
|
||||
// Incremental: only new messages + neighbors, on background
|
||||
// Incremental non-interactive: async on background
|
||||
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) }
|
||||
}
|
||||
calculateLayoutsAsync(dirtyIds: dirtyIds)
|
||||
} else {
|
||||
} else if !newIds.isEmpty {
|
||||
// Bulk update (pagination, sync): async full recalculation
|
||||
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>()
|
||||
snapshot.appendSections([0])
|
||||
@@ -1568,9 +1574,7 @@ final class NativeMessageListController: UIViewController {
|
||||
hideSkeletonAnimated()
|
||||
}
|
||||
|
||||
// Apply Telegram-style insertion animations after layout settles
|
||||
if isInteractive {
|
||||
collectionView.layoutIfNeeded()
|
||||
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
|
||||
|
||||
// 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.
|
||||
// Runs AFTER all insertion animations so it doesn't interfere.
|
||||
if let match = voiceCorrelationMatch {
|
||||
print("[VOICE_ANIM] correlation matched! messageId=\(match.messageId)")
|
||||
|
||||
// Scroll to bottom first so the voice cell is in the viewport
|
||||
collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
||||
@@ -1610,39 +1611,9 @@ final class NativeMessageListController: UIViewController {
|
||||
)
|
||||
collectionView.layoutIfNeeded()
|
||||
|
||||
// Add dedicated animation for the voice cell if applyInsertionAnimations
|
||||
// missed it (e.g., cell was off-screen or isInteractive was false)
|
||||
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")
|
||||
// Voice cell animation is handled by UIKit's animatingDifferences: true.
|
||||
// No additional animation needed here.
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1653,55 +1624,30 @@ final class NativeMessageListController: UIViewController {
|
||||
updateScrollToBottomBadge()
|
||||
}
|
||||
|
||||
/// Telegram-style message insertion animation (iOS 26+ parity).
|
||||
/// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s).
|
||||
/// Existing messages: spring position animation from old Y to new Y.
|
||||
/// All position animations use CASpringAnimation (stiffness=555, damping=47).
|
||||
/// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex.
|
||||
/// Telegram-identical insertion animation:
|
||||
/// - New cells: contentView alpha 0→1 over 0.2s (matches ChatMessageBubbleItemNode.animateInsertion)
|
||||
/// - Existing cells: spring position animation on vertical delta (cells shift up smoothly)
|
||||
/// Uses contentView.alpha (UIView.animate) instead of cell.layer CABasicAnimation because
|
||||
/// reconfigureVisibleCells replaces content configuration but does NOT reset contentView.alpha.
|
||||
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 {
|
||||
guard let cellId = dataSource.itemIdentifier(for: ip),
|
||||
let cell = collectionView.cellForItem(at: ip) else { continue }
|
||||
|
||||
if newIds.contains(cellId) {
|
||||
// NEW cell: slide up from below + alpha fade
|
||||
// In inverted CV: negative offset = below on screen
|
||||
let slideOffset = -cell.bounds.height * 1.2
|
||||
print("[VOICE_ANIM] animating new cell id=\(cellId.prefix(8)) height=\(cell.bounds.height) slideOffset=\(slideOffset)")
|
||||
|
||||
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")
|
||||
|
||||
// Telegram: subnodes alpha 0→1 over 0.2s (animateInsertion/animateAdded)
|
||||
cell.contentView.alpha = 0
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
cell.contentView.alpha = 1
|
||||
}
|
||||
} else if let oldY = oldPositions[cellId] {
|
||||
// EXISTING cell: spring from old position to new position
|
||||
let delta = oldY - cell.layer.position.y
|
||||
guard abs(delta) > 0.5 else { continue }
|
||||
// Existing cell shifted — animate position delta with spring
|
||||
let newY = cell.layer.position.y
|
||||
let dy = oldY - newY
|
||||
guard abs(dy) > 0.5 else { continue }
|
||||
|
||||
let move = CASpringAnimation(keyPath: "position.y")
|
||||
move.fromValue = delta
|
||||
move.fromValue = dy
|
||||
move.toValue = 0.0
|
||||
move.isAdditive = true
|
||||
move.stiffness = 555.0
|
||||
@@ -2242,14 +2188,12 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
}
|
||||
|
||||
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
|
||||
print("[VOICE_SEND] composerDidFinishRecording — sendImmediately=\(sendImmediately) deferred=\(composer.voiceSendNeedsDeferred) url=\(composer.lastRecordedURL?.lastPathComponent ?? "nil")")
|
||||
collectionView.keyboardDismissMode = .interactive
|
||||
updateScrollToBottomButtonConstraints()
|
||||
|
||||
guard sendImmediately,
|
||||
let url = composer.lastRecordedURL,
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[VOICE_SEND] composerDidFinishRecording — GUARD FAILED")
|
||||
// Guard fail while overlay may still be showing — force immediate collapse
|
||||
if composer.voiceSendNeedsDeferred {
|
||||
composer.performDeferredVoiceSendCollapse()
|
||||
@@ -2270,7 +2214,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
// Safety timer: if cell doesn't appear within 600ms, force collapse
|
||||
let timer = DispatchWorkItem { [weak self] in
|
||||
guard let self, let pending = self.pendingVoiceCollapse else { return }
|
||||
print("[VOICE_SEND] safety timer fired — forcing collapse")
|
||||
self.pendingVoiceCollapse = nil
|
||||
pending.collapseAction()
|
||||
}
|
||||
@@ -2287,7 +2230,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
let title = config.opponentTitle
|
||||
let username = config.opponentUsername
|
||||
Task { @MainActor in
|
||||
print("[VOICE_SEND] sendMessageWithAttachments START — messageId=\(messageId)")
|
||||
_ = try? await SessionManager.shared.sendMessageWithAttachments(
|
||||
text: "",
|
||||
attachments: [pending],
|
||||
@@ -2296,7 +2238,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
opponentUsername: username,
|
||||
messageId: messageId
|
||||
)
|
||||
print("[VOICE_SEND] sendMessageWithAttachments DONE")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2326,13 +2267,11 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
|
||||
private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) {
|
||||
guard let window = view.window else {
|
||||
print("[VOICE_SEND] resolveVoiceTargetFrame — no window, removing snapshot")
|
||||
snapshot.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
let maxAttempts = 12
|
||||
guard attempt <= maxAttempts else {
|
||||
print("[VOICE_SEND] resolveVoiceTargetFrame — MAX ATTEMPTS reached, fading out snapshot")
|
||||
UIView.animate(withDuration: 0.16, animations: {
|
||||
snapshot.alpha = 0
|
||||
snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
||||
@@ -2344,16 +2283,11 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
|
||||
let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window)
|
||||
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
|
||||
self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot)
|
||||
}
|
||||
return
|
||||
}
|
||||
print("[VOICE_SEND] resolveVoiceTargetFrame — FOUND target at attempt \(attempt), frame=\(targetFrame)")
|
||||
|
||||
UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) {
|
||||
snapshot.frame = targetFrame
|
||||
snapshot.layer.cornerRadius = 12
|
||||
|
||||
@@ -505,5 +505,10 @@ private struct IOS18ScrollTracker<Content: View>: View {
|
||||
}
|
||||
}
|
||||
.onScrollPhaseChange { _, p in scrollPhase = p }
|
||||
.onChange(of: isLargeHeader) { wasLarge, isLarge in
|
||||
if isLarge && !wasLarge {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,121 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Thin UIKit wrapper around SwiftUI OpponentProfileView.
|
||||
/// Phase 1 of incremental migration: handles nav bar + swipe-back natively.
|
||||
/// The SwiftUI content is embedded as a child UIHostingController.
|
||||
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
/// Pure UIKit peer profile screen. Phase 2: data + interactivity.
|
||||
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
|
||||
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
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 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) {
|
||||
self.route = route
|
||||
self.showMessageButton = showMessageButton
|
||||
self.viewModel = PeerProfileViewModel(dialogKey: route.publicKey)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
|
||||
|
||||
// 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
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
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) {
|
||||
super.viewWillAppear(animated)
|
||||
// Show nav bar (SwiftUI .toolbarBackground(.hidden) makes it invisible)
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
let clear = UINavigationBarAppearance()
|
||||
clear.configureWithTransparentBackground()
|
||||
clear.shadowColor = .clear
|
||||
@@ -54,27 +128,590 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
||||
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() {
|
||||
guard !addedSwipeBackGesture else { return }
|
||||
addedSwipeBackGesture = true
|
||||
|
||||
guard let nav = navigationController,
|
||||
let edgeGesture = nav.interactivePopGestureRecognizer,
|
||||
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
|
||||
targets.count > 0 else { return }
|
||||
|
||||
edgeGesture.isEnabled = true
|
||||
let fullWidthGesture = UIPanGestureRecognizer()
|
||||
fullWidthGesture.setValue(targets, forKey: "targets")
|
||||
fullWidthGesture.delegate = self
|
||||
nav.view.addGestureRecognizer(fullWidthGesture)
|
||||
let edge = nav.interactivePopGestureRecognizer,
|
||||
let targets = edge.value(forKey: "targets") as? NSArray, targets.count > 0 else { return }
|
||||
edge.isEnabled = true
|
||||
let pan = UIPanGestureRecognizer()
|
||||
pan.setValue(targets, forKey: "targets")
|
||||
pan.delegate = self
|
||||
nav.view.addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
||||
let velocity = pan.velocity(in: pan.view)
|
||||
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
|
||||
func gestureRecognizerShouldBegin(_ gr: UIGestureRecognizer) -> Bool {
|
||||
guard let p = gr as? UIPanGestureRecognizer else { return true }
|
||||
let v = p.velocity(in: p.view)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user