Фикс: добавлен вызов applyInsertionAnimations + плавное появление баблов как в Telegram
This commit is contained in:
@@ -74,6 +74,7 @@ struct MessageCellLayout: Sendable {
|
|||||||
let forwardNameFrame: CGRect
|
let forwardNameFrame: CGRect
|
||||||
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
||||||
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
|
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
|
||||||
|
let additionalForwardItems: [ForwardItemFrame] // items 2+ for multi-forward (Android parity)
|
||||||
|
|
||||||
// MARK: - Date Header (optional)
|
// MARK: - Date Header (optional)
|
||||||
|
|
||||||
@@ -104,6 +105,29 @@ struct MessageCellLayout: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward item info for multi-forward layout (Android parity).
|
||||||
|
struct ForwardItemInfo: Sendable {
|
||||||
|
let senderName: String
|
||||||
|
let senderKey: String
|
||||||
|
let caption: String
|
||||||
|
let chachaKeyPlain: String
|
||||||
|
let attachments: [ReplyAttachmentData]
|
||||||
|
let imageCount: Int
|
||||||
|
let fileCount: Int
|
||||||
|
let voiceCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-calculated frame for a single additional forward item (items 2+).
|
||||||
|
struct ForwardItemFrame: Sendable {
|
||||||
|
let dividerY: CGFloat // Y of horizontal divider (bubble coords)
|
||||||
|
let headerFrame: CGRect // "Forwarded from" label frame
|
||||||
|
let avatarFrame: CGRect // Mini avatar frame
|
||||||
|
let nameFrame: CGRect // Sender name label frame
|
||||||
|
let textFrame: CGRect // Caption text frame
|
||||||
|
let photoFrame: CGRect // Photo collage frame
|
||||||
|
let info: ForwardItemInfo // Full data for rendering
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Layout Calculation (Thread-Safe)
|
// MARK: - Layout Calculation (Thread-Safe)
|
||||||
|
|
||||||
extension MessageCellLayout {
|
extension MessageCellLayout {
|
||||||
@@ -139,6 +163,7 @@ extension MessageCellLayout {
|
|||||||
let forwardSenderName: String // forward sender name (for dynamic min bubble width)
|
let forwardSenderName: String // forward sender name (for dynamic min bubble width)
|
||||||
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
let forwardChachaKeyPlain: String // hex key for decrypting forwarded CDN attachments
|
||||||
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
|
let forwardAttachments: [ReplyAttachmentData] // forwarded attachment metadata for download
|
||||||
|
let allForwardItems: [ForwardItemInfo] // all forward items for multi-forward
|
||||||
let senderName: String // group sender name (for min bubble width)
|
let senderName: String // group sender name (for min bubble width)
|
||||||
let isGroupAdmin: Bool // sender is group owner (admin badge takes extra space)
|
let isGroupAdmin: Bool // sender is group owner (admin badge takes extra space)
|
||||||
}
|
}
|
||||||
@@ -259,6 +284,7 @@ extension MessageCellLayout {
|
|||||||
forwardNameFrame: .zero,
|
forwardNameFrame: .zero,
|
||||||
forwardChachaKeyPlain: "",
|
forwardChachaKeyPlain: "",
|
||||||
forwardAttachments: [],
|
forwardAttachments: [],
|
||||||
|
additionalForwardItems: [],
|
||||||
showsDateHeader: config.showsDateHeader,
|
showsDateHeader: config.showsDateHeader,
|
||||||
dateHeaderText: config.dateHeaderText,
|
dateHeaderText: config.dateHeaderText,
|
||||||
dateHeaderHeight: dateH,
|
dateHeaderHeight: dateH,
|
||||||
@@ -398,7 +424,8 @@ extension MessageCellLayout {
|
|||||||
let replyBottomGap: CGFloat = 3
|
let replyBottomGap: CGFloat = 3
|
||||||
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
|
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
|
||||||
var photoH: CGFloat = 0
|
var photoH: CGFloat = 0
|
||||||
let forwardHeaderH: CGFloat = config.isForward ? 41 : 0
|
// Two-row: "Forwarded from" + [avatar] Name
|
||||||
|
let forwardHeaderH: CGFloat = config.isForward ? 38 : 0
|
||||||
var fileH: CGFloat = CGFloat(config.fileCount) * 52
|
var fileH: CGFloat = CGFloat(config.fileCount) * 52
|
||||||
+ CGFloat(config.callCount) * 42
|
+ CGFloat(config.callCount) * 42
|
||||||
+ CGFloat(config.avatarCount) * 52
|
+ CGFloat(config.avatarCount) * 52
|
||||||
@@ -585,14 +612,14 @@ extension MessageCellLayout {
|
|||||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
|
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
|
||||||
bubbleH = max(bubbleH, 37)
|
bubbleH = max(bubbleH, 37)
|
||||||
}
|
}
|
||||||
// Forward header needs minimum width for "Forwarded from" + avatar + name
|
// Forward header min width: "Forwarded from" label + [avatar] Name row
|
||||||
if config.isForward {
|
if config.isForward {
|
||||||
let fwdLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
let fwdLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||||
let headerW = ("Forwarded from" as NSString).size(withAttributes: [.font: fwdLabelFont]).width
|
let headerW = ("Forwarded from" as NSString).size(withAttributes: [.font: fwdLabelFont]).width
|
||||||
let nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont]).width
|
let nameW = (config.forwardSenderName as NSString).size(withAttributes: [.font: fwdNameFont]).width
|
||||||
// Header: 10pt left + text + 10pt right
|
// Row 1: 10pt + "Forwarded from" + 10pt
|
||||||
// Name: 10pt left + 16pt avatar + 4pt gap + name + 10pt right
|
// Row 2: 10pt + 16pt avatar + 4pt + name + 10pt
|
||||||
let fwdMinW = ceil(max(headerW + 20, nameW + 40))
|
let fwdMinW = ceil(max(headerW + 20, nameW + 40))
|
||||||
bubbleW = max(bubbleW, min(fwdMinW, effectiveMaxBubbleWidth))
|
bubbleW = max(bubbleW, min(fwdMinW, effectiveMaxBubbleWidth))
|
||||||
}
|
}
|
||||||
@@ -619,6 +646,61 @@ extension MessageCellLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-forward: add height for additional items (Android parity).
|
||||||
|
// Item 0 is already included in bubbleH above. Items 1+ are appended.
|
||||||
|
// IMPORTANT: widen bubbleW FIRST (for sender names), THEN compute heights
|
||||||
|
// using the final bubbleW. Both passes must use the same width to avoid
|
||||||
|
// collage height mismatch between height estimation and frame positioning.
|
||||||
|
var additionalFwdH: CGFloat = 0
|
||||||
|
if config.isForward && config.allForwardItems.count > 1 {
|
||||||
|
// Step 1: Widen bubbleW for additional senders' two-row headers
|
||||||
|
let fwdNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
let fwdLabelW = ("Forwarded from" as NSString).size(
|
||||||
|
withAttributes: [.font: UIFont.systemFont(ofSize: 14, weight: .regular)]
|
||||||
|
).width
|
||||||
|
for i in 1..<config.allForwardItems.count {
|
||||||
|
let nameW = (config.allForwardItems[i].senderName as NSString).size(
|
||||||
|
withAttributes: [.font: fwdNameFont]
|
||||||
|
).width
|
||||||
|
let neededW = ceil(max(fwdLabelW + 20, nameW + 40))
|
||||||
|
bubbleW = max(bubbleW, min(neededW, effectiveMaxBubbleWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Compute heights with final bubbleW
|
||||||
|
let fwdTextFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||||
|
let fwdMaxTextW = bubbleW - leftPad - rightPad
|
||||||
|
|
||||||
|
for i in 1..<config.allForwardItems.count {
|
||||||
|
let item = config.allForwardItems[i]
|
||||||
|
additionalFwdH += 8 // divider spacing
|
||||||
|
additionalFwdH += 38 // forward header (two-row)
|
||||||
|
|
||||||
|
if item.imageCount > 0 {
|
||||||
|
let ph = Self.collageHeight(
|
||||||
|
count: item.imageCount, width: bubbleW - 4,
|
||||||
|
maxHeight: mediaDimensions.maxHeight,
|
||||||
|
minHeight: mediaDimensions.minHeight
|
||||||
|
)
|
||||||
|
additionalFwdH += ph + 4 // photo + 2pt inset top/bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalFwdH += CGFloat(item.fileCount) * 52
|
||||||
|
additionalFwdH += CGFloat(item.voiceCount) * 44
|
||||||
|
|
||||||
|
let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
|
||||||
|
let textSize = (cleanCaption as NSString).boundingRect(
|
||||||
|
with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
|
||||||
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||||
|
attributes: [.font: fwdTextFont],
|
||||||
|
context: nil
|
||||||
|
).size
|
||||||
|
additionalFwdH += 2 + ceil(textSize.height) + 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bubbleH += additionalFwdH
|
||||||
|
|
||||||
// Date header adds height above the bubble.
|
// Date header adds height above the bubble.
|
||||||
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
||||||
|
|
||||||
@@ -765,9 +847,79 @@ extension MessageCellLayout {
|
|||||||
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
|
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
|
||||||
let fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH)
|
let fileFrame = CGRect(x: 0, y: contentTopOffset, width: bubbleW, height: fileH)
|
||||||
|
|
||||||
let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17)
|
// Two-row header: "Forwarded from" on line 1, [avatar] Name on line 2
|
||||||
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16)
|
let fwdHeaderFrame = CGRect(x: 10, y: 5, width: bubbleW - 20, height: 14)
|
||||||
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17)
|
let fwdAvatarFrame = CGRect(x: 10, y: 20, width: 16, height: 16)
|
||||||
|
let fwdNameFrame = CGRect(x: 30, y: 20, width: bubbleW - 40, height: 17)
|
||||||
|
|
||||||
|
// Multi-forward: compute per-item frame positions (items 2+).
|
||||||
|
var additionalFwdLayouts: [ForwardItemFrame] = []
|
||||||
|
if config.isForward && config.allForwardItems.count > 1 {
|
||||||
|
let fwdTextFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||||
|
let fwdMaxTextW = bubbleW - leftPad - rightPad
|
||||||
|
|
||||||
|
// Where does the first item's content end?
|
||||||
|
var currentY: CGFloat
|
||||||
|
if !config.text.isEmpty {
|
||||||
|
currentY = textFrame.maxY + 4
|
||||||
|
} else if photoH > 0 {
|
||||||
|
currentY = photoFrame.maxY + 2
|
||||||
|
} else if fileH > 0 {
|
||||||
|
currentY = fileFrame.maxY
|
||||||
|
} else {
|
||||||
|
currentY = forwardHeaderH
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 1..<config.allForwardItems.count {
|
||||||
|
let item = config.allForwardItems[i]
|
||||||
|
let divY = currentY
|
||||||
|
currentY += 8
|
||||||
|
|
||||||
|
// Two-row header: "Forwarded from" + [avatar] Name
|
||||||
|
let hdrFrame = CGRect(x: 10, y: currentY + 5, width: bubbleW - 20, height: 14)
|
||||||
|
let avtFrame = CGRect(x: 10, y: currentY + 20, width: 16, height: 16)
|
||||||
|
let nmFrame = CGRect(x: 30, y: currentY + 20, width: bubbleW - 40, height: 17)
|
||||||
|
currentY += 38
|
||||||
|
|
||||||
|
var pFrame: CGRect = .zero
|
||||||
|
if item.imageCount > 0 {
|
||||||
|
let ph = Self.collageHeight(
|
||||||
|
count: item.imageCount, width: bubbleW - 4,
|
||||||
|
maxHeight: mediaDimensions.maxHeight,
|
||||||
|
minHeight: mediaDimensions.minHeight
|
||||||
|
)
|
||||||
|
pFrame = CGRect(x: 2, y: currentY + 2, width: bubbleW - 4, height: ph)
|
||||||
|
currentY += ph + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += CGFloat(item.fileCount) * 52
|
||||||
|
currentY += CGFloat(item.voiceCount) * 44
|
||||||
|
|
||||||
|
var tFrame: CGRect = .zero
|
||||||
|
let cleanCaption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !cleanCaption.isEmpty && !isGarbageOrEncrypted(cleanCaption) {
|
||||||
|
let textSize = (cleanCaption as NSString).boundingRect(
|
||||||
|
with: CGSize(width: fwdMaxTextW, height: .greatestFiniteMagnitude),
|
||||||
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||||
|
attributes: [.font: fwdTextFont],
|
||||||
|
context: nil
|
||||||
|
).size
|
||||||
|
tFrame = CGRect(x: leftPad, y: currentY + 2,
|
||||||
|
width: fwdMaxTextW, height: ceil(textSize.height))
|
||||||
|
currentY += 2 + ceil(textSize.height) + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalFwdLayouts.append(ForwardItemFrame(
|
||||||
|
dividerY: divY,
|
||||||
|
headerFrame: hdrFrame,
|
||||||
|
avatarFrame: avtFrame,
|
||||||
|
nameFrame: nmFrame,
|
||||||
|
textFrame: tFrame,
|
||||||
|
photoFrame: pFrame,
|
||||||
|
info: item
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let layout = MessageCellLayout(
|
let layout = MessageCellLayout(
|
||||||
totalHeight: totalH,
|
totalHeight: totalH,
|
||||||
@@ -808,6 +960,7 @@ extension MessageCellLayout {
|
|||||||
forwardNameFrame: fwdNameFrame,
|
forwardNameFrame: fwdNameFrame,
|
||||||
forwardChachaKeyPlain: config.forwardChachaKeyPlain,
|
forwardChachaKeyPlain: config.forwardChachaKeyPlain,
|
||||||
forwardAttachments: config.forwardAttachments,
|
forwardAttachments: config.forwardAttachments,
|
||||||
|
additionalForwardItems: additionalFwdLayouts,
|
||||||
showsDateHeader: config.showsDateHeader,
|
showsDateHeader: config.showsDateHeader,
|
||||||
dateHeaderText: config.dateHeaderText,
|
dateHeaderText: config.dateHeaderText,
|
||||||
dateHeaderHeight: dateHeaderH,
|
dateHeaderHeight: dateHeaderH,
|
||||||
@@ -1137,6 +1290,7 @@ extension MessageCellLayout {
|
|||||||
var forwardSenderName = ""
|
var forwardSenderName = ""
|
||||||
var forwardChachaKeyPlain = ""
|
var forwardChachaKeyPlain = ""
|
||||||
var forwardAttachments: [ReplyAttachmentData] = []
|
var forwardAttachments: [ReplyAttachmentData] = []
|
||||||
|
var allForwardItems: [ForwardItemInfo] = []
|
||||||
if isForward,
|
if isForward,
|
||||||
let att = message.attachments.first(where: { $0.type == .messages }),
|
let att = message.attachments.first(where: { $0.type == .messages }),
|
||||||
let data = att.blob.data(using: .utf8),
|
let data = att.blob.data(using: .utf8),
|
||||||
@@ -1160,6 +1314,32 @@ extension MessageCellLayout {
|
|||||||
} else {
|
} else {
|
||||||
forwardSenderName = DialogRepository.shared.dialogs[senderKey]?.opponentTitle ?? String(senderKey.prefix(8)) + "…"
|
forwardSenderName = DialogRepository.shared.dialogs[senderKey]?.opponentTitle ?? String(senderKey.prefix(8)) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build ForwardItemInfo for ALL items (multi-forward Android parity)
|
||||||
|
for reply in replies {
|
||||||
|
let rKey = reply.publicKey
|
||||||
|
let rName: String
|
||||||
|
if rKey == currentPublicKey {
|
||||||
|
rName = "You"
|
||||||
|
} else if rKey == opponentPublicKey {
|
||||||
|
rName = opponentTitle.isEmpty ? String(rKey.prefix(8)) + "…" : opponentTitle
|
||||||
|
} else {
|
||||||
|
rName = DialogRepository.shared.dialogs[rKey]?.opponentTitle ?? String(rKey.prefix(8)) + "…"
|
||||||
|
}
|
||||||
|
let rText = reply.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rCaption = (!rText.isEmpty && !isGarbageOrEncrypted(rText))
|
||||||
|
? EmojiParser.replaceShortcodes(in: rText) : ""
|
||||||
|
allForwardItems.append(ForwardItemInfo(
|
||||||
|
senderName: rName,
|
||||||
|
senderKey: rKey,
|
||||||
|
caption: rCaption,
|
||||||
|
chachaKeyPlain: reply.chacha_key_plain,
|
||||||
|
attachments: reply.attachments,
|
||||||
|
imageCount: reply.attachments.filter { $0.type == 0 }.count,
|
||||||
|
fileCount: reply.attachments.filter { $0.type == 2 }.count,
|
||||||
|
voiceCount: reply.attachments.filter { $0.type == 5 }.count
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse image dimensions from preview field (format: "tag::blurhash::WxH")
|
// Parse image dimensions from preview field (format: "tag::blurhash::WxH")
|
||||||
@@ -1217,6 +1397,7 @@ extension MessageCellLayout {
|
|||||||
forwardSenderName: forwardSenderName,
|
forwardSenderName: forwardSenderName,
|
||||||
forwardChachaKeyPlain: forwardChachaKeyPlain,
|
forwardChachaKeyPlain: forwardChachaKeyPlain,
|
||||||
forwardAttachments: forwardAttachments,
|
forwardAttachments: forwardAttachments,
|
||||||
|
allForwardItems: allForwardItems,
|
||||||
senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "",
|
senderName: (isGroupChat && !isOutgoing) ? (DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle ?? String(message.fromPublicKey.prefix(8))) : "",
|
||||||
isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey)
|
isGroupAdmin: (isGroupChat && !isOutgoing && !groupAdminKey.isEmpty && message.fromPublicKey == groupAdminKey)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -407,54 +407,85 @@ final class SessionManager {
|
|||||||
/// Sends current user's avatar to a chat as a message attachment.
|
/// Sends current user's avatar to a chat as a message attachment.
|
||||||
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
|
/// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type
|
||||||
/// → `prepareAttachmentsToSend()` encrypts blob → uploads to transport → sends PacketMessage.
|
/// → `prepareAttachmentsToSend()` encrypts blob → uploads to transport → sends PacketMessage.
|
||||||
func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws {
|
/// Sends an avatar to a chat as a message attachment.
|
||||||
|
/// - Parameter avatarSourceKey: Key to load avatar from. `nil` = personal avatar (currentPublicKey).
|
||||||
|
/// Pass groupDialogKey to send the group's avatar instead.
|
||||||
|
func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "", avatarSourceKey: String? = nil) async throws {
|
||||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||||
Self.logger.error("📤 Cannot send avatar — missing keys")
|
Self.logger.error("📤 Cannot send avatar — missing keys")
|
||||||
throw CryptoError.decryptionFailed
|
throw CryptoError.decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sourceKey = avatarSourceKey ?? currentPublicKey
|
||||||
// Load avatar from local storage as base64 (desktop: avatars[0].avatar)
|
// Load avatar from local storage as base64 (desktop: avatars[0].avatar)
|
||||||
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: currentPublicKey) else {
|
guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: sourceKey) else {
|
||||||
Self.logger.error("📤 No avatar to send")
|
Self.logger.error("📤 No avatar to send (source=\(sourceKey.prefix(12))…)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
let randomPart = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
||||||
|
// Mark group avatar attachments with "ga_" prefix so UI can distinguish
|
||||||
|
// "Shared group photo" vs "Shared profile photo" in the same group chat.
|
||||||
|
let isGroupAvatarSource = avatarSourceKey != nil && DatabaseManager.isGroupDialogKey(avatarSourceKey!)
|
||||||
|
let attachmentId = isGroupAvatarSource ? "ga_\(randomPart)" : randomPart
|
||||||
|
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
|
||||||
|
|
||||||
// Android/Desktop parity: avatar messages have empty text.
|
// Android/Desktop parity: avatar messages have empty text.
|
||||||
// Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
|
// Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
|
||||||
// Sending " " (space) causes Desktop chat list to show nothing.
|
// Group vs direct: different encryption paths (same as sendMessageWithAttachments).
|
||||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
let attachmentPassword: String
|
||||||
plaintext: "",
|
let encryptedContent: String
|
||||||
recipientPublicKeyHex: toPublicKey
|
let outChachaKey: String
|
||||||
)
|
let outAesChachaKey: String
|
||||||
|
let targetKey: String
|
||||||
|
|
||||||
// Attachment password: HEX encoding of raw 56-byte key+nonce.
|
if isGroup {
|
||||||
// Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex').
|
targetKey = Self.normalizedGroupDialogIdentity(toPublicKey)
|
||||||
// HEX is lossless for all byte values (no U+FFFD data loss).
|
guard let groupKey = GroupRepository.shared.groupKey(
|
||||||
let attachmentPassword = encrypted.plainKeyAndNonce.hexString
|
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:
|
// aesChachaKey = Latin-1 encoding (matches desktop sync chain)
|
||||||
// Buffer.from(decryptedString, 'binary') takes low byte of each char).
|
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||||
// NEVER use WHATWG UTF-8 for aesChachaKey — U+FFFD round-trips as 0xFD, not original byte.
|
throw CryptoError.encryptionFailed
|
||||||
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...")
|
// Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...")
|
||||||
// not just raw base64. Desktop's AvatarProvider stores and sends data URIs.
|
|
||||||
let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
|
let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
|
||||||
let avatarData = Data(dataURI.utf8)
|
let avatarData = Data(dataURI.utf8)
|
||||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||||
avatarData,
|
avatarData, password: attachmentPassword
|
||||||
password: attachmentPassword
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
|
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
|
||||||
// (same pattern as sendMessageWithAttachments — AttachmentCache.saveImage before upload).
|
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: sourceKey)
|
||||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
|
|
||||||
if let avatarImage {
|
if let avatarImage {
|
||||||
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
|
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
|
||||||
}
|
}
|
||||||
@@ -462,70 +493,69 @@ final class SessionManager {
|
|||||||
// BlurHash for preview (computed before upload so optimistic UI has it)
|
// BlurHash for preview (computed before upload so optimistic UI has it)
|
||||||
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
||||||
|
|
||||||
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
|
|
||||||
let aesChachaPayload = Data(latin1ForSync.utf8)
|
|
||||||
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
|
||||||
aesChachaPayload,
|
|
||||||
password: privKey
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build packet with avatar attachment — preview will be updated with tag after upload
|
// Build packet with avatar attachment — preview will be updated with tag after upload
|
||||||
var packet = PacketMessage()
|
var packet = PacketMessage()
|
||||||
packet.fromPublicKey = currentPublicKey
|
packet.fromPublicKey = currentPublicKey
|
||||||
packet.toPublicKey = toPublicKey
|
packet.toPublicKey = targetKey
|
||||||
packet.content = encrypted.content
|
packet.content = encryptedContent
|
||||||
packet.chachaKey = encrypted.chachaKey
|
packet.chachaKey = outChachaKey
|
||||||
packet.timestamp = timestamp
|
packet.timestamp = timestamp
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.messageId = messageId
|
packet.messageId = messageId
|
||||||
packet.aesChachaKey = aesChachaKey
|
packet.aesChachaKey = outAesChachaKey
|
||||||
packet.attachments = [
|
packet.attachments = [
|
||||||
MessageAttachment(
|
MessageAttachment(
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
preview: blurhash, // Will be updated with "tag::blurhash" after upload
|
preview: blurhash,
|
||||||
blob: "",
|
blob: "",
|
||||||
type: .avatar
|
type: .avatar
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Ensure dialog exists
|
// Ensure dialog exists
|
||||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
let dialogKey = isGroup ? targetKey : toPublicKey
|
||||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
let existingDialog = DialogRepository.shared.dialogs[dialogKey]
|
||||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
let groupMetadata = isGroup
|
||||||
|
? GroupRepository.shared.groupMetadata(account: currentPublicKey, groupDialogKey: targetKey)
|
||||||
|
: nil
|
||||||
|
let title = !opponentTitle.isEmpty
|
||||||
|
? opponentTitle
|
||||||
|
: (existingDialog?.opponentTitle.isEmpty == false
|
||||||
|
? (existingDialog?.opponentTitle ?? "")
|
||||||
|
: (groupMetadata?.title ?? ""))
|
||||||
|
let username = !opponentUsername.isEmpty
|
||||||
|
? opponentUsername
|
||||||
|
: (existingDialog?.opponentUsername.isEmpty == false
|
||||||
|
? (existingDialog?.opponentUsername ?? "")
|
||||||
|
: (groupMetadata?.description ?? ""))
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: toPublicKey,
|
opponentKey: dialogKey, title: title, username: username, myPublicKey: currentPublicKey
|
||||||
title: title,
|
|
||||||
username: username,
|
|
||||||
myPublicKey: currentPublicKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optimistic UI — show message IMMEDIATELY (before upload)
|
// Optimistic UI — show message IMMEDIATELY (before upload)
|
||||||
|
let storedPassword = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword)
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet, myPublicKey: currentPublicKey, decryptedText: "",
|
packet, myPublicKey: currentPublicKey, decryptedText: "",
|
||||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
attachmentPassword: storedPassword,
|
||||||
fromSync: false
|
fromSync: false,
|
||||||
|
dialogIdentityOverride: dialogKey
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
|
||||||
|
|
||||||
// Upload encrypted blob to transport server in background (desktop: uploadFile)
|
// Upload encrypted blob to transport server
|
||||||
let upload: (tag: String, server: String)
|
let upload: (tag: String, server: String)
|
||||||
do {
|
do {
|
||||||
upload = try await attachmentFlowTransport.uploadFile(
|
upload = try await attachmentFlowTransport.uploadFile(
|
||||||
id: attachmentId,
|
id: attachmentId, content: Data(encryptedBlob.utf8)
|
||||||
content: Data(encryptedBlob.utf8)
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Upload failed — mark as error
|
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: dialogKey, status: .error)
|
||||||
Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
|
Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop parity: preview = pure blurhash (no tag prefix).
|
// Desktop parity: preview = pure blurhash, CDN tag in transportTag
|
||||||
// Desktop MessageAvatar.tsx passes preview directly to blurhash decoder —
|
|
||||||
// including the "tag::" prefix causes "blurhash length mismatch" errors.
|
|
||||||
// CDN tag is stored in transportTag for download.
|
|
||||||
packet.attachments = [
|
packet.attachments = [
|
||||||
MessageAttachment(
|
MessageAttachment(
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
@@ -538,19 +568,23 @@ final class SessionManager {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Saved Messages — mark delivered locally but STILL send to server
|
// Saved Messages — mark delivered locally but STILL send to server
|
||||||
// for cross-device avatar sync. Other devices receive via sync and
|
|
||||||
// update their local avatar cache.
|
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||||
// Send to server for multi-device sync (unlike text Saved Messages)
|
|
||||||
packetFlowSender.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)")
|
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(upload.tag)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
packetFlowSender.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
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()
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)")
|
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(upload.tag)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct ChatDetailView: View {
|
|||||||
@State private var replyingToMessage: ChatMessage?
|
@State private var replyingToMessage: ChatMessage?
|
||||||
@State private var showForwardPicker = false
|
@State private var showForwardPicker = false
|
||||||
@State private var forwardingMessage: ChatMessage?
|
@State private var forwardingMessage: ChatMessage?
|
||||||
|
@State private var forwardingMessages: [ChatMessage] = []
|
||||||
@State private var pendingGroupInvite: String?
|
@State private var pendingGroupInvite: String?
|
||||||
@State private var pendingGroupInviteTitle: String?
|
@State private var pendingGroupInviteTitle: String?
|
||||||
@State private var mentionChatRoute: ChatRoute?
|
@State private var mentionChatRoute: ChatRoute?
|
||||||
@@ -390,10 +391,18 @@ struct ChatDetailView: View {
|
|||||||
.sheet(isPresented: $showForwardPicker) {
|
.sheet(isPresented: $showForwardPicker) {
|
||||||
ForwardChatPickerView { targetRoutes in
|
ForwardChatPickerView { targetRoutes in
|
||||||
showForwardPicker = false
|
showForwardPicker = false
|
||||||
guard let message = forwardingMessage else { return }
|
let msgs: [ChatMessage]
|
||||||
forwardingMessage = nil
|
if !forwardingMessages.isEmpty {
|
||||||
|
msgs = forwardingMessages
|
||||||
|
forwardingMessages = []
|
||||||
|
} else if let single = forwardingMessage {
|
||||||
|
msgs = [single]
|
||||||
|
forwardingMessage = nil
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
for route in targetRoutes {
|
for route in targetRoutes {
|
||||||
forwardMessage(message, to: route)
|
forwardMessages(msgs, to: route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -890,9 +899,8 @@ private extension ChatDetailView {
|
|||||||
let selected = messages
|
let selected = messages
|
||||||
.filter { selectedMessageIds.contains($0.id) }
|
.filter { selectedMessageIds.contains($0.id) }
|
||||||
.sorted { $0.timestamp < $1.timestamp }
|
.sorted { $0.timestamp < $1.timestamp }
|
||||||
guard let first = selected.first else { return }
|
guard !selected.isEmpty else { return }
|
||||||
// For now: forward first selected message, exit selection
|
forwardingMessages = selected
|
||||||
forwardingMessage = first
|
|
||||||
showForwardPicker = true
|
showForwardPicker = true
|
||||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false }
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isMultiSelectMode = false }
|
||||||
selectedMessageIds.removeAll()
|
selectedMessageIds.removeAll()
|
||||||
@@ -1445,29 +1453,24 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
// MARK: - Forward
|
// MARK: - Forward
|
||||||
|
|
||||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
/// Batch-forwards multiple messages as a SINGLE packet (Android parity).
|
||||||
// Android parity: unwrap nested forwards.
|
/// All selected messages → one JSON array in one .messages attachment.
|
||||||
// If the message being forwarded is itself a forward, extract the inner
|
func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
|
||||||
// forwarded messages and re-forward them directly (flatten).
|
var allForwardData: [ReplyMessageData] = []
|
||||||
let forwardDataList: [ReplyMessageData]
|
for message in messages {
|
||||||
|
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||||
|
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|
||||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
if isForward,
|
||||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
let att = replyAttachment,
|
||||||
|
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
|
||||||
if isForward,
|
!innerMessages.isEmpty {
|
||||||
let att = replyAttachment,
|
allForwardData.append(contentsOf: innerMessages)
|
||||||
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
|
} else {
|
||||||
!innerMessages.isEmpty {
|
allForwardData.append(buildReplyData(from: message))
|
||||||
// Unwrap: forward the original messages, not the wrapper
|
}
|
||||||
forwardDataList = innerMessages
|
|
||||||
} else {
|
|
||||||
// Regular message — forward as-is
|
|
||||||
forwardDataList = [buildReplyData(from: message)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop commit aaa4b42: no re-upload needed.
|
|
||||||
// chacha_key_plain in ReplyMessageData carries the original key,
|
|
||||||
// so the recipient can decrypt original CDN blobs directly.
|
|
||||||
let targetKey = targetRoute.publicKey
|
let targetKey = targetRoute.publicKey
|
||||||
let targetTitle = targetRoute.title
|
let targetTitle = targetRoute.title
|
||||||
let targetUsername = targetRoute.username
|
let targetUsername = targetRoute.username
|
||||||
@@ -1476,13 +1479,13 @@ private extension ChatDetailView {
|
|||||||
do {
|
do {
|
||||||
try await SessionManager.shared.sendMessageWithReply(
|
try await SessionManager.shared.sendMessageWithReply(
|
||||||
text: "",
|
text: "",
|
||||||
replyMessages: forwardDataList,
|
replyMessages: allForwardData,
|
||||||
toPublicKey: targetKey,
|
toPublicKey: targetKey,
|
||||||
opponentTitle: targetTitle,
|
opponentTitle: targetTitle,
|
||||||
opponentUsername: targetUsername
|
opponentUsername: targetUsername
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
sendError = "Failed to forward message"
|
sendError = "Failed to forward messages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
willShow viewController: UIViewController,
|
willShow viewController: UIViewController,
|
||||||
animated: Bool
|
animated: Bool
|
||||||
) {
|
) {
|
||||||
let hide = viewController === self
|
let hide = viewController === self || viewController is OpponentProfileViewController
|
||||||
navigationController.setNavigationBarHidden(hide, animated: animated)
|
navigationController.setNavigationBarHidden(hide, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +736,8 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
|
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
|
||||||
navigationController?.pushViewController(hosting, animated: true)
|
navigationController?.pushViewController(hosting, animated: true)
|
||||||
} else if !route.isSystemAccount {
|
} else if !route.isSystemAccount {
|
||||||
// Peer profile — UIKit wrapper (Phase 1), nav bar managed by the VC itself
|
// Peer profile — pure UIKit, nav bar hidden (custom back button)
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
let profileVC = OpponentProfileViewController(route: route)
|
let profileVC = OpponentProfileViewController(route: route)
|
||||||
navigationController?.pushViewController(profileVC, animated: true)
|
navigationController?.pushViewController(profileVC, animated: true)
|
||||||
}
|
}
|
||||||
@@ -864,9 +865,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.dismiss(animated: true)
|
self.dismiss(animated: true)
|
||||||
for route in targetRoutes {
|
for route in targetRoutes {
|
||||||
for message in messagesToForward {
|
self.forwardMessages(messagesToForward, to: route)
|
||||||
self.forwardMessage(message, to: route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hosting = UIHostingController(rootView: picker)
|
let hosting = UIHostingController(rootView: picker)
|
||||||
@@ -1320,6 +1319,20 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendAvatarToChat() {
|
private func sendAvatarToChat() {
|
||||||
|
if route.isGroup {
|
||||||
|
let cached = GroupRepository.shared.cachedMembers(
|
||||||
|
account: currentPublicKey,
|
||||||
|
groupDialogKey: route.publicKey
|
||||||
|
)
|
||||||
|
if cached?.adminKey == currentPublicKey {
|
||||||
|
showAvatarActionSheet()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
performSendAvatar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSendAvatar() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
try await SessionManager.shared.sendAvatar(
|
try await SessionManager.shared.sendAvatar(
|
||||||
@@ -1333,6 +1346,38 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showAvatarActionSheet() {
|
||||||
|
let hasGroupAvatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
|
||||||
|
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||||
|
sheet.addAction(UIAlertAction(title: "Send My Avatar", style: .default) { [weak self] _ in
|
||||||
|
self?.performSendAvatar()
|
||||||
|
})
|
||||||
|
if hasGroupAvatar {
|
||||||
|
sheet.addAction(UIAlertAction(title: "Share Group Avatar", style: .default) { [weak self] _ in
|
||||||
|
self?.shareGroupAvatar()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareGroupAvatar() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await SessionManager.shared.sendAvatar(
|
||||||
|
toPublicKey: route.publicKey,
|
||||||
|
opponentTitle: route.title,
|
||||||
|
opponentUsername: route.username,
|
||||||
|
avatarSourceKey: route.publicKey
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
showAlert(title: "Send Error", message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleComposerUserTyping() {
|
private func handleComposerUserTyping() {
|
||||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||||
@@ -1442,14 +1487,18 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Forward
|
// MARK: - Forward
|
||||||
|
|
||||||
private func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
/// Batch-forwards multiple messages as a SINGLE packet (Android parity).
|
||||||
// Unwrap forwarded messages if the message itself is a forward (.messages attachment)
|
/// All selected messages → one JSON array in one .messages attachment.
|
||||||
var forwardDataList: [ReplyMessageData]
|
private func forwardMessages(_ messages: [ChatMessage], to targetRoute: ChatRoute) {
|
||||||
if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
|
var allForwardData: [ReplyMessageData] = []
|
||||||
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
|
for message in messages {
|
||||||
forwardDataList = innerMessages
|
// Unwrap nested forwards (flatten)
|
||||||
} else {
|
if let msgAtt = message.attachments.first(where: { $0.type == .messages }),
|
||||||
forwardDataList = [buildReplyData(from: message)]
|
let innerMessages = parseReplyBlob(msgAtt.blob), !innerMessages.isEmpty {
|
||||||
|
allForwardData.append(contentsOf: innerMessages)
|
||||||
|
} else {
|
||||||
|
allForwardData.append(buildReplyData(from: message))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetKey = targetRoute.publicKey
|
let targetKey = targetRoute.publicKey
|
||||||
@@ -1460,7 +1509,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
do {
|
do {
|
||||||
try await SessionManager.shared.sendMessageWithReply(
|
try await SessionManager.shared.sendMessageWithReply(
|
||||||
text: "",
|
text: "",
|
||||||
replyMessages: forwardDataList,
|
replyMessages: allForwardData,
|
||||||
toPublicKey: targetKey,
|
toPublicKey: targetKey,
|
||||||
opponentTitle: targetTitle,
|
opponentTitle: targetTitle,
|
||||||
opponentUsername: targetUsername
|
opponentUsername: targetUsername
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ struct MessageAvatarView: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(RosettaColors.error)
|
.foregroundStyle(RosettaColors.error)
|
||||||
} else if avatarImage != nil {
|
} else if avatarImage != nil {
|
||||||
Text("Shared profile photo.")
|
Text(attachment.id.hasPrefix("ga_")
|
||||||
|
? "Shared group photo."
|
||||||
|
: "Shared profile photo.")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -262,11 +264,14 @@ struct MessageAvatarView: View {
|
|||||||
if let downloadedImage {
|
if let downloadedImage {
|
||||||
avatarImage = downloadedImage
|
avatarImage = downloadedImage
|
||||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||||
// Android parity: save avatar to sender's profile after download
|
// Desktop parity: in group chats save as group avatar,
|
||||||
let senderKey = message.fromPublicKey
|
// in personal chats save as sender's avatar
|
||||||
|
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
|
||||||
|
? message.toPublicKey
|
||||||
|
: message.fromPublicKey
|
||||||
if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) {
|
if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) {
|
||||||
let base64 = jpegData.base64EncodedString()
|
let base64 = jpegData.base64EncodedString()
|
||||||
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
|
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: avatarKey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
downloadError = true
|
downloadError = true
|
||||||
|
|||||||
@@ -204,13 +204,16 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
// Avatar-specific
|
// Avatar-specific
|
||||||
private let avatarImageView = UIImageView()
|
private let avatarImageView = UIImageView()
|
||||||
|
|
||||||
// Forward header
|
// Forward header (item 0)
|
||||||
private let forwardLabel = UILabel()
|
private let forwardLabel = UILabel()
|
||||||
private let forwardAvatarView = UIView()
|
private let forwardAvatarView = UIView()
|
||||||
private let forwardAvatarInitialLabel = UILabel()
|
private let forwardAvatarInitialLabel = UILabel()
|
||||||
private let forwardAvatarImageView = UIImageView()
|
private let forwardAvatarImageView = UIImageView()
|
||||||
private let forwardNameLabel = UILabel()
|
private let forwardNameLabel = UILabel()
|
||||||
|
|
||||||
|
// Additional forward items (items 1+, Android parity)
|
||||||
|
private var additionalForwardViews: [ForwardItemSubview] = []
|
||||||
|
|
||||||
// Group sender info (Telegram parity)
|
// Group sender info (Telegram parity)
|
||||||
private let senderNameLabel = UILabel()
|
private let senderNameLabel = UILabel()
|
||||||
private let senderAdminIconView = UIImageView()
|
private let senderAdminIconView = UIImageView()
|
||||||
@@ -829,15 +832,16 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
replyContainer.isHidden = true
|
replyContainer.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward
|
// Forward — two-row: "Forwarded from" + [avatar] **Name**
|
||||||
if let forwardSenderName {
|
if let forwardSenderName {
|
||||||
forwardLabel.isHidden = false
|
forwardLabel.isHidden = false
|
||||||
forwardAvatarView.isHidden = false
|
forwardAvatarView.isHidden = false
|
||||||
forwardNameLabel.isHidden = false
|
forwardNameLabel.isHidden = false
|
||||||
forwardNameLabel.text = forwardSenderName
|
|
||||||
// Telegram: same accentTextColor for both title and name
|
|
||||||
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
|
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
|
||||||
|
forwardLabel.text = "Forwarded from"
|
||||||
forwardLabel.textColor = accent
|
forwardLabel.textColor = accent
|
||||||
|
forwardNameLabel.text = forwardSenderName
|
||||||
|
forwardNameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
forwardNameLabel.textColor = accent
|
forwardNameLabel.textColor = accent
|
||||||
// Avatar: real photo if available, otherwise initial + color
|
// Avatar: real photo if available, otherwise initial + color
|
||||||
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
|
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
|
||||||
@@ -866,6 +870,26 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
forwardNameLabel.isHidden = true
|
forwardNameLabel.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional forward items (items 2+, Android parity)
|
||||||
|
if let layout = currentLayout {
|
||||||
|
let items = layout.additionalForwardItems
|
||||||
|
// Ensure enough subviews exist
|
||||||
|
while additionalForwardViews.count < items.count {
|
||||||
|
let sv = ForwardItemSubview()
|
||||||
|
bubbleView.insertSubview(sv, belowSubview: highlightOverlay)
|
||||||
|
additionalForwardViews.append(sv)
|
||||||
|
}
|
||||||
|
let isOut = layout.isOutgoing
|
||||||
|
for (i, itemFrame) in items.enumerated() {
|
||||||
|
let sv = additionalForwardViews[i]
|
||||||
|
sv.isHidden = false
|
||||||
|
sv.configure(info: itemFrame.info, isOutgoing: isOut)
|
||||||
|
}
|
||||||
|
for i in items.count..<additionalForwardViews.count {
|
||||||
|
additionalForwardViews[i].isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Photo (regular or forwarded)
|
// Photo (regular or forwarded)
|
||||||
if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty {
|
if let layout = currentLayout, layout.isForward, !layout.forwardAttachments.isEmpty {
|
||||||
configureForwardedPhotos()
|
configureForwardedPhotos()
|
||||||
@@ -1136,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
avatarImageView.image = cached
|
avatarImageView.image = cached
|
||||||
avatarImageView.isHidden = false
|
avatarImageView.isHidden = false
|
||||||
fileIconView.isHidden = true
|
fileIconView.isHidden = true
|
||||||
fileSizeLabel.text = "Shared profile photo"
|
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
||||||
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
|
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
|
||||||
} else {
|
} else {
|
||||||
if isOutgoing {
|
if isOutgoing {
|
||||||
// Own avatar — already uploaded, just loading from disk
|
// Own avatar — already uploaded, just loading from disk
|
||||||
fileSizeLabel.text = "Shared profile photo"
|
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
||||||
} else {
|
} else {
|
||||||
// Incoming avatar — needs download on tap (Android parity)
|
// Incoming avatar — needs download on tap (Android parity)
|
||||||
fileSizeLabel.text = "Tap to download"
|
fileSizeLabel.text = "Tap to download"
|
||||||
@@ -1184,7 +1208,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
self.avatarImageView.image = diskImage
|
self.avatarImageView.image = diskImage
|
||||||
self.avatarImageView.isHidden = false
|
self.avatarImageView.isHidden = false
|
||||||
self.fileIconView.isHidden = true
|
self.fileIconView.isHidden = true
|
||||||
self.fileSizeLabel.text = "Shared profile photo"
|
self.fileSizeLabel.text = attId.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CDN download is triggered by user tap via .triggerAttachmentDownload
|
// CDN download is triggered by user tap via .triggerAttachmentDownload
|
||||||
@@ -1571,7 +1595,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
|
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward
|
// Forward — two-row: "Forwarded from" + [avatar] Name
|
||||||
if layout.isForward {
|
if layout.isForward {
|
||||||
forwardLabel.frame = layout.forwardHeaderFrame
|
forwardLabel.frame = layout.forwardHeaderFrame
|
||||||
forwardAvatarView.frame = layout.forwardAvatarFrame
|
forwardAvatarView.frame = layout.forwardAvatarFrame
|
||||||
@@ -1579,6 +1603,21 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
forwardAvatarInitialLabel.frame = avatarBounds
|
forwardAvatarInitialLabel.frame = avatarBounds
|
||||||
forwardAvatarImageView.frame = avatarBounds
|
forwardAvatarImageView.frame = avatarBounds
|
||||||
forwardNameLabel.frame = layout.forwardNameFrame
|
forwardNameLabel.frame = layout.forwardNameFrame
|
||||||
|
|
||||||
|
// Additional forward items (items 2+)
|
||||||
|
for (i, itemFrame) in layout.additionalForwardItems.enumerated() {
|
||||||
|
if i < additionalForwardViews.count {
|
||||||
|
let subview = additionalForwardViews[i]
|
||||||
|
subview.isHidden = false
|
||||||
|
subview.applyFrames(itemFrame: itemFrame, bubbleWidth: layout.bubbleSize.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hide excess subviews
|
||||||
|
for i in layout.additionalForwardItems.count..<additionalForwardViews.count {
|
||||||
|
additionalForwardViews[i].isHidden = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for sv in additionalForwardViews { sv.isHidden = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style failed delivery badge outside bubble (slide + fade).
|
// Telegram-style failed delivery badge outside bubble (slide + fade).
|
||||||
@@ -1677,6 +1716,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
forwardLabel.frame.origin.y += senderNameShift
|
forwardLabel.frame.origin.y += senderNameShift
|
||||||
forwardAvatarView.frame.origin.y += senderNameShift
|
forwardAvatarView.frame.origin.y += senderNameShift
|
||||||
forwardNameLabel.frame.origin.y += senderNameShift
|
forwardNameLabel.frame.origin.y += senderNameShift
|
||||||
|
for sv in additionalForwardViews where !sv.isHidden {
|
||||||
|
sv.frame.origin.y += senderNameShift
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Re-apply bubble image with tail protrusion after height expansion
|
// Re-apply bubble image with tail protrusion after height expansion
|
||||||
let expandedImageFrame: CGRect
|
let expandedImageFrame: CGRect
|
||||||
@@ -2248,7 +2290,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
self.avatarImageView.image = downloaded
|
self.avatarImageView.image = downloaded
|
||||||
self.avatarImageView.isHidden = false
|
self.avatarImageView.isHidden = false
|
||||||
self.fileIconView.isHidden = true
|
self.fileIconView.isHidden = true
|
||||||
self.fileSizeLabel.text = "Shared profile photo"
|
self.fileSizeLabel.text = id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
|
||||||
// Trigger refresh of sender avatar circles in visible cells
|
// Trigger refresh of sender avatar circles in visible cells
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||||
@@ -3465,6 +3507,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
forwardLabel.isHidden = true
|
forwardLabel.isHidden = true
|
||||||
forwardAvatarView.isHidden = true
|
forwardAvatarView.isHidden = true
|
||||||
forwardNameLabel.isHidden = true
|
forwardNameLabel.isHidden = true
|
||||||
|
for sv in additionalForwardViews { sv.isHidden = true }
|
||||||
senderNameLabel.isHidden = true
|
senderNameLabel.isHidden = true
|
||||||
senderAdminIconView.isHidden = true
|
senderAdminIconView.isHidden = true
|
||||||
senderAvatarContainer.isHidden = true
|
senderAvatarContainer.isHidden = true
|
||||||
@@ -3710,3 +3753,125 @@ final class BubblePathCache {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - ForwardItemSubview (additional forward items, Android parity)
|
||||||
|
|
||||||
|
/// Renders a single additional forwarded message inside a multi-forward bubble.
|
||||||
|
/// Contains: divider line, "Forwarded from" header, avatar, sender name, caption text.
|
||||||
|
final class ForwardItemSubview: UIView {
|
||||||
|
private static let headerFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
|
private static let nameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
|
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||||
|
|
||||||
|
let dividerLine = UIView()
|
||||||
|
let headerLabel = UILabel()
|
||||||
|
let avatarView = UIView()
|
||||||
|
let avatarInitialLabel = UILabel()
|
||||||
|
let avatarImageView = UIImageView()
|
||||||
|
let nameLabel = UILabel()
|
||||||
|
let textLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||||
|
addSubview(dividerLine)
|
||||||
|
|
||||||
|
headerLabel.font = Self.headerFont
|
||||||
|
headerLabel.text = "Forwarded from"
|
||||||
|
addSubview(headerLabel)
|
||||||
|
|
||||||
|
avatarView.layer.cornerRadius = 8
|
||||||
|
avatarView.clipsToBounds = true
|
||||||
|
addSubview(avatarView)
|
||||||
|
|
||||||
|
avatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
|
||||||
|
avatarInitialLabel.textColor = .white
|
||||||
|
avatarInitialLabel.textAlignment = .center
|
||||||
|
avatarView.addSubview(avatarInitialLabel)
|
||||||
|
|
||||||
|
avatarImageView.contentMode = .scaleAspectFill
|
||||||
|
avatarImageView.clipsToBounds = true
|
||||||
|
avatarView.addSubview(avatarImageView)
|
||||||
|
|
||||||
|
nameLabel.font = Self.nameFont
|
||||||
|
addSubview(nameLabel)
|
||||||
|
|
||||||
|
textLabel.font = Self.textFont
|
||||||
|
textLabel.numberOfLines = 0
|
||||||
|
addSubview(textLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
func configure(info: ForwardItemInfo, isOutgoing: Bool) {
|
||||||
|
let accent: UIColor = isOutgoing ? .white : UIColor(red: 0.14, green: 0.54, blue: 0.9, alpha: 1)
|
||||||
|
headerLabel.text = "Forwarded from"
|
||||||
|
headerLabel.textColor = accent
|
||||||
|
nameLabel.isHidden = false
|
||||||
|
nameLabel.text = info.senderName
|
||||||
|
nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
nameLabel.textColor = accent
|
||||||
|
dividerLine.isHidden = true // spacing only, no visible line
|
||||||
|
|
||||||
|
textLabel.textColor = isOutgoing
|
||||||
|
? .white
|
||||||
|
: UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark ? .white : .darkText
|
||||||
|
}
|
||||||
|
let cleanCaption = info.caption.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption
|
||||||
|
textLabel.isHidden = cleanCaption.isEmpty
|
||||||
|
|
||||||
|
// Avatar: real photo if available, otherwise initial + color
|
||||||
|
let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5,
|
||||||
|
0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2]
|
||||||
|
if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) {
|
||||||
|
avatarImageView.image = avatarImage
|
||||||
|
avatarImageView.isHidden = false
|
||||||
|
avatarInitialLabel.isHidden = true
|
||||||
|
avatarView.backgroundColor = .clear
|
||||||
|
} else {
|
||||||
|
avatarImageView.image = nil
|
||||||
|
avatarImageView.isHidden = true
|
||||||
|
avatarInitialLabel.isHidden = false
|
||||||
|
avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased()
|
||||||
|
let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey)
|
||||||
|
let hex = hexes[colorIndex % hexes.count]
|
||||||
|
avatarView.backgroundColor = UIColor(
|
||||||
|
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(hex & 0xFF) / 255, alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFrames(itemFrame: ForwardItemFrame, bubbleWidth: CGFloat) {
|
||||||
|
// Compute bounding box: from divider top to max of all content
|
||||||
|
let topY = itemFrame.dividerY
|
||||||
|
var bottomY = max(itemFrame.headerFrame.maxY, itemFrame.nameFrame.maxY)
|
||||||
|
if itemFrame.avatarFrame.height > 0 { bottomY = max(bottomY, itemFrame.avatarFrame.maxY) }
|
||||||
|
if itemFrame.textFrame.height > 0 { bottomY = max(bottomY, itemFrame.textFrame.maxY) }
|
||||||
|
if itemFrame.photoFrame.height > 0 { bottomY = max(bottomY, itemFrame.photoFrame.maxY) }
|
||||||
|
|
||||||
|
// Set parent frame — positions this subview in bubble coords
|
||||||
|
frame = CGRect(x: 0, y: topY, width: bubbleWidth, height: bottomY - topY)
|
||||||
|
|
||||||
|
// All children in LOCAL coords (subtract topY)
|
||||||
|
let dy = topY
|
||||||
|
dividerLine.frame = CGRect(x: itemFrame.headerFrame.origin.x,
|
||||||
|
y: 0,
|
||||||
|
width: itemFrame.headerFrame.width,
|
||||||
|
height: 1)
|
||||||
|
|
||||||
|
headerLabel.frame = itemFrame.headerFrame.offsetBy(dx: 0, dy: -dy)
|
||||||
|
avatarView.frame = itemFrame.avatarFrame.offsetBy(dx: 0, dy: -dy)
|
||||||
|
avatarInitialLabel.frame = avatarView.bounds
|
||||||
|
avatarImageView.frame = avatarView.bounds
|
||||||
|
nameLabel.frame = itemFrame.nameFrame.offsetBy(dx: 0, dy: -dy)
|
||||||
|
if itemFrame.textFrame.height > 0 {
|
||||||
|
textLabel.frame = itemFrame.textFrame.offsetBy(dx: 0, dy: -dy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1466,14 +1466,6 @@ final class NativeMessageListController: UIViewController {
|
|||||||
&& newIds.count <= 3
|
&& newIds.count <= 3
|
||||||
&& messages.last?.id != oldNewestId
|
&& messages.last?.id != oldNewestId
|
||||||
|
|
||||||
if !newIds.isEmpty {
|
|
||||||
let hasPending = pendingVoiceCollapse != nil
|
|
||||||
print("[VOICE_ANIM] update() — newIds=\(newIds.count) isInteractive=\(isInteractive) hasCompletedInitialLoad=\(hasCompletedInitialLoad) newestChanged=\(messages.last?.id != oldNewestId) pendingVoice=\(hasPending)")
|
|
||||||
if let pending = pendingVoiceCollapse {
|
|
||||||
print("[VOICE_ANIM] pendingMessageId=\(pending.messageId) matchesNew=\(newIds.contains(pending.messageId))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
||||||
var oldPositions: [String: CGFloat] = [:]
|
var oldPositions: [String: CGFloat] = [:]
|
||||||
// Capture pill positions for matching spring animation
|
// Capture pill positions for matching spring animation
|
||||||
@@ -1503,22 +1495,36 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout calculation: sync for first load, async for subsequent updates.
|
// Layout calculation: sync for first load and interactive inserts,
|
||||||
|
// async for bulk updates.
|
||||||
|
// Interactive inserts (≤3 new messages) MUST be sync to avoid delayed
|
||||||
|
// reconfigureVisibleCells() from calculateLayoutsAsync killing animations.
|
||||||
if layoutCache.isEmpty {
|
if layoutCache.isEmpty {
|
||||||
// First load: synchronous to avoid blank cells
|
// First load: synchronous to avoid blank cells
|
||||||
calculateLayouts()
|
calculateLayouts()
|
||||||
|
} else if isInteractive {
|
||||||
|
// Interactive insert (1-3 messages): sync layout so no delayed reconfigure
|
||||||
|
var dirtyIds = newIds
|
||||||
|
for i in messages.indices where newIds.contains(messages[i].id) {
|
||||||
|
if i > 0 { dirtyIds.insert(messages[i - 1].id) }
|
||||||
|
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
|
||||||
|
}
|
||||||
|
calculateLayouts(dirtyIds: dirtyIds)
|
||||||
} else if !newIds.isEmpty && newIds.count <= 20 {
|
} else if !newIds.isEmpty && newIds.count <= 20 {
|
||||||
// Incremental: only new messages + neighbors, on background
|
// Incremental non-interactive: async on background
|
||||||
var dirtyIds = newIds
|
var dirtyIds = newIds
|
||||||
for i in messages.indices where newIds.contains(messages[i].id) {
|
for i in messages.indices where newIds.contains(messages[i].id) {
|
||||||
if i > 0 { dirtyIds.insert(messages[i - 1].id) }
|
if i > 0 { dirtyIds.insert(messages[i - 1].id) }
|
||||||
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
|
if i < messages.count - 1 { dirtyIds.insert(messages[i + 1].id) }
|
||||||
}
|
}
|
||||||
calculateLayoutsAsync(dirtyIds: dirtyIds)
|
calculateLayoutsAsync(dirtyIds: dirtyIds)
|
||||||
} else {
|
} else if !newIds.isEmpty {
|
||||||
// Bulk update (pagination, sync): async full recalculation
|
// Bulk update (pagination, sync): async full recalculation
|
||||||
calculateLayoutsAsync()
|
calculateLayoutsAsync()
|
||||||
}
|
}
|
||||||
|
// else: newIds is empty — no new messages, skip layout recalculation.
|
||||||
|
// Prevents Combine debounce duplicate from killing insertion animations
|
||||||
|
// via calculateLayoutsAsync → reconfigureVisibleCells → dataSource.apply.
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
||||||
snapshot.appendSections([0])
|
snapshot.appendSections([0])
|
||||||
@@ -1568,9 +1574,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
hideSkeletonAnimated()
|
hideSkeletonAnimated()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Telegram-style insertion animations after layout settles
|
|
||||||
if isInteractive {
|
if isInteractive {
|
||||||
collectionView.layoutIfNeeded()
|
|
||||||
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
|
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
|
||||||
|
|
||||||
// Animate date pills with same spring as cells
|
// Animate date pills with same spring as cells
|
||||||
@@ -1599,10 +1603,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Voice send: ensure cell is visible and animated, then fire collapse.
|
// Voice send: ensure cell is visible and animated, then fire collapse.
|
||||||
// Runs AFTER all insertion animations so it doesn't interfere.
|
|
||||||
if let match = voiceCorrelationMatch {
|
if let match = voiceCorrelationMatch {
|
||||||
print("[VOICE_ANIM] correlation matched! messageId=\(match.messageId)")
|
|
||||||
|
|
||||||
// Scroll to bottom first so the voice cell is in the viewport
|
// Scroll to bottom first so the voice cell is in the viewport
|
||||||
collectionView.setContentOffset(
|
collectionView.setContentOffset(
|
||||||
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
||||||
@@ -1610,39 +1611,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
)
|
)
|
||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
|
|
||||||
// Add dedicated animation for the voice cell if applyInsertionAnimations
|
// Voice cell animation is handled by UIKit's animatingDifferences: true.
|
||||||
// missed it (e.g., cell was off-screen or isInteractive was false)
|
// No additional animation needed here.
|
||||||
if let itemIndex = dataSource.snapshot().indexOfItem(match.messageId) {
|
|
||||||
let ip = IndexPath(item: itemIndex, section: 0)
|
|
||||||
let cell = collectionView.cellForItem(at: ip)
|
|
||||||
print("[VOICE_ANIM] itemIndex=\(itemIndex) cell=\(cell != nil) cellHeight=\(cell?.bounds.height ?? -1) hasSlideAnim=\(cell?.layer.animation(forKey: "insertionSlide") != nil)")
|
|
||||||
if let cell = cell {
|
|
||||||
// Only add animation if not already animating (avoid double-animation)
|
|
||||||
if cell.layer.animation(forKey: "insertionSlide") == nil {
|
|
||||||
let slideOffset = -cell.bounds.height * 1.2
|
|
||||||
let slide = CASpringAnimation(keyPath: "position.y")
|
|
||||||
slide.fromValue = slideOffset
|
|
||||||
slide.toValue = 0.0
|
|
||||||
slide.isAdditive = true
|
|
||||||
slide.stiffness = 555.0
|
|
||||||
slide.damping = 47.0
|
|
||||||
slide.mass = 1.0
|
|
||||||
slide.initialVelocity = 0
|
|
||||||
slide.duration = slide.settlingDuration
|
|
||||||
slide.fillMode = .backwards
|
|
||||||
cell.layer.add(slide, forKey: "insertionSlide")
|
|
||||||
|
|
||||||
let alpha = CABasicAnimation(keyPath: "opacity")
|
|
||||||
alpha.fromValue = 0.0
|
|
||||||
alpha.toValue = 1.0
|
|
||||||
alpha.duration = 0.12
|
|
||||||
alpha.fillMode = .backwards
|
|
||||||
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("[VOICE_SEND] correlation match — collapsing with animation")
|
|
||||||
match.collapseAction()
|
match.collapseAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,55 +1624,30 @@ final class NativeMessageListController: UIViewController {
|
|||||||
updateScrollToBottomBadge()
|
updateScrollToBottomBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Telegram-style message insertion animation (iOS 26+ parity).
|
/// Telegram-identical insertion animation:
|
||||||
/// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s).
|
/// - New cells: contentView alpha 0→1 over 0.2s (matches ChatMessageBubbleItemNode.animateInsertion)
|
||||||
/// Existing messages: spring position animation from old Y to new Y.
|
/// - Existing cells: spring position animation on vertical delta (cells shift up smoothly)
|
||||||
/// All position animations use CASpringAnimation (stiffness=555, damping=47).
|
/// Uses contentView.alpha (UIView.animate) instead of cell.layer CABasicAnimation because
|
||||||
/// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex.
|
/// reconfigureVisibleCells replaces content configuration but does NOT reset contentView.alpha.
|
||||||
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
|
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
|
||||||
let visibleIds = Set(collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) })
|
|
||||||
let newVisible = newIds.intersection(visibleIds)
|
|
||||||
let newMissing = newIds.subtracting(visibleIds)
|
|
||||||
if !newIds.isEmpty {
|
|
||||||
print("[VOICE_ANIM] applyInsertionAnimations — newIds=\(newIds.count) visible=\(newVisible.count) missing=\(newMissing.count) visibleIPs=\(collectionView.indexPathsForVisibleItems.count)")
|
|
||||||
}
|
|
||||||
for ip in collectionView.indexPathsForVisibleItems {
|
for ip in collectionView.indexPathsForVisibleItems {
|
||||||
guard let cellId = dataSource.itemIdentifier(for: ip),
|
guard let cellId = dataSource.itemIdentifier(for: ip),
|
||||||
let cell = collectionView.cellForItem(at: ip) else { continue }
|
let cell = collectionView.cellForItem(at: ip) else { continue }
|
||||||
|
|
||||||
if newIds.contains(cellId) {
|
if newIds.contains(cellId) {
|
||||||
// NEW cell: slide up from below + alpha fade
|
// Telegram: subnodes alpha 0→1 over 0.2s (animateInsertion/animateAdded)
|
||||||
// In inverted CV: negative offset = below on screen
|
cell.contentView.alpha = 0
|
||||||
let slideOffset = -cell.bounds.height * 1.2
|
UIView.animate(withDuration: 0.2) {
|
||||||
print("[VOICE_ANIM] animating new cell id=\(cellId.prefix(8)) height=\(cell.bounds.height) slideOffset=\(slideOffset)")
|
cell.contentView.alpha = 1
|
||||||
|
}
|
||||||
let slide = CASpringAnimation(keyPath: "position.y")
|
|
||||||
slide.fromValue = slideOffset
|
|
||||||
slide.toValue = 0.0
|
|
||||||
slide.isAdditive = true
|
|
||||||
slide.stiffness = 555.0
|
|
||||||
slide.damping = 47.0
|
|
||||||
slide.mass = 1.0
|
|
||||||
slide.initialVelocity = 0
|
|
||||||
slide.duration = slide.settlingDuration
|
|
||||||
slide.fillMode = .backwards
|
|
||||||
cell.layer.add(slide, forKey: "insertionSlide")
|
|
||||||
|
|
||||||
// Alpha fade: 0 → 1 (Telegram-parity: fast fade)
|
|
||||||
let alpha = CABasicAnimation(keyPath: "opacity")
|
|
||||||
alpha.fromValue = 0.0
|
|
||||||
alpha.toValue = 1.0
|
|
||||||
alpha.duration = 0.12
|
|
||||||
alpha.fillMode = .backwards
|
|
||||||
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
|
|
||||||
|
|
||||||
} else if let oldY = oldPositions[cellId] {
|
} else if let oldY = oldPositions[cellId] {
|
||||||
// EXISTING cell: spring from old position to new position
|
// Existing cell shifted — animate position delta with spring
|
||||||
let delta = oldY - cell.layer.position.y
|
let newY = cell.layer.position.y
|
||||||
guard abs(delta) > 0.5 else { continue }
|
let dy = oldY - newY
|
||||||
|
guard abs(dy) > 0.5 else { continue }
|
||||||
|
|
||||||
let move = CASpringAnimation(keyPath: "position.y")
|
let move = CASpringAnimation(keyPath: "position.y")
|
||||||
move.fromValue = delta
|
move.fromValue = dy
|
||||||
move.toValue = 0.0
|
move.toValue = 0.0
|
||||||
move.isAdditive = true
|
move.isAdditive = true
|
||||||
move.stiffness = 555.0
|
move.stiffness = 555.0
|
||||||
@@ -2242,14 +2188,12 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
|
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
|
||||||
print("[VOICE_SEND] composerDidFinishRecording — sendImmediately=\(sendImmediately) deferred=\(composer.voiceSendNeedsDeferred) url=\(composer.lastRecordedURL?.lastPathComponent ?? "nil")")
|
|
||||||
collectionView.keyboardDismissMode = .interactive
|
collectionView.keyboardDismissMode = .interactive
|
||||||
updateScrollToBottomButtonConstraints()
|
updateScrollToBottomButtonConstraints()
|
||||||
|
|
||||||
guard sendImmediately,
|
guard sendImmediately,
|
||||||
let url = composer.lastRecordedURL,
|
let url = composer.lastRecordedURL,
|
||||||
let data = try? Data(contentsOf: url) else {
|
let data = try? Data(contentsOf: url) else {
|
||||||
print("[VOICE_SEND] composerDidFinishRecording — GUARD FAILED")
|
|
||||||
// Guard fail while overlay may still be showing — force immediate collapse
|
// Guard fail while overlay may still be showing — force immediate collapse
|
||||||
if composer.voiceSendNeedsDeferred {
|
if composer.voiceSendNeedsDeferred {
|
||||||
composer.performDeferredVoiceSendCollapse()
|
composer.performDeferredVoiceSendCollapse()
|
||||||
@@ -2270,7 +2214,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
// Safety timer: if cell doesn't appear within 600ms, force collapse
|
// Safety timer: if cell doesn't appear within 600ms, force collapse
|
||||||
let timer = DispatchWorkItem { [weak self] in
|
let timer = DispatchWorkItem { [weak self] in
|
||||||
guard let self, let pending = self.pendingVoiceCollapse else { return }
|
guard let self, let pending = self.pendingVoiceCollapse else { return }
|
||||||
print("[VOICE_SEND] safety timer fired — forcing collapse")
|
|
||||||
self.pendingVoiceCollapse = nil
|
self.pendingVoiceCollapse = nil
|
||||||
pending.collapseAction()
|
pending.collapseAction()
|
||||||
}
|
}
|
||||||
@@ -2287,7 +2230,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
let title = config.opponentTitle
|
let title = config.opponentTitle
|
||||||
let username = config.opponentUsername
|
let username = config.opponentUsername
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
print("[VOICE_SEND] sendMessageWithAttachments START — messageId=\(messageId)")
|
|
||||||
_ = try? await SessionManager.shared.sendMessageWithAttachments(
|
_ = try? await SessionManager.shared.sendMessageWithAttachments(
|
||||||
text: "",
|
text: "",
|
||||||
attachments: [pending],
|
attachments: [pending],
|
||||||
@@ -2296,7 +2238,6 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
opponentUsername: username,
|
opponentUsername: username,
|
||||||
messageId: messageId
|
messageId: messageId
|
||||||
)
|
)
|
||||||
print("[VOICE_SEND] sendMessageWithAttachments DONE")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2326,13 +2267,11 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
|
|
||||||
private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) {
|
private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) {
|
||||||
guard let window = view.window else {
|
guard let window = view.window else {
|
||||||
print("[VOICE_SEND] resolveVoiceTargetFrame — no window, removing snapshot")
|
|
||||||
snapshot.removeFromSuperview()
|
snapshot.removeFromSuperview()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let maxAttempts = 12
|
let maxAttempts = 12
|
||||||
guard attempt <= maxAttempts else {
|
guard attempt <= maxAttempts else {
|
||||||
print("[VOICE_SEND] resolveVoiceTargetFrame — MAX ATTEMPTS reached, fading out snapshot")
|
|
||||||
UIView.animate(withDuration: 0.16, animations: {
|
UIView.animate(withDuration: 0.16, animations: {
|
||||||
snapshot.alpha = 0
|
snapshot.alpha = 0
|
||||||
snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
||||||
@@ -2344,16 +2283,11 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
|
|
||||||
let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window)
|
let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window)
|
||||||
guard let targetFrame else {
|
guard let targetFrame else {
|
||||||
if attempt == 0 || attempt == 5 || attempt == 10 {
|
|
||||||
print("[VOICE_SEND] resolveVoiceTargetFrame — attempt \(attempt), cell not found, retrying")
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in
|
||||||
self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot)
|
self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("[VOICE_SEND] resolveVoiceTargetFrame — FOUND target at attempt \(attempt), frame=\(targetFrame)")
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) {
|
UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) {
|
||||||
snapshot.frame = targetFrame
|
snapshot.frame = targetFrame
|
||||||
snapshot.layer.cornerRadius = 12
|
snapshot.layer.cornerRadius = 12
|
||||||
|
|||||||
@@ -505,5 +505,10 @@ private struct IOS18ScrollTracker<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onScrollPhaseChange { _, p in scrollPhase = p }
|
.onScrollPhaseChange { _, p in scrollPhase = p }
|
||||||
|
.onChange(of: isLargeHeader) { wasLarge, isLarge in
|
||||||
|
if isLarge && !wasLarge {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,121 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Thin UIKit wrapper around SwiftUI OpponentProfileView.
|
/// Pure UIKit peer profile screen. Phase 2: data + interactivity.
|
||||||
/// Phase 1 of incremental migration: handles nav bar + swipe-back natively.
|
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
|
||||||
/// The SwiftUI content is embedded as a child UIHostingController.
|
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
|
||||||
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate {
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
private let route: ChatRoute
|
private let route: ChatRoute
|
||||||
private let showMessageButton: Bool
|
var showMessageButton = false
|
||||||
|
private let viewModel: PeerProfileViewModel
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var addedSwipeBackGesture = false
|
private var addedSwipeBackGesture = false
|
||||||
|
private var selectedTab = 0
|
||||||
|
private var copiedField: String?
|
||||||
|
private var isMuted = false
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentView = UIView()
|
||||||
|
private let backButton = ChatDetailBackButton()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
private let avatarContainer = UIView()
|
||||||
|
private var avatarHosting: UIHostingController<AvatarView>?
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
private var actionButtonViews: [(container: UIControl, icon: UIImageView, label: UILabel)] = []
|
||||||
|
|
||||||
|
// Info card
|
||||||
|
private let infoCard = UIView()
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
private let tabBar = ProfileTabBarView(titles: ["Media", "Files", "Links", "Groups"])
|
||||||
|
|
||||||
|
// Media grid
|
||||||
|
private var mediaCollectionView: UICollectionView!
|
||||||
|
|
||||||
|
// List containers
|
||||||
|
private let filesContainer = UIView()
|
||||||
|
private let linksContainer = UIView()
|
||||||
|
private let groupsContainer = UIView()
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
private let emptyIcon = UIImageView()
|
||||||
|
private let emptyLabel = UILabel()
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private let hPad: CGFloat = 16
|
||||||
|
private let avatarSize: CGFloat = 100
|
||||||
|
private let buttonSpacing: CGFloat = 6
|
||||||
|
private let buttonCornerRadius: CGFloat = 15
|
||||||
|
private let cardCornerRadius: CGFloat = 11
|
||||||
|
|
||||||
|
private let sectionFill = UIColor { $0.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 28/255, green: 28/255, blue: 29/255, alpha: 1)
|
||||||
|
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
|
||||||
|
}
|
||||||
|
private let separatorColor = UIColor { $0.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
|
||||||
|
: UIColor(red: 0x3C/255, green: 0x3C/255, blue: 0x43/255, alpha: 0.36)
|
||||||
|
}
|
||||||
|
private let primaryBlue = UIColor(red: 0x24/255, green: 0x8A/255, blue: 0xE6/255, alpha: 1)
|
||||||
|
private let textPrimary = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
|
||||||
|
private let textSecondary = UIColor { $0.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0x8D/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
|
||||||
|
: UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
|
||||||
|
}
|
||||||
|
private let onlineColor = UIColor(red: 0x34/255, green: 0xC7/255, blue: 0x59/255, alpha: 1)
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
init(route: ChatRoute, showMessageButton: Bool = false) {
|
init(route: ChatRoute, showMessageButton: Bool = false) {
|
||||||
self.route = route
|
self.route = route
|
||||||
self.showMessageButton = showMessageButton
|
self.showMessageButton = showMessageButton
|
||||||
|
self.viewModel = PeerProfileViewModel(dialogKey: route.publicKey)
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
|
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
// Embed existing SwiftUI view as child
|
|
||||||
var profileView = OpponentProfileView(route: route)
|
|
||||||
profileView.showMessageButton = showMessageButton
|
|
||||||
let hosting = UIHostingController(rootView: profileView)
|
|
||||||
hosting.view.backgroundColor = .clear
|
|
||||||
|
|
||||||
addChild(hosting)
|
|
||||||
view.addSubview(hosting.view)
|
|
||||||
hosting.view.frame = view.bounds
|
|
||||||
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
hosting.didMove(toParent: self)
|
|
||||||
|
|
||||||
// Hide system back button — SwiftUI .toolbar handles its own
|
|
||||||
navigationItem.hidesBackButton = true
|
navigationItem.hidesBackButton = true
|
||||||
|
|
||||||
|
setupScrollView()
|
||||||
|
setupAvatar()
|
||||||
|
setupLabels()
|
||||||
|
setupActionButtons()
|
||||||
|
setupInfoCard()
|
||||||
|
setupTabBar()
|
||||||
|
setupMediaGrid()
|
||||||
|
setupListContainers()
|
||||||
|
setupEmptyState()
|
||||||
|
|
||||||
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
|
view.addSubview(backButton)
|
||||||
|
|
||||||
|
isMuted = DialogRepository.shared.dialogs[route.publicKey]?.isMuted ?? false
|
||||||
|
bindViewModel()
|
||||||
|
viewModel.startObservingOnline()
|
||||||
|
viewModel.loadSharedContent()
|
||||||
|
viewModel.loadCommonGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
// Show nav bar (SwiftUI .toolbarBackground(.hidden) makes it invisible)
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
|
||||||
let clear = UINavigationBarAppearance()
|
let clear = UINavigationBarAppearance()
|
||||||
clear.configureWithTransparentBackground()
|
clear.configureWithTransparentBackground()
|
||||||
clear.shadowColor = .clear
|
clear.shadowColor = .clear
|
||||||
@@ -54,27 +128,590 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
|
|||||||
setupFullWidthSwipeBack()
|
setupFullWidthSwipeBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Full-Width Swipe Back
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
layoutAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel Binding
|
||||||
|
|
||||||
|
private func bindViewModel() {
|
||||||
|
viewModel.$isOnline
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] online in
|
||||||
|
self?.subtitleLabel.text = online ? "online" : "offline"
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
let dataChanged = viewModel.$mediaItems
|
||||||
|
.combineLatest(viewModel.$fileItems, viewModel.$linkItems, viewModel.$commonGroups)
|
||||||
|
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||||
|
|
||||||
|
dataChanged
|
||||||
|
.sink { [weak self] _ in self?.rebuildTabContent() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildTabContent() {
|
||||||
|
mediaCollectionView.reloadData()
|
||||||
|
view.setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupScrollView() {
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupAvatar() {
|
||||||
|
contentView.addSubview(avatarContainer)
|
||||||
|
|
||||||
|
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||||
|
let dn = resolveDisplayName(dialog: dialog)
|
||||||
|
let image = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||||
|
|
||||||
|
let av = AvatarView(
|
||||||
|
initials: RosettaColors.initials(name: dn, publicKey: route.publicKey),
|
||||||
|
colorIndex: RosettaColors.avatarColorIndex(for: dn, publicKey: route.publicKey),
|
||||||
|
size: avatarSize, isOnline: false, isSavedMessages: route.isSavedMessages, image: image
|
||||||
|
)
|
||||||
|
let hosting = UIHostingController(rootView: av)
|
||||||
|
hosting.view.backgroundColor = .clear
|
||||||
|
addChild(hosting)
|
||||||
|
avatarContainer.addSubview(hosting.view)
|
||||||
|
hosting.didMove(toParent: self)
|
||||||
|
avatarHosting = hosting
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupLabels() {
|
||||||
|
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||||
|
nameLabel.text = resolveDisplayName(dialog: dialog)
|
||||||
|
nameLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
nameLabel.textColor = textPrimary
|
||||||
|
nameLabel.textAlignment = .center
|
||||||
|
contentView.addSubview(nameLabel)
|
||||||
|
|
||||||
|
subtitleLabel.text = (dialog?.isOnline ?? false) ? "online" : "offline"
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 16)
|
||||||
|
subtitleLabel.textColor = textSecondary
|
||||||
|
subtitleLabel.textAlignment = .center
|
||||||
|
contentView.addSubview(subtitleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupActionButtons() {
|
||||||
|
var defs: [(icon: String, title: String, action: Selector)] = []
|
||||||
|
if showMessageButton || route.isSavedMessages {
|
||||||
|
defs.append(("bubble.left.fill", "Message", #selector(messageTapped)))
|
||||||
|
}
|
||||||
|
if !route.isSavedMessages {
|
||||||
|
defs.append(("phone.fill", "Call", #selector(callTapped)))
|
||||||
|
defs.append((isMuted ? "bell.slash.fill" : "bell.fill", isMuted ? "Unmute" : "Mute", #selector(muteTapped)))
|
||||||
|
}
|
||||||
|
defs.append(("magnifyingglass", "Search", #selector(searchTapped)))
|
||||||
|
defs.append(("ellipsis", "More", #selector(moreTapped)))
|
||||||
|
|
||||||
|
for def in defs {
|
||||||
|
let control = UIControl()
|
||||||
|
control.backgroundColor = sectionFill
|
||||||
|
control.layer.cornerRadius = buttonCornerRadius
|
||||||
|
control.layer.cornerCurve = .continuous
|
||||||
|
control.addTarget(self, action: def.action, for: .touchUpInside)
|
||||||
|
|
||||||
|
let iv = UIImageView(image: UIImage(systemName: def.icon))
|
||||||
|
iv.contentMode = .scaleAspectFit
|
||||||
|
iv.tintColor = primaryBlue
|
||||||
|
iv.isUserInteractionEnabled = false
|
||||||
|
control.addSubview(iv)
|
||||||
|
|
||||||
|
let lbl = UILabel()
|
||||||
|
lbl.text = def.title
|
||||||
|
lbl.font = .systemFont(ofSize: 11)
|
||||||
|
lbl.textAlignment = .center
|
||||||
|
lbl.textColor = primaryBlue
|
||||||
|
lbl.isUserInteractionEnabled = false
|
||||||
|
control.addSubview(lbl)
|
||||||
|
|
||||||
|
contentView.addSubview(control)
|
||||||
|
actionButtonViews.append((control, iv, lbl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupInfoCard() {
|
||||||
|
infoCard.backgroundColor = sectionFill
|
||||||
|
infoCard.layer.cornerRadius = cardCornerRadius
|
||||||
|
infoCard.layer.cornerCurve = .continuous
|
||||||
|
contentView.addSubview(infoCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupTabBar() {
|
||||||
|
tabBar.delegate = self
|
||||||
|
contentView.addSubview(tabBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMediaGrid() {
|
||||||
|
let layout = UICollectionViewCompositionalLayout { _, _ in
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: .init(
|
||||||
|
widthDimension: .fractionalWidth(1.0 / 3.0), heightDimension: .fractionalWidth(1.0 / 3.0)))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(
|
||||||
|
layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0 / 3.0)),
|
||||||
|
repeatingSubitem: item, count: 3)
|
||||||
|
group.interItemSpacing = .fixed(1)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = 1
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
mediaCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
mediaCollectionView.register(ProfileMediaCell.self, forCellWithReuseIdentifier: "m")
|
||||||
|
mediaCollectionView.dataSource = self
|
||||||
|
mediaCollectionView.delegate = self
|
||||||
|
mediaCollectionView.backgroundColor = .clear
|
||||||
|
mediaCollectionView.isScrollEnabled = false
|
||||||
|
mediaCollectionView.isHidden = true
|
||||||
|
contentView.addSubview(mediaCollectionView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupListContainers() {
|
||||||
|
for c in [filesContainer, linksContainer, groupsContainer] {
|
||||||
|
c.backgroundColor = sectionFill
|
||||||
|
c.layer.cornerRadius = cardCornerRadius
|
||||||
|
c.layer.cornerCurve = .continuous
|
||||||
|
c.isHidden = true
|
||||||
|
contentView.addSubview(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupEmptyState() {
|
||||||
|
emptyIcon.tintColor = textSecondary.withAlphaComponent(0.5)
|
||||||
|
emptyIcon.contentMode = .scaleAspectFit
|
||||||
|
contentView.addSubview(emptyIcon)
|
||||||
|
emptyLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
emptyLabel.textColor = textSecondary
|
||||||
|
emptyLabel.textAlignment = .center
|
||||||
|
contentView.addSubview(emptyLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
private func layoutAll() {
|
||||||
|
let w = view.bounds.width
|
||||||
|
let safeTop = view.safeAreaInsets.top
|
||||||
|
scrollView.frame = view.bounds
|
||||||
|
contentView.frame.size.width = w
|
||||||
|
backButton.frame = CGRect(x: 7, y: safeTop - 10, width: 44, height: 44)
|
||||||
|
|
||||||
|
var y: CGFloat = safeTop - 8
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
|
||||||
|
avatarHosting?.view.frame = avatarContainer.bounds
|
||||||
|
y += avatarSize + 12
|
||||||
|
|
||||||
|
// Name
|
||||||
|
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
|
||||||
|
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
|
||||||
|
y += nameH + 1
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
|
||||||
|
y += 20 + 20
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
let btnCount = CGFloat(actionButtonViews.count)
|
||||||
|
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
|
||||||
|
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
|
||||||
|
c.frame = CGRect(x: hPad + CGFloat(i) * (btnW + buttonSpacing), y: y, width: btnW, height: 58)
|
||||||
|
iv.frame = CGRect(x: (btnW - 24) / 2, y: 7, width: 24, height: 26)
|
||||||
|
lbl.frame = CGRect(x: 0, y: 37, width: btnW, height: 16)
|
||||||
|
}
|
||||||
|
y += 58 + 16
|
||||||
|
|
||||||
|
// Info card
|
||||||
|
let infoH = layoutInfoCard(width: w - hPad * 2)
|
||||||
|
infoCard.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: infoH)
|
||||||
|
y += infoH + 20
|
||||||
|
|
||||||
|
// Tab bar
|
||||||
|
tabBar.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: 38)
|
||||||
|
y += 38 + 12
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
y = layoutTabContent(at: y, width: w)
|
||||||
|
|
||||||
|
contentView.frame.size.height = y
|
||||||
|
scrollView.contentSize = CGSize(width: w, height: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutInfoCard(width: CGFloat) -> CGFloat {
|
||||||
|
infoCard.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||||
|
let username = dialog?.opponentUsername ?? route.username
|
||||||
|
var cy: CGFloat = 0
|
||||||
|
|
||||||
|
if !username.isEmpty {
|
||||||
|
cy += makeInfoRow(in: infoCard, at: cy, width: width,
|
||||||
|
label: "username", value: "@\(username)", rawValue: username, fieldId: "username")
|
||||||
|
let div = UIView()
|
||||||
|
div.backgroundColor = separatorColor
|
||||||
|
div.frame = CGRect(x: 16, y: cy, width: width - 16, height: 1 / UIScreen.main.scale)
|
||||||
|
infoCard.addSubview(div)
|
||||||
|
}
|
||||||
|
cy += makeInfoRow(in: infoCard, at: cy, width: width,
|
||||||
|
label: "public key", value: route.publicKey, rawValue: route.publicKey, fieldId: "publicKey")
|
||||||
|
return cy
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeInfoRow(in card: UIView, at y: CGFloat, width: CGFloat,
|
||||||
|
label: String, value: String, rawValue: String, fieldId: String) -> CGFloat {
|
||||||
|
let row = UIControl()
|
||||||
|
row.frame = CGRect(x: 0, y: y, width: width, height: 66)
|
||||||
|
row.addAction(UIAction { [weak self] _ in
|
||||||
|
UIPasteboard.general.string = rawValue
|
||||||
|
self?.showCopied(fieldId: fieldId)
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
|
||||||
|
let titleLbl = UILabel()
|
||||||
|
titleLbl.text = label
|
||||||
|
titleLbl.font = .systemFont(ofSize: 14)
|
||||||
|
titleLbl.textColor = textSecondary
|
||||||
|
titleLbl.frame = CGRect(x: 16, y: 12, width: width - 32, height: 18)
|
||||||
|
titleLbl.isUserInteractionEnabled = false
|
||||||
|
row.addSubview(titleLbl)
|
||||||
|
|
||||||
|
let isCopied = copiedField == fieldId
|
||||||
|
let valueLbl = UILabel()
|
||||||
|
valueLbl.text = isCopied ? "Copied" : value
|
||||||
|
valueLbl.font = .systemFont(ofSize: 17)
|
||||||
|
valueLbl.textColor = isCopied ? onlineColor : primaryBlue
|
||||||
|
valueLbl.lineBreakMode = .byTruncatingMiddle
|
||||||
|
valueLbl.frame = CGRect(x: 16, y: 32, width: width - 32, height: 22)
|
||||||
|
valueLbl.isUserInteractionEnabled = false
|
||||||
|
valueLbl.tag = fieldId.hashValue
|
||||||
|
row.addSubview(valueLbl)
|
||||||
|
|
||||||
|
card.addSubview(row)
|
||||||
|
return 66
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showCopied(fieldId: String) {
|
||||||
|
copiedField = fieldId
|
||||||
|
let infoH = layoutInfoCard(width: infoCard.bounds.width)
|
||||||
|
infoCard.frame.size.height = infoH
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
guard copiedField == fieldId else { return }
|
||||||
|
copiedField = nil
|
||||||
|
let h = layoutInfoCard(width: infoCard.bounds.width)
|
||||||
|
infoCard.frame.size.height = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab Content Layout
|
||||||
|
|
||||||
|
private func layoutTabContent(at startY: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
|
mediaCollectionView.isHidden = true
|
||||||
|
filesContainer.isHidden = true
|
||||||
|
linksContainer.isHidden = true
|
||||||
|
groupsContainer.isHidden = true
|
||||||
|
emptyIcon.isHidden = true
|
||||||
|
emptyLabel.isHidden = true
|
||||||
|
|
||||||
|
var y = startY
|
||||||
|
|
||||||
|
switch selectedTab {
|
||||||
|
case 0: // Media
|
||||||
|
if viewModel.mediaItems.isEmpty {
|
||||||
|
y = layoutEmpty(at: y, width: width, icon: "photo.on.rectangle", title: "No Media Yet")
|
||||||
|
} else {
|
||||||
|
mediaCollectionView.isHidden = false
|
||||||
|
let rows = ceil(Double(viewModel.mediaItems.count) / 3.0)
|
||||||
|
let itemH = (width - 2) / 3
|
||||||
|
let gridH = CGFloat(rows) * itemH + max(0, CGFloat(rows - 1))
|
||||||
|
mediaCollectionView.frame = CGRect(x: 0, y: y, width: width, height: gridH)
|
||||||
|
y += gridH + 40
|
||||||
|
}
|
||||||
|
case 1: // Files
|
||||||
|
if viewModel.fileItems.isEmpty {
|
||||||
|
y = layoutEmpty(at: y, width: width, icon: "doc", title: "No Files Yet")
|
||||||
|
} else {
|
||||||
|
filesContainer.isHidden = false
|
||||||
|
let h = layoutFileRows(width: width - hPad * 2)
|
||||||
|
filesContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
|
||||||
|
y += h + 40
|
||||||
|
}
|
||||||
|
case 2: // Links
|
||||||
|
if viewModel.linkItems.isEmpty {
|
||||||
|
y = layoutEmpty(at: y, width: width, icon: "link", title: "No Links Yet")
|
||||||
|
} else {
|
||||||
|
linksContainer.isHidden = false
|
||||||
|
let h = layoutLinkRows(width: width - hPad * 2)
|
||||||
|
linksContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
|
||||||
|
y += h + 40
|
||||||
|
}
|
||||||
|
case 3: // Groups
|
||||||
|
if viewModel.commonGroups.isEmpty {
|
||||||
|
y = layoutEmpty(at: y, width: width, icon: "person.2", title: "No Groups in Common")
|
||||||
|
} else {
|
||||||
|
groupsContainer.isHidden = false
|
||||||
|
let h = layoutGroupRows(width: width - hPad * 2)
|
||||||
|
groupsContainer.frame = CGRect(x: hPad, y: y, width: width - hPad * 2, height: h)
|
||||||
|
y += h + 40
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutEmpty(at y: CGFloat, width: CGFloat, icon: String, title: String) -> CGFloat {
|
||||||
|
emptyIcon.isHidden = false
|
||||||
|
emptyLabel.isHidden = false
|
||||||
|
emptyIcon.image = UIImage(systemName: icon)
|
||||||
|
emptyLabel.text = title
|
||||||
|
emptyIcon.frame = CGRect(x: (width - 50) / 2, y: y + 20, width: 50, height: 50)
|
||||||
|
emptyLabel.frame = CGRect(x: 30, y: y + 82, width: width - 60, height: 20)
|
||||||
|
return y + 142
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutFileRows(width: CGFloat) -> CGFloat {
|
||||||
|
filesContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
var cy: CGFloat = 0
|
||||||
|
for (i, file) in viewModel.fileItems.enumerated() {
|
||||||
|
let rowH: CGFloat = 60
|
||||||
|
let row = UIView(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
|
||||||
|
|
||||||
|
let iconBg = UIView(frame: CGRect(x: 16, y: 10, width: 40, height: 40))
|
||||||
|
iconBg.backgroundColor = primaryBlue.withAlphaComponent(0.12)
|
||||||
|
iconBg.layer.cornerRadius = 10
|
||||||
|
row.addSubview(iconBg)
|
||||||
|
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: "doc.fill"))
|
||||||
|
icon.tintColor = primaryBlue
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
icon.frame = CGRect(x: 9, y: 9, width: 22, height: 22)
|
||||||
|
iconBg.addSubview(icon)
|
||||||
|
|
||||||
|
let name = UILabel(frame: CGRect(x: 68, y: 12, width: width - 84, height: 20))
|
||||||
|
name.text = file.fileName
|
||||||
|
name.font = .systemFont(ofSize: 16)
|
||||||
|
name.textColor = textPrimary
|
||||||
|
row.addSubview(name)
|
||||||
|
|
||||||
|
let sub = UILabel(frame: CGRect(x: 68, y: 34, width: width - 84, height: 18))
|
||||||
|
sub.text = file.subtitle
|
||||||
|
sub.font = .systemFont(ofSize: 13)
|
||||||
|
sub.textColor = textSecondary
|
||||||
|
row.addSubview(sub)
|
||||||
|
|
||||||
|
filesContainer.addSubview(row)
|
||||||
|
cy += rowH
|
||||||
|
if i < viewModel.fileItems.count - 1 {
|
||||||
|
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
|
||||||
|
sep.backgroundColor = separatorColor
|
||||||
|
filesContainer.addSubview(sep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cy
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutLinkRows(width: CGFloat) -> CGFloat {
|
||||||
|
linksContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
var cy: CGFloat = 0
|
||||||
|
for (i, link) in viewModel.linkItems.enumerated() {
|
||||||
|
let rowH: CGFloat = 60
|
||||||
|
let row = UIControl(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
|
||||||
|
row.addAction(UIAction { _ in
|
||||||
|
if let url = URL(string: link.url) { UIApplication.shared.open(url) }
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
|
||||||
|
let badge = UILabel(frame: CGRect(x: 16, y: 10, width: 40, height: 40))
|
||||||
|
badge.text = String(link.displayHost.prefix(1)).uppercased()
|
||||||
|
badge.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
badge.textColor = .white
|
||||||
|
badge.textAlignment = .center
|
||||||
|
badge.backgroundColor = primaryBlue
|
||||||
|
badge.layer.cornerRadius = 10
|
||||||
|
badge.clipsToBounds = true
|
||||||
|
row.addSubview(badge)
|
||||||
|
|
||||||
|
let host = UILabel(frame: CGRect(x: 68, y: 12, width: width - 84, height: 20))
|
||||||
|
host.text = link.displayHost
|
||||||
|
host.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
host.textColor = textPrimary
|
||||||
|
row.addSubview(host)
|
||||||
|
|
||||||
|
let ctx = UILabel(frame: CGRect(x: 68, y: 34, width: width - 84, height: 18))
|
||||||
|
ctx.text = link.context
|
||||||
|
ctx.font = .systemFont(ofSize: 13)
|
||||||
|
ctx.textColor = textSecondary
|
||||||
|
ctx.numberOfLines = 2
|
||||||
|
row.addSubview(ctx)
|
||||||
|
|
||||||
|
linksContainer.addSubview(row)
|
||||||
|
cy += rowH
|
||||||
|
if i < viewModel.linkItems.count - 1 {
|
||||||
|
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
|
||||||
|
sep.backgroundColor = separatorColor
|
||||||
|
linksContainer.addSubview(sep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cy
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutGroupRows(width: CGFloat) -> CGFloat {
|
||||||
|
groupsContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
var cy: CGFloat = 0
|
||||||
|
for (i, group) in viewModel.commonGroups.enumerated() {
|
||||||
|
let rowH: CGFloat = 56
|
||||||
|
let row = UIView(frame: CGRect(x: 0, y: cy, width: width, height: rowH))
|
||||||
|
|
||||||
|
let av = AvatarView(
|
||||||
|
initials: RosettaColors.initials(name: group.title, publicKey: group.dialogKey),
|
||||||
|
colorIndex: RosettaColors.avatarColorIndex(for: group.title, publicKey: group.dialogKey),
|
||||||
|
size: 40, isOnline: false, image: group.avatar)
|
||||||
|
let h = UIHostingController(rootView: av)
|
||||||
|
h.view.backgroundColor = .clear
|
||||||
|
h.view.frame = CGRect(x: 16, y: 8, width: 40, height: 40)
|
||||||
|
row.addSubview(h.view)
|
||||||
|
|
||||||
|
let title = UILabel(frame: CGRect(x: 68, y: (rowH - 22) / 2, width: width - 84, height: 22))
|
||||||
|
title.text = group.title
|
||||||
|
title.font = .systemFont(ofSize: 17)
|
||||||
|
title.textColor = textPrimary
|
||||||
|
row.addSubview(title)
|
||||||
|
|
||||||
|
groupsContainer.addSubview(row)
|
||||||
|
cy += rowH
|
||||||
|
if i < viewModel.commonGroups.count - 1 {
|
||||||
|
let sep = UIView(frame: CGRect(x: 68, y: cy, width: width - 68, height: 1 / UIScreen.main.scale))
|
||||||
|
sep.backgroundColor = separatorColor
|
||||||
|
groupsContainer.addSubview(sep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cy
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UICollectionView (Media Grid)
|
||||||
|
|
||||||
|
func collectionView(_ cv: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
viewModel.mediaItems.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ cv: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = cv.dequeueReusableCell(withReuseIdentifier: "m", for: indexPath) as! ProfileMediaCell
|
||||||
|
cell.configure(with: viewModel.mediaItems[indexPath.item])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let viewable = viewModel.mediaItems.map {
|
||||||
|
ViewableImageInfo(attachmentId: $0.attachmentId, messageId: $0.messageId, senderName: $0.senderName,
|
||||||
|
timestamp: Date(timeIntervalSince1970: Double($0.timestamp) / 1000.0), caption: $0.caption)
|
||||||
|
}
|
||||||
|
ImageViewerPresenter.shared.present(state: ImageViewerState(images: viewable, initialIndex: indexPath.item, sourceFrame: .zero))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProfileTabBarDelegate
|
||||||
|
|
||||||
|
func tabBar(_ tabBar: ProfileTabBarView, didSelectTabAt index: Int) {
|
||||||
|
selectedTab = index
|
||||||
|
view.setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func backTapped() { navigationController?.popViewController(animated: true) }
|
||||||
|
|
||||||
|
@objc private func callTapped() {
|
||||||
|
let dialog = DialogRepository.shared.dialogs[route.publicKey]
|
||||||
|
let name = dialog?.opponentTitle ?? route.title
|
||||||
|
let user = dialog?.opponentUsername ?? route.username
|
||||||
|
Task { @MainActor in
|
||||||
|
_ = CallManager.shared.startOutgoingCall(toPublicKey: route.publicKey, title: name, username: user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func muteTapped() {
|
||||||
|
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
|
||||||
|
isMuted.toggle()
|
||||||
|
// Find mute button and update
|
||||||
|
for (_, iv, lbl) in actionButtonViews where lbl.text == "Mute" || lbl.text == "Unmute" {
|
||||||
|
iv.image = UIImage(systemName: isMuted ? "bell.slash.fill" : "bell.fill")
|
||||||
|
lbl.text = isMuted ? "Unmute" : "Mute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func searchTapped() { navigationController?.popViewController(animated: true) }
|
||||||
|
|
||||||
|
@objc private func moreTapped() {
|
||||||
|
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||||
|
if !route.isSavedMessages {
|
||||||
|
sheet.addAction(UIAlertAction(title: "Block User", style: .destructive))
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "Clear Chat History", style: .destructive))
|
||||||
|
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func messageTapped() {
|
||||||
|
if route.isSavedMessages {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
} else {
|
||||||
|
let chatVC = ChatDetailViewController(route: route)
|
||||||
|
navigationController?.pushViewController(chatVC, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func resolveDisplayName(dialog: Dialog?) -> String {
|
||||||
|
if let d = dialog, !d.opponentTitle.isEmpty { return d.opponentTitle }
|
||||||
|
if !route.title.isEmpty { return route.title }
|
||||||
|
if let d = dialog, !d.opponentUsername.isEmpty { return "@\(d.opponentUsername)" }
|
||||||
|
if !route.username.isEmpty { return "@\(route.username)" }
|
||||||
|
return String(route.publicKey.prefix(12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swipe Back
|
||||||
|
|
||||||
private func setupFullWidthSwipeBack() {
|
private func setupFullWidthSwipeBack() {
|
||||||
guard !addedSwipeBackGesture else { return }
|
guard !addedSwipeBackGesture else { return }
|
||||||
addedSwipeBackGesture = true
|
addedSwipeBackGesture = true
|
||||||
|
|
||||||
guard let nav = navigationController,
|
guard let nav = navigationController,
|
||||||
let edgeGesture = nav.interactivePopGestureRecognizer,
|
let edge = nav.interactivePopGestureRecognizer,
|
||||||
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
|
let targets = edge.value(forKey: "targets") as? NSArray, targets.count > 0 else { return }
|
||||||
targets.count > 0 else { return }
|
edge.isEnabled = true
|
||||||
|
let pan = UIPanGestureRecognizer()
|
||||||
edgeGesture.isEnabled = true
|
pan.setValue(targets, forKey: "targets")
|
||||||
let fullWidthGesture = UIPanGestureRecognizer()
|
pan.delegate = self
|
||||||
fullWidthGesture.setValue(targets, forKey: "targets")
|
nav.view.addGestureRecognizer(pan)
|
||||||
fullWidthGesture.delegate = self
|
|
||||||
nav.view.addGestureRecognizer(fullWidthGesture)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
func gestureRecognizerShouldBegin(_ gr: UIGestureRecognizer) -> Bool {
|
||||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
guard let p = gr as? UIPanGestureRecognizer else { return true }
|
||||||
let velocity = pan.velocity(in: pan.view)
|
let v = p.velocity(in: p.view)
|
||||||
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
|
return v.x > 0 && abs(v.x) > abs(v.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Media Cell
|
||||||
|
|
||||||
|
private final class ProfileMediaCell: UICollectionViewCell {
|
||||||
|
private let iv = UIImageView()
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
iv.contentMode = .scaleAspectFill
|
||||||
|
iv.clipsToBounds = true
|
||||||
|
contentView.addSubview(iv)
|
||||||
|
contentView.backgroundColor = UIColor(white: 0.15, alpha: 1)
|
||||||
|
}
|
||||||
|
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||||
|
override func layoutSubviews() { super.layoutSubviews(); iv.frame = contentView.bounds }
|
||||||
|
override func prepareForReuse() { super.prepareForReuse(); iv.image = nil }
|
||||||
|
func configure(with item: SharedMediaItem) {
|
||||||
|
if let img = AttachmentCache.shared.loadImage(forAttachmentId: item.attachmentId) { iv.image = img }
|
||||||
|
else if !item.blurhash.isEmpty, let blur = BlurHashDecoder.decode(blurHash: item.blurhash, width: 32, height: 32) { iv.image = blur }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user