Reply-бар: Telegram-parity стилизация, alignment, preview-текст и cross-platform аудит
This commit is contained in:
@@ -339,16 +339,14 @@ final class MessageRepository: ObservableObject {
|
|||||||
MessageRecord.Columns.account == myPublicKey &&
|
MessageRecord.Columns.account == myPublicKey &&
|
||||||
MessageRecord.Columns.messageId == messageId
|
MessageRecord.Columns.messageId == messageId
|
||||||
).fetchOne(db) {
|
).fetchOne(db) {
|
||||||
// Protect decrypted .messages blobs from being overwritten by encrypted sync copies.
|
// Protect existing attachments from being wiped during sync.
|
||||||
// If existing attachments have a valid JSON blob but new ones have encrypted ciphertext,
|
// Android parity: never replace non-empty attachments with empty.
|
||||||
// keep the existing (correctly decrypted) version.
|
|
||||||
let shouldPreserveAttachments: Bool = {
|
let shouldPreserveAttachments: Bool = {
|
||||||
guard existing.attachments != "[]" else { return false }
|
guard existing.attachments != "[]", !existing.attachments.isEmpty else { return false }
|
||||||
// If existing has a .messages blob that looks like valid JSON (starts with "["),
|
// Never wipe existing attachments with empty data
|
||||||
// and new blob looks like ciphertext (contains ":" from ivBase64:ctBase64),
|
if attachmentsJSON == "[]" { return true }
|
||||||
// preserve the existing data.
|
// Protect decrypted .messages blobs from encrypted sync copies
|
||||||
if existing.attachments.contains("\"type\":1") || existing.attachments.contains("\"type\": 1") {
|
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 existingHasDecrypted = existing.attachments.contains("\"message_id\"")
|
||||||
let newHasDecrypted = attachmentsJSON.contains("\"message_id\"")
|
let newHasDecrypted = attachmentsJSON.contains("\"message_id\"")
|
||||||
if existingHasDecrypted && !newHasDecrypted { return true }
|
if existingHasDecrypted && !newHasDecrypted { return true }
|
||||||
|
|||||||
@@ -66,12 +66,19 @@ enum PacketRegistry {
|
|||||||
|
|
||||||
// MARK: - Attachment Types
|
// MARK: - Attachment Types
|
||||||
|
|
||||||
enum AttachmentType: Int, Codable {
|
enum AttachmentType: Int, Codable, Sendable {
|
||||||
case image = 0
|
case image = 0
|
||||||
case messages = 1
|
case messages = 1
|
||||||
case file = 2
|
case file = 2
|
||||||
case avatar = 3
|
case avatar = 3
|
||||||
case call = 4
|
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 {
|
struct MessageAttachment: Codable, Equatable {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ struct CallActivityAttributes: ActivityAttributes {
|
|||||||
let peerName: String
|
let peerName: String
|
||||||
let peerPublicKey: String
|
let peerPublicKey: String
|
||||||
let colorIndex: Int
|
let colorIndex: Int
|
||||||
|
/// Tiny avatar thumbnail (32x32 JPEG, ~1-2KB). Nil = show initials.
|
||||||
|
let avatarThumb: Data?
|
||||||
|
|
||||||
struct ContentState: Codable, Hashable {
|
struct ContentState: Codable, Hashable {
|
||||||
let durationSec: Int
|
let durationSec: Int
|
||||||
|
|||||||
@@ -336,20 +336,26 @@ final class CallManager: NSObject, ObservableObject {
|
|||||||
print("[Call] LiveActivity DISABLED by user settings")
|
print("[Call] LiveActivity DISABLED by user settings")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Save peer avatar to App Group so widget extension can read it
|
// Create tiny avatar thumbnail (32x32) to embed directly in attributes (<4KB limit)
|
||||||
if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.rosetta.dev") {
|
var avatarThumb: Data?
|
||||||
let avatarURL = containerURL.appendingPathComponent("call_avatar.jpg")
|
if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
|
||||||
if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey),
|
let thumbSize = CGSize(width: 32, height: 32)
|
||||||
let thumb = avatar.jpegData(compressionQuality: 0.4) {
|
UIGraphicsBeginImageContextWithOptions(thumbSize, false, 1.0)
|
||||||
try? thumb.write(to: avatarURL)
|
avatar.draw(in: CGRect(origin: .zero, size: thumbSize))
|
||||||
} else {
|
let tiny = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
try? FileManager.default.removeItem(at: avatarURL)
|
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(
|
let attributes = CallActivityAttributes(
|
||||||
peerName: uiState.displayName,
|
peerName: uiState.displayName,
|
||||||
peerPublicKey: uiState.peerPublicKey,
|
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(
|
let state = CallActivityAttributes.ContentState(
|
||||||
durationSec: uiState.durationSec,
|
durationSec: uiState.durationSec,
|
||||||
@@ -371,6 +377,7 @@ final class CallManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
func updateLiveActivity() {
|
func updateLiveActivity() {
|
||||||
guard let liveActivity, liveActivity.activityState == .active else { return }
|
guard let liveActivity, liveActivity.activityState == .active else { return }
|
||||||
|
// Avatar is embedded in attributes (set at startLiveActivity)
|
||||||
let state = CallActivityAttributes.ContentState(
|
let state = CallActivityAttributes.ContentState(
|
||||||
durationSec: uiState.durationSec,
|
durationSec: uiState.durationSec,
|
||||||
isActive: uiState.phase == .active,
|
isActive: uiState.phase == .active,
|
||||||
@@ -381,6 +388,8 @@ final class CallManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avatar is embedded directly in CallActivityAttributes (32x32 thumb)
|
||||||
|
|
||||||
func endLiveActivity() {
|
func endLiveActivity() {
|
||||||
guard let liveActivity else { return }
|
guard let liveActivity else { return }
|
||||||
let finalState = CallActivityAttributes.ContentState(
|
let finalState = CallActivityAttributes.ContentState(
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ struct ActiveCallOverlayView: View {
|
|||||||
AvatarView(
|
AvatarView(
|
||||||
initials: peerInitials,
|
initials: peerInitials,
|
||||||
colorIndex: peerColorIndex,
|
colorIndex: peerColorIndex,
|
||||||
size: 90
|
size: 90,
|
||||||
|
image: AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: 130, height: 130)
|
.frame(width: 130, height: 130)
|
||||||
|
|||||||
@@ -1181,6 +1181,8 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
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 == .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)
|
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty { return message.text }
|
if !trimmed.isEmpty { return message.text }
|
||||||
if !message.attachments.isEmpty { return "Attachment" }
|
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 == .avatar }) { return "Avatar" }
|
||||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
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
|
// No known attachment type — fall back to text
|
||||||
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty { return message.text }
|
if !trimmed.isEmpty { return message.text }
|
||||||
@@ -1217,17 +1221,17 @@ private extension ChatDetailView {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 1.5)
|
RoundedRectangle(cornerRadius: 1.0)
|
||||||
.fill(RosettaColors.figmaBlue)
|
.fill(RosettaColors.figmaBlue)
|
||||||
.frame(width: 3, height: 36)
|
.frame(width: 2, height: 36)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("Reply to ")
|
Text("Reply to ")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(.white)
|
||||||
Text(senderName)
|
Text(senderName)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.figmaBlue)
|
.foregroundStyle(RosettaColors.figmaBlue)
|
||||||
}
|
}
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -1247,15 +1251,15 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 44, height: 44)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 8) // align blue line with text cursor (6pt textView padding + 2pt inset)
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 0)
|
||||||
.padding(.top, 6)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 2) // tight gap to text input below
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,15 +125,15 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
replyBar.isHidden = true
|
replyBar.isHidden = true
|
||||||
|
|
||||||
replyBlueBar.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
|
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)
|
replyBar.addSubview(replyBlueBar)
|
||||||
|
|
||||||
replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
replyTitleLabel.textColor = UIColor(white: 1, alpha: 0.6)
|
replyTitleLabel.textColor = .white
|
||||||
replyTitleLabel.text = "Reply to "
|
replyTitleLabel.text = "Reply to "
|
||||||
replyBar.addSubview(replyTitleLabel)
|
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)
|
replySenderLabel.textColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
|
||||||
replyBar.addSubview(replySenderLabel)
|
replyBar.addSubview(replySenderLabel)
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
replyPreviewLabel.lineBreakMode = .byTruncatingTail
|
replyPreviewLabel.lineBreakMode = .byTruncatingTail
|
||||||
replyBar.addSubview(replyPreviewLabel)
|
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.setImage(xImage, for: .normal)
|
||||||
replyCancelButton.tintColor = UIColor(white: 1, alpha: 0.6)
|
replyCancelButton.tintColor = UIColor(white: 1, alpha: 0.6)
|
||||||
replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside)
|
replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside)
|
||||||
@@ -281,8 +281,8 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
let w = bounds.width
|
let w = bounds.width
|
||||||
guard w > 0 else { return }
|
guard w > 0 else { return }
|
||||||
|
|
||||||
// Reply bar height
|
// Reply bar height (8 top + 36 bar + 2 bottom = tight gap to text)
|
||||||
let replyH: CGFloat = isReplyVisible ? 46 : 0 // 6 top + 36 bar + 4 bottom
|
let replyH: CGFloat = isReplyVisible ? 46 : 0
|
||||||
|
|
||||||
// Text row height = textViewHeight (clamped)
|
// Text row height = textViewHeight (clamped)
|
||||||
let textRowH = textViewHeight
|
let textRowH = textViewHeight
|
||||||
@@ -366,21 +366,22 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
|
|
||||||
private func layoutReplyBar(width: CGFloat, height: CGFloat) {
|
private func layoutReplyBar(width: CGFloat, height: CGFloat) {
|
||||||
guard height > 0 else { return }
|
guard height > 0 else { return }
|
||||||
let barX: CGFloat = 0
|
// barX=5 aligns blue line with text cursor: replyX(6)+5 = 11 = textX(9)+inset(2)
|
||||||
let barY: CGFloat = 6
|
let barX: CGFloat = 5
|
||||||
replyBlueBar.frame = CGRect(x: barX, y: barY, width: 3, height: 36)
|
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))
|
let titleSize = replyTitleLabel.sizeThatFits(CGSize(width: 200, height: 20))
|
||||||
replyTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: titleSize.width, height: 17)
|
replyTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: titleSize.width, height: 17)
|
||||||
|
|
||||||
let senderX = labelX + titleSize.width
|
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)
|
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
|
// MARK: - Icon Helpers
|
||||||
|
|||||||
@@ -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.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.file.rawValue }) { return "File" }
|
||||||
if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
|
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"
|
return "Attachment"
|
||||||
}()
|
}()
|
||||||
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ struct CallActivityAttributes: ActivityAttributes {
|
|||||||
let peerName: String
|
let peerName: String
|
||||||
let peerPublicKey: String
|
let peerPublicKey: String
|
||||||
let colorIndex: Int
|
let colorIndex: Int
|
||||||
|
/// Tiny avatar thumbnail (32x32 JPEG, ~1-2KB). Nil = show initials.
|
||||||
|
let avatarThumb: Data?
|
||||||
|
|
||||||
struct ContentState: Codable, Hashable {
|
struct ContentState: Codable, Hashable {
|
||||||
let durationSec: Int
|
let durationSec: Int
|
||||||
|
|||||||
@@ -2,21 +2,31 @@ import ActivityKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// Mantine v8 avatar tint colors (shade-6) — exact hex values, desktop parity
|
// Mantine v8 avatar palette — exact hex parity with RosettaColors.avatarColors
|
||||||
private let mantineTints: [Color] = [
|
// tint = shade-6, text = shade-3
|
||||||
Color(red: 34/255, green: 139/255, blue: 230/255), // #228be6 blue
|
|
||||||
Color(red: 21/255, green: 170/255, blue: 191/255), // #15aabf cyan
|
private struct AvatarPalette {
|
||||||
Color(red: 190/255, green: 75/255, blue: 219/255), // #be4bdb grape
|
let tint: Color
|
||||||
Color(red: 64/255, green: 192/255, blue: 87/255), // #40c057 green
|
let text: Color
|
||||||
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
|
private let mantinePalette: [AvatarPalette] = [
|
||||||
Color(red: 230/255, green: 73/255, blue: 128/255), // #e64980 pink
|
AvatarPalette(tint: Color(red: 0x22/255, green: 0x8B/255, blue: 0xE6/255), text: Color(red: 0x74/255, green: 0xC0/255, blue: 0xFC/255)), // blue
|
||||||
Color(red: 250/255, green: 82/255, blue: 82/255), // #fa5252 red
|
AvatarPalette(tint: Color(red: 0x15/255, green: 0xAA/255, blue: 0xBF/255), text: Color(red: 0x66/255, green: 0xD9/255, blue: 0xE8/255)), // cyan
|
||||||
Color(red: 18/255, green: 184/255, blue: 134/255), // #12b886 teal
|
AvatarPalette(tint: Color(red: 0xBE/255, green: 0x4B/255, blue: 0xDB/255), text: Color(red: 0xE5/255, green: 0x99/255, blue: 0xF7/255)), // grape
|
||||||
Color(red: 121/255, green: 80/255, blue: 242/255), // #7950f2 violet
|
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"
|
private let appGroupID = "group.com.rosetta.dev"
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -118,7 +128,7 @@ struct CallLiveActivity: Widget {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
avatarView(context: context, size: 26, fontSize: 10)
|
avatarView(context: context, size: 26, fontSize: 9)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
if context.state.isActive {
|
if context.state.isActive {
|
||||||
Text(fmt(context.state.durationSec))
|
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
|
@ViewBuilder
|
||||||
private func avatarView(context: ActivityViewContext<CallActivityAttributes>, size: CGFloat, fontSize: CGFloat) -> some View {
|
private func avatarView(context: ActivityViewContext<CallActivityAttributes>, size: CGFloat, fontSize: CGFloat) -> some View {
|
||||||
if let img = loadSharedAvatar() {
|
if let data = context.attributes.avatarThumb,
|
||||||
|
let img = UIImage(data: data) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
} else {
|
} 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 {
|
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))
|
Text(makeInitials(context.attributes.peerName, context.attributes.peerPublicKey))
|
||||||
.font(.system(size: fontSize, weight: .bold))
|
.font(.system(size: fontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(palette.text)
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func fmt(_ sec: Int) -> String {
|
private func fmt(_ sec: Int) -> String {
|
||||||
|
|||||||
437
RosettaTests/ReplyPreviewTextTests.swift
Normal file
437
RosettaTests/ReplyPreviewTextTests.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user