Equatable-ячейки сообщений, пагинация скролла, оптимизация removeDuplicates

This commit is contained in:
2026-03-25 15:06:01 +05:00
parent d482cdf62b
commit d0041f0c10
9 changed files with 1307 additions and 1258 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,10 @@ final class ChatDetailViewModel: ObservableObject {
@Published private(set) var isTyping: Bool = false
/// Android parity: true while loading messages from DB. Shows skeleton placeholder.
@Published private(set) var isLoading: Bool = true
/// Pagination: true while older messages are available in SQLite.
@Published private(set) var hasMoreMessages: Bool = true
/// Pagination: guard against concurrent loads.
@Published private(set) var isLoadingMore: Bool = false
private var cancellables = Set<AnyCancellable>()
@@ -29,6 +33,10 @@ final class ChatDetailViewModel: ObservableObject {
// Android parity: if we already have messages, skip skeleton.
// Otherwise keep isLoading=true until first Combine emission or timeout.
isLoading = initial.isEmpty
// If initial load returned fewer than pageSize, no more to load.
if initial.count < MessageRepository.pageSize {
hasMoreMessages = false
}
// Subscribe to messagesByDialog changes, filtered to our dialog only.
// Broken into steps to help the Swift type-checker.
@@ -43,7 +51,9 @@ final class ChatDetailViewModel: ObservableObject {
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
guard lhs.count == rhs.count else { return false }
for i in lhs.indices {
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
if lhs[i].id != rhs[i].id ||
lhs[i].deliveryStatus != rhs[i].deliveryStatus ||
lhs[i].isRead != rhs[i].isRead {
return false
}
}
@@ -74,4 +84,23 @@ final class ChatDetailViewModel: ObservableObject {
}
.store(in: &cancellables)
}
/// Pagination: load older messages from SQLite when user scrolls to top.
func loadMore() async {
guard !isLoadingMore, hasMoreMessages else { return }
guard let earliest = messages.first else { return }
isLoadingMore = true
let older = MessageRepository.shared.loadOlderMessages(
for: dialogKey,
beforeTimestamp: earliest.timestamp,
limit: MessageRepository.pageSize
)
if older.count < MessageRepository.pageSize {
hasMoreMessages = false
}
// messages will update via Combine pipeline (repo already prepends to cache).
isLoadingMore = false
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
/// Stable callback reference for message cell interactions.
/// Class ref means SwiftUI sees the same pointer on parent re-render,
/// so cells are NOT marked dirty due to closure diffing (memcmp).
@MainActor
final class MessageCellActions {
var onReply: (ChatMessage) -> Void = { _ in }
var onForward: (ChatMessage) -> Void = { _ in }
var onDelete: (ChatMessage) -> Void = { _ in }
var onCopy: (String) -> Void = { _ in }
var onImageTap: (String) -> Void = { _ in }
var onScrollToMessage: (String) -> Void = { _ in }
var onRetry: (ChatMessage) -> Void = { _ in }
var onRemove: (ChatMessage) -> Void = { _ in }
}

View File

@@ -0,0 +1,843 @@
import SwiftUI
import UIKit
/// Equatable message cell SwiftUI skips body re-evaluation when inputs haven't changed.
/// Extracted from ChatDetailView to create an Equatable boundary for `.equatable()` modifier.
struct MessageCellView: View, Equatable {
let message: ChatMessage
let maxBubbleWidth: CGFloat
let position: BubblePosition
let currentPublicKey: String
let highlightedMessageId: String?
let isSavedMessages: Bool
let isSystemAccount: Bool
let opponentPublicKey: String
let opponentTitle: String
let opponentUsername: String
let actions: MessageCellActions
static func == (lhs: MessageCellView, rhs: MessageCellView) -> Bool {
lhs.message == rhs.message &&
lhs.maxBubbleWidth == rhs.maxBubbleWidth &&
lhs.position == rhs.position &&
lhs.highlightedMessageId == rhs.highlightedMessageId
// currentPublicKey, isSavedMessages, isSystemAccount, opponent* stable per chat session
// actions class ref, excluded (pointer identity is unstable across re-renders)
}
var body: some View {
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let hasTail = position == .single || position == .bottom
let visibleAttachments = message.attachments.filter {
$0.type == .image || $0.type == .file || $0.type == .avatar
}
Group {
if visibleAttachments.isEmpty {
let hasReplyAttachment = message.attachments.contains(where: { $0.type == .messages })
if hasReplyAttachment || !Self.isGarbageText(message.text) {
textOnlyBubble(
message: message,
outgoing: outgoing,
hasTail: hasTail,
maxBubbleWidth: maxBubbleWidth,
position: position
)
}
} else {
attachmentBubble(
message: message,
attachments: visibleAttachments,
outgoing: outgoing,
hasTail: hasTail,
maxBubbleWidth: maxBubbleWidth,
position: position
)
}
}
.modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount,
onReply: { actions.onReply(message) }
))
.overlay {
if highlightedMessageId == message.id {
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.12))
.allowsHitTesting(false)
}
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.top, (position == .single || position == .top) ? 6 : 2)
.padding(.bottom, 0)
}
// MARK: - Text-Only Bubble
@ViewBuilder
private func textOnlyBubble(
message: ChatMessage, outgoing: Bool, hasTail: Bool,
maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let messageText = message.text.isEmpty ? " " : message.text
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let replyData = replyAttachment.flatMap {
parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview)
}?.first
let isForward = (message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| Self.isGarbageText(message.text)) && replyData != nil
if isForward, let reply = replyData {
forwardedMessageBubble(
message: message, reply: reply, outgoing: outgoing,
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
)
} else {
VStack(alignment: .leading, spacing: 0) {
if let reply = replyData {
replyQuoteView(reply: reply, outgoing: outgoing)
}
Text(parsedMarkdown(messageText))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in actions.onScrollToMessage(reply.message_id) }
}
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
}
// MARK: - Forwarded Message Bubble
@ViewBuilder
private func forwardedMessageBubble(
message: ChatMessage, reply: ReplyMessageData, outgoing: Bool,
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let senderName = senderDisplayName(for: reply.publicKey)
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
let imageAttachments = reply.attachments.filter { $0.type == 0 }
let fileAttachments = reply.attachments.filter { $0.type == 2 }
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
&& !Self.isGarbageText(reply.message)
#if DEBUG
let _ = {
if reply.attachments.isEmpty {
print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))")
if let att = message.attachments.first(where: { $0.type == .messages }) {
let blobPrefix = att.blob.prefix(60)
let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[")
print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)")
}
} else {
print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)")
}
}()
#endif
let fallbackText: String = {
if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
if let file = fileAttachments.first {
let parts = file.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
return file.id.isEmpty ? "File" : file.id
}
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
return "Message"
}()
let imageContentWidth = maxBubbleWidth - 22
- (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
- (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
VStack(alignment: .leading, spacing: 0) {
Text("Forwarded from")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.top, 6)
HStack(spacing: 6) {
AvatarView(
initials: senderInitials,
colorIndex: senderColorIndex,
size: 20,
image: senderAvatar
)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
.lineLimit(1)
}
.padding(.leading, 11)
.padding(.top, 3)
if !imageAttachments.isEmpty {
ForwardedPhotoCollageView(
attachments: imageAttachments,
outgoing: outgoing,
maxWidth: imageContentWidth,
onImageTap: { attId in actions.onImageTap(attId) }
)
.padding(.horizontal, 6)
.padding(.top, 4)
}
ForEach(fileAttachments, id: \.id) { att in
forwardedFilePreview(attachment: att, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
}
if hasCaption {
Text(parsedMarkdown(reply.message))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 3)
.padding(.bottom, 5)
} else if !hasVisualAttachments {
Text(fallbackText)
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 3)
.padding(.bottom, 5)
} else {
Spacer().frame(height: 5)
}
}
.frame(minWidth: 130, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
if !imageAttachments.isEmpty && !hasCaption {
mediaTimestampOverlay(message: message, outgoing: outgoing)
} else {
timestampOverlay(message: message, outgoing: outgoing)
}
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !imageAttachments.isEmpty ? { _ in
if let firstId = imageAttachments.first?.id {
actions.onImageTap(firstId)
}
} : nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
// MARK: - Attachment Bubble
@ViewBuilder
private func attachmentBubble(
message: ChatMessage, attachments: [MessageAttachment], outgoing: Bool,
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let hasCaption = Self.isValidCaption(message.text)
let partitioned = Self.partitionAttachments(attachments)
let imageAttachments = partitioned.images
let otherAttachments = partitioned.others
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) {
if !imageAttachments.isEmpty {
PhotoCollageView(
attachments: imageAttachments,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
position: position
)
}
ForEach(otherAttachments, id: \.id) { attachment in
Group {
switch attachment.type {
case .file:
MessageFileView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 6)
.padding(.top, 4)
default:
EmptyView()
}
}
}
if hasCaption {
Text(parsedMarkdown(message.text))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 6)
.padding(.bottom, 5)
}
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
if isImageOnly {
mediaTimestampOverlay(message: message, outgoing: outgoing)
} else {
timestampOverlay(message: message, outgoing: outgoing)
}
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !attachments.isEmpty ? { tapLocation in
if !imageAttachments.isEmpty {
let tappedId = imageAttachments.count == 1
? imageAttachments[0].id
: Self.collageAttachmentId(
at: tapLocation,
attachments: imageAttachments,
maxWidth: maxBubbleWidth
)
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
actions.onImageTap(tappedId)
} else {
NotificationCenter.default.post(
name: .triggerAttachmentDownload, object: tappedId
)
}
} else {
for att in otherAttachments {
NotificationCenter.default.post(
name: .triggerAttachmentDownload, object: att.id
)
}
}
} : nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
// MARK: - Timestamp Overlays
@ViewBuilder
private func timestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
}
@ViewBuilder
private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(.white)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
.padding(.trailing, 6)
.padding(.bottom, 6)
}
// MARK: - Delivery Indicators
private func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color {
if status == .delivered && read { return Color(hex: 0xA4E2FF) }
switch status {
case .delivered: return Color.white.opacity(0.5)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
}
}
@ViewBuilder
private func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
if status == .delivered && read {
DoubleCheckmarkShape()
.fill(deliveryTint(status, read: read))
.frame(width: 16, height: 8.7)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(deliveryTint(status, read: read))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status, read: read))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status, read: read))
}
}
}
@ViewBuilder
private func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
if status == .delivered && read {
DoubleCheckmarkShape()
.fill(Color.white)
.frame(width: 16, height: 8.7)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(Color.white.opacity(0.8))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
}
@ViewBuilder
private func errorMenu(for message: ChatMessage) -> some View {
Menu {
Button {
actions.onRetry(message)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
actions.onRemove(message)
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
// MARK: - Bubble Background
private var incomingBubbleFill: Color {
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
}
@ViewBuilder
private func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View {
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
MessageBubbleShape(position: position, outgoing: outgoing)
.fill(fill)
}
// MARK: - Context Menu
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
return "Read"
}
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
result.append(BubbleContextAction(
title: "Copy",
image: UIImage(systemName: "doc.on.doc"),
role: []
) { actions.onCopy(message.text) })
if !message.attachments.contains(where: { $0.type == .avatar }) {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),
role: []
) { actions.onForward(message) })
}
result.append(BubbleContextAction(
title: "Delete",
image: UIImage(systemName: "trash"),
role: .destructive
) { actions.onDelete(message) })
return result
}
// MARK: - Reply Quote
@ViewBuilder
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
let senderName = senderDisplayName(for: reply.publicKey)
let previewText: 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" }
return "Attachment"
}()
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
let parts = att.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : att.preview
return hash.isEmpty ? nil : hash
}()
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 4)
if let att = imageAttachment {
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 1) {
Text(senderName)
.font(.system(size: 15, weight: .semibold))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
.lineLimit(1)
Text(previewText)
.font(.system(size: 15, weight: .regular))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.padding(.leading, 6)
Spacer(minLength: 0)
}
.frame(height: 41)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
.padding(.horizontal, 5)
.padding(.top, 5)
.padding(.bottom, 0)
}
// MARK: - Forwarded File Preview
@ViewBuilder
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
let filename: String = {
let parts = attachment.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
return attachment.id.isEmpty ? "File" : attachment.id
}()
HStack(spacing: 8) {
Image(systemName: "doc.fill")
.font(.system(size: 20))
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
Text(filename)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
}
// MARK: - Collage Hit Test
static func collageAttachmentId(
at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat
) -> String {
let spacing: CGFloat = 2
let count = attachments.count
let x = point.x
let y = point.y
switch count {
case 2:
let half = (maxWidth - spacing) / 2
return attachments[x < half ? 0 : 1].id
case 3:
let rightWidth = maxWidth * 0.34
let leftWidth = maxWidth - spacing - rightWidth
let totalHeight = min(leftWidth * 1.1, 300)
let rightCellHeight = (totalHeight - spacing) / 2
if x < leftWidth {
return attachments[0].id
} else {
return attachments[y < rightCellHeight ? 1 : 2].id
}
case 4:
let half = (maxWidth - spacing) / 2
let cellHeight = min(half * 0.85, 150)
let row = y < cellHeight ? 0 : 1
let col = x < half ? 0 : 1
return attachments[row * 2 + col].id
case 5:
let topCellWidth = (maxWidth - spacing) / 2
let bottomCellWidth = (maxWidth - spacing * 2) / 3
let topHeight = min(topCellWidth * 0.85, 165)
if y < topHeight {
return attachments[x < topCellWidth ? 0 : 1].id
} else {
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
return attachments[2 + col].id
}
default:
return attachments[0].id
}
}
// MARK: - Sender Display Name
@MainActor private static var senderNameCache: [String: String] = [:]
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey {
return "You"
}
if let cached = Self.senderNameCache[publicKey] {
return cached
}
if publicKey == opponentPublicKey && !opponentTitle.isEmpty {
Self.senderNameCache[publicKey] = opponentTitle
return opponentTitle
}
if let dialog = DialogRepository.shared.dialogs[publicKey],
!dialog.opponentTitle.isEmpty {
Self.senderNameCache[publicKey] = dialog.opponentTitle
return dialog.opponentTitle
}
if publicKey == opponentPublicKey && !opponentUsername.isEmpty {
let name = "@\(opponentUsername)"
Self.senderNameCache[publicKey] = name
return name
}
return String(publicKey.prefix(8)) + ""
}
// MARK: - Static Caches
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
guard !blob.isEmpty else { return nil }
if let cached = Self.replyBlobCache[blob] { return cached }
guard let data = blob.data(using: .utf8) else { return nil }
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
if Self.replyBlobCache.count > 300 {
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
}
Self.replyBlobCache[blob] = result
return result
}
@MainActor private static var markdownCache: [String: AttributedString] = [:]
private func parsedMarkdown(_ text: String) -> AttributedString {
if let cached = Self.markdownCache[text] {
PerformanceLogger.shared.track("markdown.cacheHit")
return cached
}
PerformanceLogger.shared.track("markdown.cacheMiss")
let withEmoji = EmojiParser.replaceShortcodes(in: text)
let result: AttributedString
if let parsed = try? AttributedString(
markdown: withEmoji,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
result = parsed
} else {
result = AttributedString(withEmoji)
}
if Self.markdownCache.count > 500 {
let keysToRemove = Array(Self.markdownCache.keys.prefix(250))
for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) }
}
Self.markdownCache[text] = result
return result
}
@MainActor private static var blurHashCache: [String: UIImage] = [:]
@MainActor
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
let key = "\(hash)_\(width)x\(height)"
if let cached = blurHashCache[key] { return cached }
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
if blurHashCache.count > 300 {
let keysToRemove = Array(blurHashCache.keys.prefix(150))
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
}
blurHashCache[key] = image
return image
}
@MainActor private static var timeCache: [Int64: String] = [:]
private func messageTime(_ timestamp: Int64) -> String {
if let cached = Self.timeCache[timestamp] { return cached }
let result = Self.timeFormatter.string(
from: Date(timeIntervalSince1970: Double(timestamp) / 1000)
)
if Self.timeCache.count > 500 {
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
}
Self.timeCache[timestamp] = result
return result
}
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HH:mm"
return formatter
}()
// MARK: - Static Helpers
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
}
static func isValidCaption(_ text: String) -> Bool {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.isEmpty { return false }
if text == " " { return false }
if isGarbageText(text) { return false }
return true
}
static func partitionAttachments(
_ attachments: [MessageAttachment]
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
var images: [MessageAttachment] = []
var others: [MessageAttachment] = []
for att in attachments {
if att.type == .image { images.append(att) }
else { others.append(att) }
}
return (images, others)
}
}