Equatable-ячейки сообщений, пагинация скролла, оптимизация removeDuplicates
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
16
Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift
Normal file
16
Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift
Normal 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 }
|
||||
}
|
||||
843
Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
Normal file
843
Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user