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