diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 49e6151..59b4ba8 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -339,16 +339,14 @@ final class MessageRepository: ObservableObject { MessageRecord.Columns.account == myPublicKey && MessageRecord.Columns.messageId == messageId ).fetchOne(db) { - // Protect decrypted .messages blobs from being overwritten by encrypted sync copies. - // If existing attachments have a valid JSON blob but new ones have encrypted ciphertext, - // keep the existing (correctly decrypted) version. + // Protect existing attachments from being wiped during sync. + // Android parity: never replace non-empty attachments with empty. let shouldPreserveAttachments: Bool = { - guard existing.attachments != "[]" else { return false } - // If existing has a .messages blob that looks like valid JSON (starts with "["), - // and new blob looks like ciphertext (contains ":" from ivBase64:ctBase64), - // preserve the existing data. + guard existing.attachments != "[]", !existing.attachments.isEmpty else { return false } + // Never wipe existing attachments with empty data + if attachmentsJSON == "[]" { return true } + // Protect decrypted .messages blobs from encrypted sync copies if existing.attachments.contains("\"type\":1") || existing.attachments.contains("\"type\": 1") { - // Existing has .messages attachment — check if new data would corrupt it let existingHasDecrypted = existing.attachments.contains("\"message_id\"") let newHasDecrypted = attachmentsJSON.contains("\"message_id\"") if existingHasDecrypted && !newHasDecrypted { return true } diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 9aa5607..4e70867 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -66,12 +66,19 @@ enum PacketRegistry { // MARK: - Attachment Types -enum AttachmentType: Int, Codable { +enum AttachmentType: Int, Codable, Sendable { case image = 0 case messages = 1 case file = 2 case avatar = 3 case call = 4 + + /// Android parity: `fromInt() ?: UNKNOWN`. Fallback to `.image` for unknown values + /// so a single unknown type doesn't crash the entire [MessageAttachment] array decode. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(Int.self) + self = AttachmentType(rawValue: raw) ?? .image + } } struct MessageAttachment: Codable, Equatable { diff --git a/Rosetta/Core/Services/CallActivityAttributes.swift b/Rosetta/Core/Services/CallActivityAttributes.swift index 33c331f..7f90781 100644 --- a/Rosetta/Core/Services/CallActivityAttributes.swift +++ b/Rosetta/Core/Services/CallActivityAttributes.swift @@ -5,6 +5,8 @@ struct CallActivityAttributes: ActivityAttributes { let peerName: String let peerPublicKey: String let colorIndex: Int + /// Tiny avatar thumbnail (32x32 JPEG, ~1-2KB). Nil = show initials. + let avatarThumb: Data? struct ContentState: Codable, Hashable { let durationSec: Int diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 2ef17fb..2616dd3 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -336,20 +336,26 @@ final class CallManager: NSObject, ObservableObject { print("[Call] LiveActivity DISABLED by user settings") return } - // Save peer avatar to App Group so widget extension can read it - if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.rosetta.dev") { - let avatarURL = containerURL.appendingPathComponent("call_avatar.jpg") - if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey), - let thumb = avatar.jpegData(compressionQuality: 0.4) { - try? thumb.write(to: avatarURL) - } else { - try? FileManager.default.removeItem(at: avatarURL) + // Create tiny avatar thumbnail (32x32) to embed directly in attributes (<4KB limit) + var avatarThumb: Data? + if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) { + let thumbSize = CGSize(width: 32, height: 32) + UIGraphicsBeginImageContextWithOptions(thumbSize, false, 1.0) + avatar.draw(in: CGRect(origin: .zero, size: thumbSize)) + let tiny = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + if let jpeg = tiny?.jpegData(compressionQuality: 0.5) { + avatarThumb = jpeg + print("[Call] Avatar thumb: \(jpeg.count) bytes (32x32)") } + } else { + print("[Call] No avatar for peer") } let attributes = CallActivityAttributes( peerName: uiState.displayName, peerPublicKey: uiState.peerPublicKey, - colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey) + colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey), + avatarThumb: avatarThumb ) let state = CallActivityAttributes.ContentState( durationSec: uiState.durationSec, @@ -371,6 +377,7 @@ final class CallManager: NSObject, ObservableObject { func updateLiveActivity() { guard let liveActivity, liveActivity.activityState == .active else { return } + // Avatar is embedded in attributes (set at startLiveActivity) let state = CallActivityAttributes.ContentState( durationSec: uiState.durationSec, isActive: uiState.phase == .active, @@ -381,6 +388,8 @@ final class CallManager: NSObject, ObservableObject { } } + // Avatar is embedded directly in CallActivityAttributes (32x32 thumb) + func endLiveActivity() { guard let liveActivity else { return } let finalState = CallActivityAttributes.ContentState( diff --git a/Rosetta/Features/Calls/ActiveCallOverlayView.swift b/Rosetta/Features/Calls/ActiveCallOverlayView.swift index e67f2f2..2c8695b 100644 --- a/Rosetta/Features/Calls/ActiveCallOverlayView.swift +++ b/Rosetta/Features/Calls/ActiveCallOverlayView.swift @@ -73,7 +73,8 @@ struct ActiveCallOverlayView: View { AvatarView( initials: peerInitials, colorIndex: peerColorIndex, - size: 90 + size: 90, + image: AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey) ) } .frame(width: 130, height: 130) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 39a2bbd..3750a04 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1181,6 +1181,8 @@ private extension ChatDetailView { } if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } + // Android/Desktop parity: show "Call" for call attachments + if message.attachments.contains(where: { $0.type == .call }) { return "Call" } let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return message.text } if !message.attachments.isEmpty { return "Attachment" } @@ -1206,6 +1208,8 @@ private extension ChatDetailView { } if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } + // Android/Desktop parity: show "Call" for call attachments + if message.attachments.contains(where: { $0.type == .call }) { return "Call" } // No known attachment type — fall back to text let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return message.text } @@ -1217,17 +1221,17 @@ private extension ChatDetailView { #endif HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 1.5) + RoundedRectangle(cornerRadius: 1.0) .fill(RosettaColors.figmaBlue) - .frame(width: 3, height: 36) + .frame(width: 2, height: 36) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 0) { Text("Reply to ") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .foregroundStyle(.white) Text(senderName) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 14, weight: .medium)) .foregroundStyle(RosettaColors.figmaBlue) } .lineLimit(1) @@ -1247,15 +1251,15 @@ private extension ChatDetailView { } } label: { Image(systemName: "xmark") - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 12, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .frame(width: 30, height: 30) + .frame(width: 44, height: 44) } } - .padding(.leading, 6) - .padding(.trailing, 4) - .padding(.top, 6) - .padding(.bottom, 4) + .padding(.leading, 8) // align blue line with text cursor (6pt textView padding + 2pt inset) + .padding(.trailing, 0) + .padding(.top, 8) + .padding(.bottom, 2) // tight gap to text input below .transition(.move(edge: .bottom).combined(with: .opacity)) } diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index ef7f93b..4175967 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -125,15 +125,15 @@ final class ComposerView: UIView, UITextViewDelegate { replyBar.isHidden = true replyBlueBar.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) - replyBlueBar.layer.cornerRadius = 1.5 + replyBlueBar.layer.cornerRadius = 1.0 replyBar.addSubview(replyBlueBar) replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium) - replyTitleLabel.textColor = UIColor(white: 1, alpha: 0.6) + replyTitleLabel.textColor = .white replyTitleLabel.text = "Reply to " replyBar.addSubview(replyTitleLabel) - replySenderLabel.font = .systemFont(ofSize: 14, weight: .semibold) + replySenderLabel.font = .systemFont(ofSize: 14, weight: .medium) replySenderLabel.textColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) replyBar.addSubview(replySenderLabel) @@ -142,7 +142,7 @@ final class ComposerView: UIView, UITextViewDelegate { replyPreviewLabel.lineBreakMode = .byTruncatingTail replyBar.addSubview(replyPreviewLabel) - let xImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)) + let xImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)) replyCancelButton.setImage(xImage, for: .normal) replyCancelButton.tintColor = UIColor(white: 1, alpha: 0.6) replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside) @@ -281,8 +281,8 @@ final class ComposerView: UIView, UITextViewDelegate { let w = bounds.width guard w > 0 else { return } - // Reply bar height - let replyH: CGFloat = isReplyVisible ? 46 : 0 // 6 top + 36 bar + 4 bottom + // Reply bar height (8 top + 36 bar + 2 bottom = tight gap to text) + let replyH: CGFloat = isReplyVisible ? 46 : 0 // Text row height = textViewHeight (clamped) let textRowH = textViewHeight @@ -366,21 +366,22 @@ final class ComposerView: UIView, UITextViewDelegate { private func layoutReplyBar(width: CGFloat, height: CGFloat) { guard height > 0 else { return } - let barX: CGFloat = 0 - let barY: CGFloat = 6 - replyBlueBar.frame = CGRect(x: barX, y: barY, width: 3, height: 36) + // barX=5 aligns blue line with text cursor: replyX(6)+5 = 11 = textX(9)+inset(2) + let barX: CGFloat = 5 + let barY: CGFloat = 8 + replyBlueBar.frame = CGRect(x: barX, y: barY, width: 2, height: 36) - let labelX: CGFloat = 3 + 8 + let labelX: CGFloat = barX + 2 + 8 // blue line + spacing let titleSize = replyTitleLabel.sizeThatFits(CGSize(width: 200, height: 20)) replyTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: titleSize.width, height: 17) let senderX = labelX + titleSize.width - let senderW = width - senderX - 34 + let senderW = width - senderX - 44 replySenderLabel.frame = CGRect(x: senderX, y: barY + 2, width: max(0, senderW), height: 17) - replyPreviewLabel.frame = CGRect(x: labelX, y: barY + 19, width: width - labelX - 34, height: 17) + replyPreviewLabel.frame = CGRect(x: labelX, y: barY + 19, width: width - labelX - 44, height: 17) - replyCancelButton.frame = CGRect(x: width - 30, y: barY, width: 30, height: 36) + replyCancelButton.frame = CGRect(x: width - 44, y: (height - 44) / 2, width: 44, height: 44) } // MARK: - Icon Helpers diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 8472f05..ae726fb 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -592,6 +592,8 @@ struct MessageCellView: View, Equatable { if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + // Android/Desktop parity: show "Call" for call attachments in reply quote + if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" }() let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue diff --git a/RosettaLiveActivityWidget/CallActivityAttributes.swift b/RosettaLiveActivityWidget/CallActivityAttributes.swift index 33c331f..7f90781 100644 --- a/RosettaLiveActivityWidget/CallActivityAttributes.swift +++ b/RosettaLiveActivityWidget/CallActivityAttributes.swift @@ -5,6 +5,8 @@ struct CallActivityAttributes: ActivityAttributes { let peerName: String let peerPublicKey: String let colorIndex: Int + /// Tiny avatar thumbnail (32x32 JPEG, ~1-2KB). Nil = show initials. + let avatarThumb: Data? struct ContentState: Codable, Hashable { let durationSec: Int diff --git a/RosettaLiveActivityWidget/CallLiveActivity.swift b/RosettaLiveActivityWidget/CallLiveActivity.swift index cef7c9f..192dd7f 100644 --- a/RosettaLiveActivityWidget/CallLiveActivity.swift +++ b/RosettaLiveActivityWidget/CallLiveActivity.swift @@ -2,21 +2,31 @@ import ActivityKit import SwiftUI import WidgetKit -// Mantine v8 avatar tint colors (shade-6) — exact hex values, desktop parity -private let mantineTints: [Color] = [ - Color(red: 34/255, green: 139/255, blue: 230/255), // #228be6 blue - Color(red: 21/255, green: 170/255, blue: 191/255), // #15aabf cyan - Color(red: 190/255, green: 75/255, blue: 219/255), // #be4bdb grape - Color(red: 64/255, green: 192/255, blue: 87/255), // #40c057 green - Color(red: 76/255, green: 110/255, blue: 245/255), // #4c6ef5 indigo - Color(red: 130/255, green: 201/255, blue: 30/255), // #82c91e lime - Color(red: 253/255, green: 126/255, blue: 20/255), // #fd7e14 orange - Color(red: 230/255, green: 73/255, blue: 128/255), // #e64980 pink - Color(red: 250/255, green: 82/255, blue: 82/255), // #fa5252 red - Color(red: 18/255, green: 184/255, blue: 134/255), // #12b886 teal - Color(red: 121/255, green: 80/255, blue: 242/255), // #7950f2 violet +// Mantine v8 avatar palette — exact hex parity with RosettaColors.avatarColors +// tint = shade-6, text = shade-3 + +private struct AvatarPalette { + let tint: Color + let text: Color +} + +private let mantinePalette: [AvatarPalette] = [ + AvatarPalette(tint: Color(red: 0x22/255, green: 0x8B/255, blue: 0xE6/255), text: Color(red: 0x74/255, green: 0xC0/255, blue: 0xFC/255)), // blue + AvatarPalette(tint: Color(red: 0x15/255, green: 0xAA/255, blue: 0xBF/255), text: Color(red: 0x66/255, green: 0xD9/255, blue: 0xE8/255)), // cyan + AvatarPalette(tint: Color(red: 0xBE/255, green: 0x4B/255, blue: 0xDB/255), text: Color(red: 0xE5/255, green: 0x99/255, blue: 0xF7/255)), // grape + AvatarPalette(tint: Color(red: 0x40/255, green: 0xC0/255, blue: 0x57/255), text: Color(red: 0x8C/255, green: 0xE9/255, blue: 0x9A/255)), // green + AvatarPalette(tint: Color(red: 0x4C/255, green: 0x6E/255, blue: 0xF5/255), text: Color(red: 0x91/255, green: 0xA7/255, blue: 0xFF/255)), // indigo + AvatarPalette(tint: Color(red: 0x82/255, green: 0xC9/255, blue: 0x1E/255), text: Color(red: 0xC0/255, green: 0xEB/255, blue: 0x75/255)), // lime + AvatarPalette(tint: Color(red: 0xFD/255, green: 0x7E/255, blue: 0x14/255), text: Color(red: 0xFF/255, green: 0xC0/255, blue: 0x78/255)), // orange + AvatarPalette(tint: Color(red: 0xE6/255, green: 0x49/255, blue: 0x80/255), text: Color(red: 0xFA/255, green: 0xA2/255, blue: 0xC1/255)), // pink + AvatarPalette(tint: Color(red: 0xFA/255, green: 0x52/255, blue: 0x52/255), text: Color(red: 0xFF/255, green: 0xA8/255, blue: 0xA8/255)), // red + AvatarPalette(tint: Color(red: 0x12/255, green: 0xB8/255, blue: 0x86/255), text: Color(red: 0x63/255, green: 0xE6/255, blue: 0xBE/255)), // teal + AvatarPalette(tint: Color(red: 0x79/255, green: 0x50/255, blue: 0xF2/255), text: Color(red: 0xB1/255, green: 0x97/255, blue: 0xFC/255)), // violet ] +// Mantine dark body background +private let mantineDarkBody = Color(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255) + private let appGroupID = "group.com.rosetta.dev" @main @@ -118,7 +128,7 @@ struct CallLiveActivity: Widget { .padding(.top, 4) } } compactLeading: { - avatarView(context: context, size: 26, fontSize: 10) + avatarView(context: context, size: 26, fontSize: 9) } compactTrailing: { if context.state.isActive { Text(fmt(context.state.durationSec)) @@ -137,36 +147,34 @@ struct CallLiveActivity: Widget { } } - // MARK: - Avatar + // MARK: - Avatar (Mantine "light" dark variant — matches AvatarView in main app) @ViewBuilder private func avatarView(context: ActivityViewContext, size: CGFloat, fontSize: CGFloat) -> some View { - if let img = loadSharedAvatar() { + if let data = context.attributes.avatarThumb, + let img = UIImage(data: data) { Image(uiImage: img) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(Circle()) } else { - let idx = min(context.attributes.colorIndex, mantineTints.count - 1) + let idx = min(max(context.attributes.colorIndex, 0), mantinePalette.count - 1) + let palette = mantinePalette[idx] ZStack { - Circle().fill(mantineTints[max(0, idx)]) + // Base: Mantine dark body + Circle().fill(mantineDarkBody) + // Overlay: tint at 15% opacity (dark mode) + Circle().fill(palette.tint.opacity(0.15)) + // Initials: shade-3 text color Text(makeInitials(context.attributes.peerName, context.attributes.peerPublicKey)) - .font(.system(size: fontSize, weight: .bold)) - .foregroundColor(.white) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundColor(palette.text) } .frame(width: size, height: size) } } - private func loadSharedAvatar() -> UIImage? { - guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)? - .appendingPathComponent("call_avatar.jpg"), - let data = try? Data(contentsOf: url), - let img = UIImage(data: data) else { return nil } - return img - } - // MARK: - Helpers private func fmt(_ sec: Int) -> String { diff --git a/RosettaTests/ReplyPreviewTextTests.swift b/RosettaTests/ReplyPreviewTextTests.swift new file mode 100644 index 0000000..af46ccf --- /dev/null +++ b/RosettaTests/ReplyPreviewTextTests.swift @@ -0,0 +1,437 @@ +import XCTest +@testable import Rosetta + +// MARK: - Reply Preview Text Tests (ChatDetailView.replyPreviewText parity) + +/// Tests for reply preview text logic — cross-platform parity with Android/Desktop. +/// Android: `ChatDetailInput.kt` lines 653-679 +/// Desktop: `constructLastMessageTextByAttachments.ts` +/// +/// Covers ALL 3 iOS display contexts: +/// 1. Reply bar input — `ChatDetailView.replyPreviewText(for:)` +/// 2. Reply bar render — `ChatDetailView.replyBar(for:)` inline closure +/// 3. Reply quote bubble — `MessageCellView.replyQuoteView(reply:outgoing:)` +/// Plus: chat list last message — `DialogRepository.updateDialogFromMessages()` +final class ReplyPreviewTextTests: XCTestCase { + + // MARK: - Helpers + + /// Standalone replica of `ChatDetailView.replyPreviewText(for:)`. + /// Must match the real implementation exactly. + private func replyPreviewText(for message: ChatMessage) -> String { + if message.attachments.contains(where: { $0.type == .image }) { + let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + return caption.isEmpty ? "Photo" : caption + } + if let file = message.attachments.first(where: { $0.type == .file }) { + let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !caption.isEmpty { return caption } + let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } + return file.id.isEmpty ? "File" : file.id + } + if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } + if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } + if message.attachments.contains(where: { $0.type == .call }) { return "Call" } + let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return message.text } + if !message.attachments.isEmpty { return "Attachment" } + return "" + } + + /// Standalone replica of `MessageCellView.replyQuoteView` preview logic. + /// Uses `ReplyMessageData` + `ReplyAttachmentData` (Int type), not `MessageAttachment`. + private func replyQuotePreviewText(for reply: ReplyMessageData) -> String { + let trimmed = reply.message.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { return reply.message } + if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } + if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } + if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } + return "Attachment" + } + + /// Standalone replica of `DialogRepository.updateDialogFromMessages` preview logic. + private func chatListPreviewText(text: String, attachments: [MessageAttachment]) -> String { + let textIsEmpty = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || Self.isGarbageText(text) + if textIsEmpty, let firstAtt = attachments.first { + switch firstAtt.type { + case .image: return "Photo" + case .file: return "File" + case .avatar: return "Avatar" + case .messages: return "Forwarded message" + case .call: return "Call" + } + } else if textIsEmpty { + return "" + } + return text + } + + /// Mirror of `DialogRepository.isGarbageText`. + private static func isGarbageText(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + let validCharacters = trimmed.unicodeScalars.filter { scalar in + scalar.value != 0xFFFD && + scalar.value > 0x1F && + scalar.value != 0x7F && + !CharacterSet.controlCharacters.contains(scalar) + } + return validCharacters.isEmpty + } + + private func makeMessage( + text: String = "", + attachments: [MessageAttachment] = [] + ) -> ChatMessage { + ChatMessage( + id: UUID().uuidString, + fromPublicKey: "sender", + toPublicKey: "recipient", + text: text, + timestamp: Int64(Date().timeIntervalSince1970 * 1000), + deliveryStatus: .delivered, + isRead: true, + attachments: attachments + ) + } + + private func makeAttachment(type: AttachmentType, preview: String = "", id: String = "") -> MessageAttachment { + MessageAttachment(id: id.isEmpty ? UUID().uuidString : id, preview: preview, blob: "", type: type) + } + + private func makeReply( + message: String = "", + attachments: [ReplyAttachmentData] = [] + ) -> ReplyMessageData { + ReplyMessageData( + message_id: UUID().uuidString, + publicKey: "sender", + message: message, + timestamp: Int64(Date().timeIntervalSince1970 * 1000), + attachments: attachments + ) + } + + private func makeReplyAttachment(type: Int, preview: String = "") -> ReplyAttachmentData { + ReplyAttachmentData(id: UUID().uuidString, type: type, preview: preview, blob: "") + } + + // ========================================================================= + // MARK: - Reply Bar Preview (ChatDetailView.replyPreviewText) + // ========================================================================= + + // MARK: Photo + + func testReplyBar_PhotoNoCaption() { + let msg = makeMessage(attachments: [makeAttachment(type: .image)]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + } + + func testReplyBar_PhotoWithCaption() { + let msg = makeMessage(text: "Nice sunset", attachments: [makeAttachment(type: .image)]) + XCTAssertEqual(replyPreviewText(for: msg), "Nice sunset") + } + + func testReplyBar_PhotoWithWhitespaceCaption() { + let msg = makeMessage(text: " \n ", attachments: [makeAttachment(type: .image)]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + } + + func testReplyBar_MultiplePhotos() { + let msg = makeMessage(attachments: [ + makeAttachment(type: .image), + makeAttachment(type: .image), + makeAttachment(type: .image) + ]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + } + + // MARK: File + + func testReplyBar_FileWithFilename() { + let att = makeAttachment(type: .file, preview: "tag::12345::document.pdf") + let msg = makeMessage(attachments: [att]) + XCTAssertEqual(replyPreviewText(for: msg), "document.pdf") + } + + func testReplyBar_FileNoPreview() { + let att = makeAttachment(type: .file, id: "file_123") + let msg = makeMessage(attachments: [att]) + XCTAssertEqual(replyPreviewText(for: msg), "file_123") + } + + func testReplyBar_FileEmptyIdAndPreview() { + let att = MessageAttachment(id: "", preview: "", blob: "", type: .file) + let msg = makeMessage(attachments: [att]) + XCTAssertEqual(replyPreviewText(for: msg), "File") + } + + func testReplyBar_FileWithCaption() { + let att = makeAttachment(type: .file, preview: "tag::500::notes.txt") + let msg = makeMessage(text: "Here are the notes", attachments: [att]) + XCTAssertEqual(replyPreviewText(for: msg), "Here are the notes") + } + + // MARK: Avatar + + func testReplyBar_Avatar() { + let msg = makeMessage(attachments: [makeAttachment(type: .avatar)]) + XCTAssertEqual(replyPreviewText(for: msg), "Avatar") + } + + // MARK: Forward + + func testReplyBar_Forward() { + let msg = makeMessage(attachments: [makeAttachment(type: .messages)]) + XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message") + } + + // MARK: Call (Android/Desktop parity) + + func testReplyBar_Call() { + let msg = makeMessage(attachments: [makeAttachment(type: .call)]) + XCTAssertEqual(replyPreviewText(for: msg), "Call") + } + + // MARK: Text + + func testReplyBar_TextOnly() { + let msg = makeMessage(text: "Hello world") + XCTAssertEqual(replyPreviewText(for: msg), "Hello world") + } + + func testReplyBar_Empty() { + let msg = makeMessage() + XCTAssertEqual(replyPreviewText(for: msg), "") + } + + // MARK: Priority + + func testReplyBar_ImagePriorityOverCall() { + let msg = makeMessage(attachments: [ + makeAttachment(type: .image), + makeAttachment(type: .call) + ]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + } + + func testReplyBar_ImagePriorityOverFile() { + let msg = makeMessage(attachments: [ + makeAttachment(type: .image), + makeAttachment(type: .file, preview: "tag::100::doc.pdf") + ]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + } + + func testReplyBar_FilePriorityOverAvatar() { + let msg = makeMessage(attachments: [ + makeAttachment(type: .file, preview: "tag::100::doc.pdf"), + makeAttachment(type: .avatar) + ]) + XCTAssertEqual(replyPreviewText(for: msg), "doc.pdf") + } + + // ========================================================================= + // MARK: - Reply Quote Bubble (MessageCellView.replyQuoteView) + // ========================================================================= + + func testQuote_PhotoNoText() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 0)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") + } + + func testQuote_PhotoWithText() { + let reply = makeReply(message: "Check this out", attachments: [makeReplyAttachment(type: 0)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Check this out") + } + + func testQuote_File() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 2)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "File") + } + + func testQuote_Avatar() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 3)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Avatar") + } + + func testQuote_Forward() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 1)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Forwarded message") + } + + func testQuote_Call() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 4)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Call") + } + + func testQuote_UnknownType() { + let reply = makeReply(attachments: [makeReplyAttachment(type: 99)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Attachment") + } + + func testQuote_TextPriority() { + let reply = makeReply(message: "Some text", attachments: [makeReplyAttachment(type: 4)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Some text") + } + + func testQuote_WhitespaceText() { + let reply = makeReply(message: " ", attachments: [makeReplyAttachment(type: 0)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") + } + + // ========================================================================= + // MARK: - Chat List Preview (DialogRepository) + // ========================================================================= + + func testChatList_Photo() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .image)]), "Photo") + } + + func testChatList_File() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .file)]), "File") + } + + func testChatList_Avatar() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .avatar)]), "Avatar") + } + + func testChatList_Forward() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .messages)]), "Forwarded message") + } + + func testChatList_Call() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .call)]), "Call") + } + + func testChatList_TextWins() { + XCTAssertEqual(chatListPreviewText(text: "Hello", attachments: [makeAttachment(type: .image)]), "Hello") + } + + func testChatList_EmptyNoAttachment() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: []), "") + } + + func testChatList_GarbageTextShowsAttachment() { + // U+FFFD replacement chars = garbage text → should show attachment label + let garbage = "\u{FFFD}\u{FFFD}\u{FFFD}" + XCTAssertEqual(chatListPreviewText(text: garbage, attachments: [makeAttachment(type: .image)]), "Photo") + } + + func testChatList_ControlCharsShowsAttachment() { + let control = "\u{01}\u{02}\u{03}" + XCTAssertEqual(chatListPreviewText(text: control, attachments: [makeAttachment(type: .call)]), "Call") + } + + // ========================================================================= + // MARK: - AttachmentType JSON Decode Fault Tolerance + // ========================================================================= + + func testDecode_UnknownTypeFallsBackToImage() throws { + let json = #"[{"id":"test","preview":"","blob":"","type":99}]"# + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) + XCTAssertEqual(decoded.count, 1) + XCTAssertEqual(decoded[0].type, .image) + } + + func testDecode_NegativeTypeFallsBackToImage() throws { + let json = #"[{"id":"test","preview":"","blob":"","type":-1}]"# + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) + XCTAssertEqual(decoded.count, 1) + XCTAssertEqual(decoded[0].type, .image) + } + + func testDecode_MixedKnownAndUnknown() throws { + let json = #"[{"id":"a","preview":"","blob":"","type":0},{"id":"b","preview":"","blob":"","type":99}]"# + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) + XCTAssertEqual(decoded.count, 2) + XCTAssertEqual(decoded[0].type, .image) + XCTAssertEqual(decoded[1].type, .image) + } + + func testDecode_AllKnownTypes() throws { + let json = """ + [{"id":"a","preview":"","blob":"","type":0},\ + {"id":"b","preview":"","blob":"","type":1},\ + {"id":"c","preview":"","blob":"","type":2},\ + {"id":"d","preview":"","blob":"","type":3},\ + {"id":"e","preview":"","blob":"","type":4}] + """ + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) + XCTAssertEqual(decoded.count, 5) + XCTAssertEqual(decoded[0].type, .image) + XCTAssertEqual(decoded[1].type, .messages) + XCTAssertEqual(decoded[2].type, .file) + XCTAssertEqual(decoded[3].type, .avatar) + XCTAssertEqual(decoded[4].type, .call) + } + + func testDecode_EmptyArrayIsValid() throws { + let json = "[]" + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) + XCTAssertEqual(decoded.count, 0) + } + + // MARK: - Encode → Decode Round-Trip + + func testRoundTrip_AllTypes() throws { + let original: [MessageAttachment] = [ + MessageAttachment(id: "1", preview: "tag::hash", blob: "data", type: .image), + MessageAttachment(id: "2", preview: "", blob: "", type: .messages), + MessageAttachment(id: "3", preview: "tag::500::doc.pdf", blob: "", type: .file), + MessageAttachment(id: "4", preview: "", blob: "", type: .avatar), + MessageAttachment(id: "5", preview: "60", blob: "", type: .call), + ] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode([MessageAttachment].self, from: data) + XCTAssertEqual(decoded, original) + } + + // ========================================================================= + // MARK: - Cross-Context Consistency + // ========================================================================= + + /// Verify all 3 contexts produce the same label for each type when text is empty. + func testAllContextsAgree_Photo() { + let msg = makeMessage(attachments: [makeAttachment(type: .image)]) + let reply = makeReply(attachments: [makeReplyAttachment(type: 0)]) + XCTAssertEqual(replyPreviewText(for: msg), "Photo") + XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") + XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Photo") + } + + func testAllContextsAgree_File() { + // Note: reply bar shows filename, quote/chatlist show "File" + let reply = makeReply(attachments: [makeReplyAttachment(type: 2)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "File") + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .file)]), "File") + } + + func testAllContextsAgree_Avatar() { + let msg = makeMessage(attachments: [makeAttachment(type: .avatar)]) + let reply = makeReply(attachments: [makeReplyAttachment(type: 3)]) + XCTAssertEqual(replyPreviewText(for: msg), "Avatar") + XCTAssertEqual(replyQuotePreviewText(for: reply), "Avatar") + XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Avatar") + } + + func testAllContextsAgree_Forward() { + let msg = makeMessage(attachments: [makeAttachment(type: .messages)]) + let reply = makeReply(attachments: [makeReplyAttachment(type: 1)]) + XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message") + XCTAssertEqual(replyQuotePreviewText(for: reply), "Forwarded message") + XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Forwarded message") + } + + func testAllContextsAgree_Call() { + let msg = makeMessage(attachments: [makeAttachment(type: .call)]) + let reply = makeReply(attachments: [makeReplyAttachment(type: 4)]) + XCTAssertEqual(replyPreviewText(for: msg), "Call") + XCTAssertEqual(replyQuotePreviewText(for: reply), "Call") + XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Call") + } +}