Фикс: имя файла в пересланных сообщениях, потеря фоток/файлов при пересылке forwarded-сообщений, Фоллбэк при unwrap forwarded-сообщения, защита БД от перезаписи синком
This commit is contained in:
100
Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift
Normal file
100
Rosetta/Features/Chats/ChatDetail/ChatDetailSkeletonView.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ChatDetailSkeletonView
|
||||
|
||||
/// Android parity: `MessageSkeletonList` composable in ChatDetailComponents.kt.
|
||||
/// Shows shimmer placeholder message bubbles while chat messages are loading from DB.
|
||||
/// Uses TimelineView (.animation) for clock-based shimmer that never restarts on view rebuild.
|
||||
struct ChatDetailSkeletonView: View {
|
||||
|
||||
/// Max bubble width (same as real messages).
|
||||
var maxBubbleWidth: CGFloat = 270
|
||||
|
||||
/// Predefined bubble specs matching Android's `heightRandom` / `widthRandom` arrays.
|
||||
/// (outgoing, widthFraction, height)
|
||||
private static let bubbleSpecs: [(outgoing: Bool, widthFrac: CGFloat, height: CGFloat)] = [
|
||||
(false, 0.55, 44),
|
||||
(true, 0.70, 80),
|
||||
(false, 0.45, 36),
|
||||
(true, 0.60, 52),
|
||||
(false, 0.75, 68),
|
||||
(true, 0.50, 44),
|
||||
(false, 0.65, 60),
|
||||
(true, 0.82, 96),
|
||||
(false, 0.40, 36),
|
||||
(true, 0.55, 48),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
let phase = shimmerPhase(from: timeline.date)
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(0..<Self.bubbleSpecs.count, id: \.self) { index in
|
||||
let spec = Self.bubbleSpecs[index]
|
||||
skeletonBubble(
|
||||
outgoing: spec.outgoing,
|
||||
width: maxBubbleWidth * spec.widthFrac,
|
||||
height: spec.height,
|
||||
phase: phase
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 80)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skeleton Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private func skeletonBubble(
|
||||
outgoing: Bool,
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
phase: CGFloat
|
||||
) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
if outgoing { Spacer(minLength: 0) }
|
||||
|
||||
if !outgoing {
|
||||
// Avatar circle placeholder (incoming messages)
|
||||
Circle()
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(shimmerGradient(phase: phase))
|
||||
.frame(width: width, height: height)
|
||||
|
||||
if !outgoing { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shimmer Helpers (same pattern as SearchSkeletonView)
|
||||
|
||||
/// Clock-based phase — never resets on view rebuild.
|
||||
private func shimmerPhase(from date: Date) -> CGFloat {
|
||||
let elapsed = date.timeIntervalSinceReferenceDate
|
||||
let cycle: Double = 1.5
|
||||
return CGFloat(elapsed.truncatingRemainder(dividingBy: cycle) / cycle)
|
||||
}
|
||||
|
||||
private func shimmerGradient(phase: CGFloat) -> LinearGradient {
|
||||
let position = phase * 2.0 - 0.5
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.06),
|
||||
Color.gray.opacity(0.14),
|
||||
Color.gray.opacity(0.06),
|
||||
],
|
||||
startPoint: UnitPoint(x: position - 0.3, y: 0),
|
||||
endPoint: UnitPoint(x: position + 0.3, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,12 @@ struct ChatDetailView: View {
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Android parity: mark messages as read in DB IMMEDIATELY (no delay).
|
||||
// This prevents reconcileUnreadCounts() from re-inflating badge
|
||||
// if it runs during the 600ms navigation delay.
|
||||
MessageRepository.shared.markIncomingAsRead(
|
||||
opponentKey: route.publicKey, myPublicKey: currentPublicKey
|
||||
)
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
@@ -618,7 +624,10 @@ private extension ChatDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func messagesList(maxBubbleWidth: CGFloat) -> some View {
|
||||
if messages.isEmpty {
|
||||
if viewModel.isLoading && messages.isEmpty {
|
||||
// Android parity: skeleton placeholder while loading from DB
|
||||
ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth)
|
||||
} else if messages.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
messagesScrollView(maxBubbleWidth: maxBubbleWidth)
|
||||
@@ -829,8 +838,12 @@ private extension ChatDetailView {
|
||||
|
||||
Group {
|
||||
if visibleAttachments.isEmpty {
|
||||
// Text-only message (original path)
|
||||
textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
// Text-only message — skip if text is garbage/empty (avatar messages with failed decrypt).
|
||||
// Exception: MESSAGES attachments (reply/forward) have empty text by design.
|
||||
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 {
|
||||
// Attachment message: images/files + optional caption
|
||||
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
|
||||
@@ -865,9 +878,11 @@ private extension ChatDetailView {
|
||||
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) }?.first
|
||||
// Forward detection: text is empty/space, but has a MESSAGES attachment with data.
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil
|
||||
// Android parity: try blob first, fall back to preview (incoming may store in preview).
|
||||
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview) }?.first
|
||||
// Forward detection: text is empty/whitespace/garbage, but has a MESSAGES attachment with data.
|
||||
// Uses isGarbageText to also catch replacement characters from encrypting empty text.
|
||||
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)
|
||||
@@ -937,13 +952,35 @@ private extension ChatDetailView {
|
||||
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
|
||||
|
||||
// Text: show as caption below visual attachments, or as main content if no attachments.
|
||||
// Filter garbage text (U+FFFD replacement chars from failed decryption of " " space text).
|
||||
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
|
||||
|
||||
// Fallback label when no visual attachments and no text.
|
||||
let fallbackText: String = {
|
||||
if hasCaption { return reply.message }
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||
if let file = fileAttachments.first {
|
||||
// Parse preview for filename (format: "tag::fileSize::fileName")
|
||||
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"
|
||||
}()
|
||||
@@ -1062,9 +1099,16 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
/// File attachment preview inside a forwarded message bubble.
|
||||
/// Desktop parity: parse preview ("tag::fileSize::fileName") to extract filename.
|
||||
@ViewBuilder
|
||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||
let filename = attachment.id.isEmpty ? "File" : attachment.id
|
||||
let filename: String = {
|
||||
// preview format: "tag::fileSize::fileName" (same as MessageFileView)
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
if parts.count > 2 { return parts[2] }
|
||||
// Fallback: try id, then generic label
|
||||
return attachment.id.isEmpty ? "File" : attachment.id
|
||||
}()
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 20))
|
||||
@@ -1108,9 +1152,10 @@ private extension ChatDetailView {
|
||||
let previewText: String = {
|
||||
let trimmed = reply.message.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return reply.message }
|
||||
if reply.attachments.contains(where: { $0.type == 0 }) { return "Photo" }
|
||||
if reply.attachments.contains(where: { $0.type == 2 }) { return "File" }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
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
|
||||
@@ -1171,29 +1216,37 @@ private extension ChatDetailView {
|
||||
@MainActor private static var senderNameCache: [String: String] = [:]
|
||||
|
||||
/// Resolves a public key to a display name for reply/forward quotes.
|
||||
/// Checks: current user → "You", current opponent → route.title, any known dialog → title (cached).
|
||||
/// Falls back to truncated public key if unknown.
|
||||
/// Android parity: binary check (opponent → name, else → "You") + DialogRepository fallback.
|
||||
/// Key prefix fallbacks are NOT cached — allows re-resolution when name arrives via sync/search.
|
||||
private func senderDisplayName(for publicKey: String) -> String {
|
||||
if publicKey == currentPublicKey {
|
||||
return "You"
|
||||
}
|
||||
// Current chat opponent — use route (non-observable, stable).
|
||||
if publicKey == route.publicKey {
|
||||
return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title
|
||||
}
|
||||
// PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs
|
||||
// in the per-cell render path. Cache is populated once per contact, valid for session.
|
||||
if let cached = Self.senderNameCache[publicKey] {
|
||||
return cached
|
||||
}
|
||||
// Current chat opponent — try route.title first (stable, non-observable).
|
||||
if publicKey == route.publicKey && !route.title.isEmpty {
|
||||
Self.senderNameCache[publicKey] = route.title
|
||||
return route.title
|
||||
}
|
||||
// Live lookup from DialogRepository (only on cache miss, result is cached).
|
||||
// Covers: opponent with empty route.title, and any other known contact.
|
||||
if let dialog = DialogRepository.shared.dialogs[publicKey],
|
||||
!dialog.opponentTitle.isEmpty {
|
||||
Self.senderNameCache[publicKey] = dialog.opponentTitle
|
||||
return dialog.opponentTitle
|
||||
}
|
||||
let fallback = String(publicKey.prefix(8)) + "…"
|
||||
Self.senderNameCache[publicKey] = fallback
|
||||
return fallback
|
||||
// Try username for current opponent.
|
||||
if publicKey == route.publicKey && !route.username.isEmpty {
|
||||
let name = "@\(route.username)"
|
||||
Self.senderNameCache[publicKey] = name
|
||||
return name
|
||||
}
|
||||
// Fallback: truncated key. NOT cached — re-resolution on next render when name arrives.
|
||||
return String(publicKey.prefix(8)) + "…"
|
||||
}
|
||||
|
||||
/// PERF: single-pass partition of attachments into image vs non-image.
|
||||
@@ -1210,6 +1263,33 @@ private extension ChatDetailView {
|
||||
return (images, others)
|
||||
}
|
||||
|
||||
/// Check if text is a valid caption (not garbage, not just space).
|
||||
/// Avatar messages have text=" ", which should NOT be shown as a caption.
|
||||
/// Decryption failures may produce U+FFFD replacement characters.
|
||||
private static func isValidCaption(_ text: String) -> Bool {
|
||||
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.isEmpty { return false }
|
||||
if text == " " { return false }
|
||||
// Filter garbage: text containing ONLY replacement characters / control chars
|
||||
if isGarbageText(text) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Detect garbage text from failed decryption — U+FFFD, control characters, null bytes.
|
||||
/// Messages with only these characters should be hidden, not shown as text bubbles.
|
||||
private static func isGarbageText(_ text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return true }
|
||||
// Check if ALL characters are garbage (replacement char, control, null)
|
||||
let validCharacters = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.value != 0xFFFD && // U+FFFD replacement character
|
||||
scalar.value > 0x1F && // Control characters (0x00-0x1F)
|
||||
scalar.value != 0x7F && // DEL
|
||||
!CharacterSet.controlCharacters.contains(scalar)
|
||||
}
|
||||
return validCharacters.isEmpty
|
||||
}
|
||||
|
||||
/// Attachment message bubble: images/files with optional text caption.
|
||||
///
|
||||
/// Telegram-style layout:
|
||||
@@ -1225,7 +1305,7 @@ private extension ChatDetailView {
|
||||
maxBubbleWidth: CGFloat,
|
||||
position: BubblePosition
|
||||
) -> some View {
|
||||
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
|
||||
let hasCaption = Self.isValidCaption(message.text)
|
||||
// PERF: single-pass partition instead of 3 separate .filter() calls per cell.
|
||||
let partitioned = Self.partitionAttachments(attachments)
|
||||
let imageAttachments = partitioned.images
|
||||
@@ -1685,6 +1765,7 @@ private extension ChatDetailView {
|
||||
/// For Saved Messages and system accounts — no profile to show.
|
||||
func openProfile() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
isInputFocused = false
|
||||
showOpponentProfile = true
|
||||
}
|
||||
|
||||
@@ -1810,7 +1891,7 @@ private extension ChatDetailView {
|
||||
.frame(maxWidth: maxBubbleWidth)
|
||||
} else {
|
||||
// Attachment preview — reuse full bubble, clip to shape
|
||||
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
|
||||
let hasCaption = Self.isValidCaption(message.text)
|
||||
let imageAttachments = visibleAttachments.filter { $0.type == .image }
|
||||
let otherAttachments = visibleAttachments.filter { $0.type != .image }
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
@@ -2026,12 +2107,7 @@ private extension ChatDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
// PERF: use route.title (non-observable) instead of dialog?.opponentTitle.
|
||||
// Reading `dialog` here creates @Observable tracking on DialogRepository in the
|
||||
// composer's render path, which is part of the main body.
|
||||
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
? "You"
|
||||
: (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "…" : route.title)
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let previewText: String = {
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
@@ -2091,6 +2167,18 @@ private extension ChatDetailView {
|
||||
// MARK: - Forward
|
||||
|
||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
#if DEBUG
|
||||
print("═══════════════════════════════════════════════")
|
||||
print("📤 FORWARD START")
|
||||
print("📤 Original message: id=\(message.id.prefix(16)), text='\(message.text.prefix(30))'")
|
||||
print("📤 Original attachments (\(message.attachments.count)):")
|
||||
for att in message.attachments {
|
||||
print("📤 - type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))' blob=\(att.blob.isEmpty ? "(empty)" : "(\(att.blob.count) chars, starts: \(att.blob.prefix(30)))")")
|
||||
}
|
||||
print("📤 Attachment password: \(message.attachmentPassword?.prefix(20) ?? "nil")")
|
||||
print("📤 Target: \(targetRoute.publicKey.prefix(16))")
|
||||
#endif
|
||||
|
||||
// Android parity: unwrap nested forwards.
|
||||
// If the message being forwarded is itself a forward, extract the inner
|
||||
// forwarded messages and re-forward them directly (flatten).
|
||||
@@ -2099,54 +2187,262 @@ private extension ChatDetailView {
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
#if DEBUG
|
||||
if let att = replyAttachment {
|
||||
let blobParsed = parseReplyBlob(att.blob)
|
||||
let previewParsed = parseReplyBlob(att.preview)
|
||||
print("📤 Unwrap check: isForward=\(isForward)")
|
||||
print("📤 blob parse: \(blobParsed == nil ? "FAILED" : "OK (\(blobParsed!.count) msgs, atts: \(blobParsed!.map { $0.attachments.count }))")")
|
||||
print("📤 preview parse: \(previewParsed == nil ? "FAILED (preview='\(att.preview.prefix(20))')" : "OK (\(previewParsed!.count) msgs)")")
|
||||
}
|
||||
#endif
|
||||
|
||||
if isForward,
|
||||
let att = replyAttachment,
|
||||
let innerMessages = parseReplyBlob(att.blob),
|
||||
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
|
||||
!innerMessages.isEmpty {
|
||||
// Unwrap: forward the original messages, not the wrapper
|
||||
forwardDataList = innerMessages
|
||||
#if DEBUG
|
||||
print("📤 ✅ UNWRAP path: \(innerMessages.count) inner message(s)")
|
||||
for (i, msg) in innerMessages.enumerated() {
|
||||
print("📤 msg[\(i)]: publicKey=\(msg.publicKey.prefix(12)), text='\(msg.message.prefix(30))', attachments=\(msg.attachments.count)")
|
||||
for att in msg.attachments {
|
||||
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
// Regular message — forward as-is
|
||||
forwardDataList = [buildReplyData(from: message)]
|
||||
}
|
||||
|
||||
// Android parity: collect cached images for re-upload.
|
||||
// Android re-encrypts + re-uploads each photo with the new message key.
|
||||
// Without this, Desktop tries to decrypt CDN blob with the wrong key.
|
||||
var forwardedImages: [String: Data] = [:]
|
||||
for replyData in forwardDataList {
|
||||
for att in replyData.attachments where att.type == AttachmentType.image.rawValue {
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.85) {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#if DEBUG
|
||||
print("📤 Forward: collected image \(att.id) (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📤 Forward: image \(att.id) NOT in cache — will skip re-upload")
|
||||
#endif
|
||||
#if DEBUG
|
||||
print("📤 ⚠️ BUILD_REPLY_DATA path (unwrap failed or not a forward)")
|
||||
if let first = forwardDataList.first {
|
||||
print("📤 result: publicKey=\(first.publicKey.prefix(12)), text='\(first.message.prefix(30))', attachments=\(first.attachments.count)")
|
||||
for att in first.attachments {
|
||||
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Collect attachment password for CDN downloads of uncached images.
|
||||
let storedPassword = message.attachmentPassword
|
||||
|
||||
let targetKey = targetRoute.publicKey
|
||||
let targetTitle = targetRoute.title
|
||||
let targetUsername = targetRoute.username
|
||||
|
||||
Task { @MainActor in
|
||||
// Android parity: collect cached images for re-upload.
|
||||
// Android re-encrypts + re-uploads each photo with the new message key.
|
||||
// Without this, Desktop tries to decrypt CDN blob with the wrong key.
|
||||
var forwardedImages: [String: Data] = [:]
|
||||
var forwardedFiles: [String: (data: Data, fileName: String)] = [:]
|
||||
|
||||
for replyData in forwardDataList {
|
||||
for att in replyData.attachments {
|
||||
if att.type == AttachmentType.image.rawValue {
|
||||
// ── Image re-upload ──
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.85) {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
|
||||
// Not in cache — download from CDN, decrypt, then include.
|
||||
let cdnTag = att.preview.components(separatedBy: "::").first ?? ""
|
||||
guard !cdnTag.isEmpty else {
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
let password = storedPassword ?? ""
|
||||
guard !password.isEmpty else {
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): SKIP — no attachment password")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
|
||||
#endif
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
|
||||
|
||||
if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords),
|
||||
let jpegData = img.jpegData(compressionQuality: 0.85) {
|
||||
forwardedImages[att.id] = jpegData
|
||||
AttachmentCache.shared.saveImage(img, forAttachmentId: att.id)
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): CDN download+decrypt OK (\(jpegData.count) bytes)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes, \(passwords.count) candidates)")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("📤 Image \(att.id.prefix(16)): CDN download FAILED: \(error)")
|
||||
#endif
|
||||
}
|
||||
|
||||
} else if att.type == AttachmentType.file.rawValue {
|
||||
// ── File re-upload (Desktop parity: prepareAttachmentsToSend) ──
|
||||
let parts = att.preview.components(separatedBy: "::")
|
||||
let fileName = parts.count > 2 ? parts[2] : "file"
|
||||
|
||||
// Try local cache first
|
||||
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
|
||||
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): loaded from cache (\(fileData.count) bytes, name=\(fileName))")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
|
||||
// Not in cache — download from CDN, decrypt
|
||||
let cdnTag = parts.first ?? ""
|
||||
guard !cdnTag.isEmpty else {
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
let password = storedPassword ?? ""
|
||||
guard !password.isEmpty else {
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): SKIP — no attachment password")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
|
||||
#endif
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
|
||||
|
||||
if let fileData = Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) {
|
||||
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes)")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("📤 File \(att.id.prefix(16)): CDN download FAILED: \(error)")
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📤 Attachment \(att.id.prefix(16)): SKIP — type=\(att.type)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("📤 ── SEND SUMMARY ──")
|
||||
print("📤 forwardDataList: \(forwardDataList.count) message(s)")
|
||||
for (i, msg) in forwardDataList.enumerated() {
|
||||
print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) (images: \(msg.attachments.filter { $0.type == 0 }.count), files: \(msg.attachments.filter { $0.type == 2 }.count))")
|
||||
}
|
||||
print("📤 forwardedImages: \(forwardedImages.count) re-uploads")
|
||||
print("📤 forwardedFiles: \(forwardedFiles.count) re-uploads")
|
||||
#endif
|
||||
|
||||
do {
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: " ",
|
||||
text: "",
|
||||
replyMessages: forwardDataList,
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetRoute.title,
|
||||
opponentUsername: targetRoute.username,
|
||||
forwardedImages: forwardedImages
|
||||
opponentTitle: targetTitle,
|
||||
opponentUsername: targetUsername,
|
||||
forwardedImages: forwardedImages,
|
||||
forwardedFiles: forwardedFiles
|
||||
)
|
||||
#if DEBUG
|
||||
print("📤 ✅ FORWARD SENT OK")
|
||||
print("═══════════════════════════════════════════════")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("📤 ❌ FORWARD FAILED: \(error)")
|
||||
print("═══════════════════════════════════════════════")
|
||||
#endif
|
||||
sendError = "Failed to forward message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a CDN-downloaded image blob with multiple password candidates.
|
||||
private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
|
||||
let img = parseForwardImageData(data) { return img }
|
||||
}
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
|
||||
let img = parseForwardImageData(data) { return img }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseForwardImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8),
|
||||
str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||
if let imageData = Data(base64Encoded: base64Part) {
|
||||
return UIImage(data: imageData)
|
||||
}
|
||||
}
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
/// Decrypt a CDN-downloaded file blob with multiple password candidates.
|
||||
/// Returns raw file data (extracted from data URI).
|
||||
private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
|
||||
let fileData = parseForwardFileData(data) { return fileData }
|
||||
}
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
|
||||
let fileData = parseForwardFileData(data) { return fileData }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}").
|
||||
private static func parseForwardFileData(_ data: Data) -> Data? {
|
||||
if let str = String(data: data, encoding: .utf8),
|
||||
str.hasPrefix("data:"),
|
||||
let commaIndex = str.firstIndex(of: ",") {
|
||||
let base64Part = String(str[str.index(after: commaIndex)...])
|
||||
return Data(base64Encoded: base64Part)
|
||||
}
|
||||
// Not a data URI — return raw data
|
||||
return data
|
||||
}
|
||||
|
||||
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
|
||||
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
|
||||
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
|
||||
@@ -2162,10 +2458,26 @@ private extension ChatDetailView {
|
||||
)
|
||||
}
|
||||
|
||||
// If no non-messages attachments but has a .messages attachment (forward),
|
||||
// try to extract the inner message's data to preserve photos/files.
|
||||
// This handles the case where forwardMessage() unwrap failed but the blob is actually parseable.
|
||||
if replyAttachments.isEmpty,
|
||||
let msgAtt = message.attachments.first(where: { $0.type == .messages }),
|
||||
let innerMessages = parseReplyBlob(msgAtt.blob),
|
||||
let firstInner = innerMessages.first {
|
||||
#if DEBUG
|
||||
print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments")
|
||||
#endif
|
||||
return firstInner
|
||||
}
|
||||
|
||||
// Filter garbage text (U+FFFD from failed decryption) — don't send ciphertext/garbage to recipient.
|
||||
let cleanText = Self.isGarbageText(message.text) ? "" : message.text
|
||||
|
||||
return ReplyMessageData(
|
||||
message_id: message.id,
|
||||
publicKey: message.fromPublicKey,
|
||||
message: message.text,
|
||||
message: cleanText,
|
||||
timestamp: message.timestamp,
|
||||
attachments: replyAttachments
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
|
||||
@Published private(set) var messages: [ChatMessage] = []
|
||||
@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
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -21,8 +23,12 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
let repo = MessageRepository.shared
|
||||
|
||||
// Seed with current values
|
||||
messages = repo.messages(for: dialogKey)
|
||||
let initial = repo.messages(for: dialogKey)
|
||||
messages = initial
|
||||
isTyping = repo.isTyping(dialogKey: dialogKey)
|
||||
// Android parity: if we already have messages, skip skeleton.
|
||||
// Otherwise keep isLoading=true until first Combine emission or timeout.
|
||||
isLoading = initial.isEmpty
|
||||
|
||||
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
||||
// Broken into steps to help the Swift type-checker.
|
||||
@@ -48,6 +54,9 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
.sink { [weak self] newMessages in
|
||||
PerformanceLogger.shared.track("chatDetail.messagesEmit")
|
||||
self?.messages = newMessages
|
||||
if self?.isLoading == true {
|
||||
self?.isLoading = false
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
||||
@@ -4,18 +4,15 @@ import SwiftUI
|
||||
|
||||
/// Displays an image attachment inside a message bubble.
|
||||
///
|
||||
/// Desktop parity: `MessageImage.tsx` — shows blur placeholder while downloading,
|
||||
/// full image after download, "Image expired" on error.
|
||||
///
|
||||
/// Modes:
|
||||
/// - **Standalone** (`collageSize == nil`): uses own min/max constraints + aspect ratio.
|
||||
/// - **Collage cell** (`collageSize != nil`): fills the given frame (parent controls size).
|
||||
/// Android parity: `AttachmentComponents.kt` — blurhash placeholder, download button,
|
||||
/// spinner overlay, upload spinner for outgoing photos, error retry button.
|
||||
///
|
||||
/// States:
|
||||
/// 1. **Cached** — image already in AttachmentCache, display immediately
|
||||
/// 2. **Downloading** — show blurhash placeholder + spinner
|
||||
/// 3. **Downloaded** — display image, tap for full-screen (future)
|
||||
/// 4. **Error** — "Image expired" or download error
|
||||
/// 1. **Cached + Delivered** — full image, no overlay
|
||||
/// 2. **Cached + Uploading** — full image + dark overlay + upload spinner (outgoing only)
|
||||
/// 3. **Downloading** — blurhash + dark overlay + spinner
|
||||
/// 4. **Not Downloaded** — blurhash + dark overlay + download arrow button
|
||||
/// 5. **Error** — blurhash + dark overlay + red retry button
|
||||
struct MessageImageView: View {
|
||||
|
||||
let attachment: MessageAttachment
|
||||
@@ -23,12 +20,10 @@ struct MessageImageView: View {
|
||||
let outgoing: Bool
|
||||
|
||||
/// When set, the image fills this exact frame (used inside PhotoCollageView).
|
||||
/// When nil, standalone mode with own size constraints.
|
||||
var collageSize: CGSize? = nil
|
||||
|
||||
let maxWidth: CGFloat
|
||||
|
||||
/// Called when user taps a loaded image (opens full-screen viewer).
|
||||
var onImageTap: ((UIImage) -> Void)?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@@ -36,44 +31,46 @@ struct MessageImageView: View {
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadError = false
|
||||
|
||||
/// Whether this image is inside a collage (fills parent frame).
|
||||
private var isCollageCell: Bool { collageSize != nil }
|
||||
|
||||
/// Telegram-style image constraints (standalone mode only).
|
||||
// Telegram-style constraints (standalone mode)
|
||||
private let maxImageWidth: CGFloat = 270
|
||||
private let maxImageHeight: CGFloat = 320
|
||||
private let minImageWidth: CGFloat = 140
|
||||
private let minImageHeight: CGFloat = 100
|
||||
|
||||
/// Default placeholder size (standalone mode).
|
||||
private let placeholderWidth: CGFloat = 200
|
||||
private let placeholderHeight: CGFloat = 200
|
||||
|
||||
/// Android parity: outgoing photo is "uploading" when cached but not yet delivered.
|
||||
private var isUploading: Bool {
|
||||
outgoing && image != nil && message.deliveryStatus == .waiting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ZStack {
|
||||
if let image {
|
||||
imageContent(image)
|
||||
} else if isDownloading {
|
||||
placeholderView
|
||||
.overlay { downloadingOverlay }
|
||||
} else if downloadError {
|
||||
placeholderView
|
||||
.overlay { errorOverlay }
|
||||
} else {
|
||||
placeholderView
|
||||
.overlay { downloadArrowOverlay }
|
||||
}
|
||||
|
||||
// Android parity: full-image dark overlay + centered control
|
||||
if isUploading {
|
||||
uploadingOverlay
|
||||
} else if isDownloading {
|
||||
downloadingOverlay
|
||||
} else if downloadError {
|
||||
errorOverlay
|
||||
} else if image == nil {
|
||||
downloadArrowOverlay
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// PERF: load cached image FIRST — skip expensive BlurHash DCT decode
|
||||
// if the full image is already available.
|
||||
loadFromCache()
|
||||
if image == nil {
|
||||
decodeBlurHash()
|
||||
}
|
||||
}
|
||||
// Download triggered by BubbleContextMenuOverlay tap → notification.
|
||||
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id, image == nil {
|
||||
downloadImage()
|
||||
@@ -81,46 +78,67 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlay States (Desktop parity: MessageImage.tsx)
|
||||
// MARK: - Android-Style Overlays
|
||||
|
||||
/// Desktop: dark 40x40 circle with ProgressView spinner.
|
||||
private var downloadingOverlay: some View {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop: dark rounded pill with "Image expired" + flame icon.
|
||||
private var errorOverlay: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("Image expired")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.white)
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.white)
|
||||
/// Android: outgoing photo uploading — 28dp spinner, 0.35 alpha overlay.
|
||||
private var uploadingOverlay: some View {
|
||||
overlayContainer(alpha: 0.35) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// Desktop: dark 40x40 circle with download arrow icon.
|
||||
private var downloadArrowOverlay: some View {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
/// Android: downloading — 36dp spinner, 0.3 alpha overlay.
|
||||
private var downloadingOverlay: some View {
|
||||
overlayContainer(alpha: 0.3) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.0)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
/// Android: error — red 48dp button with refresh icon + label.
|
||||
private var errorOverlay: some View {
|
||||
overlayContainer(alpha: 0.3) {
|
||||
VStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(red: 0.9, green: 0.22, blue: 0.21).opacity(0.8))
|
||||
.frame(width: 48, height: 48)
|
||||
.overlay {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Text("Error")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android: not downloaded — 48dp button with download arrow.
|
||||
private var downloadArrowOverlay: some View {
|
||||
overlayContainer(alpha: 0.3) {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
.frame(width: 48, height: 48)
|
||||
.overlay {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android parity: full-image semi-transparent dark overlay with centered content.
|
||||
private func overlayContainer<Content: View>(alpha: Double, @ViewBuilder content: () -> Content) -> some View {
|
||||
ZStack {
|
||||
Color.black.opacity(alpha)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Content
|
||||
@@ -128,7 +146,6 @@ struct MessageImageView: View {
|
||||
@ViewBuilder
|
||||
private func imageContent(_ img: UIImage) -> some View {
|
||||
if let size = collageSize {
|
||||
// Collage mode: fill the given cell frame
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
@@ -137,7 +154,6 @@ struct MessageImageView: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onImageTap?(img) }
|
||||
} else {
|
||||
// Standalone mode: respect aspect ratio constraints
|
||||
let size = constrainedSize(for: img)
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
@@ -149,10 +165,8 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
|
||||
private func constrainedSize(for img: UIImage) -> CGSize {
|
||||
let constrainedWidth = min(maxImageWidth, maxWidth)
|
||||
// Guard: zero-size images (corrupted or failed downsampling) use placeholder size.
|
||||
guard img.size.width > 0, img.size.height > 0 else {
|
||||
return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight)
|
||||
}
|
||||
@@ -181,21 +195,14 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder size: collage cell size if in collage, otherwise square default.
|
||||
private var resolvedPlaceholderSize: CGSize {
|
||||
if let size = collageSize {
|
||||
return size
|
||||
}
|
||||
if let size = collageSize { return size }
|
||||
let w = min(placeholderWidth, min(maxImageWidth, maxWidth))
|
||||
return CGSize(width: w, height: w)
|
||||
}
|
||||
|
||||
// MARK: - BlurHash Decoding
|
||||
|
||||
/// Decodes the blurhash from the attachment preview string once and caches in @State.
|
||||
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
|
||||
/// PERF: static cache for decoded BlurHash images — shared across all instances.
|
||||
/// Avoids redundant DCT decode when the same attachment appears in multiple re-renders.
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
private func decodeBlurHash() {
|
||||
@@ -246,8 +253,6 @@ struct MessageImageView: View {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
|
||||
// Try each password candidate; validate decrypted content to avoid false positives
|
||||
// from wrong-key AES-CBC that randomly produces valid PKCS7 + passable inflate.
|
||||
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
|
||||
let downloadedImage = decryptAndParseImage(
|
||||
encryptedString: encryptedString, passwords: passwords
|
||||
@@ -271,28 +276,23 @@ struct MessageImageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each password candidate and validates the decrypted content is a real image.
|
||||
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||
let crypto = CryptoManager.shared
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) else { continue }
|
||||
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
// Fallback: try without requireCompression (legacy uncompressed payloads)
|
||||
for password in passwords {
|
||||
guard let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) else { continue }
|
||||
|
||||
if let img = parseImageData(data) { return img }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
|
||||
private func parseImageData(_ data: Data) -> UIImage? {
|
||||
if let str = String(data: data, encoding: .utf8) {
|
||||
if str.hasPrefix("data:"),
|
||||
@@ -307,21 +307,16 @@ struct MessageImageView: View {
|
||||
return img
|
||||
}
|
||||
}
|
||||
// Raw image data
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
// MARK: - Preview Parsing
|
||||
|
||||
/// Extracts the server tag from preview string.
|
||||
/// Format: "tag::blurhash" or "tag::" → returns "tag".
|
||||
private func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
}
|
||||
|
||||
/// Extracts the blurhash from preview string.
|
||||
/// Format: "tag::blurhash" → returns "blurhash".
|
||||
private func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
|
||||
@@ -219,26 +219,15 @@ struct OpponentProfileView: View {
|
||||
|
||||
// MARK: - Glass helpers
|
||||
|
||||
@ViewBuilder
|
||||
// Use TelegramGlass* UIViewRepresentable for ALL iOS versions.
|
||||
// SwiftUI .glassEffect() creates UIKit containers that intercept taps
|
||||
// even with .allowsHitTesting(false) — breaks back button.
|
||||
|
||||
private func glassCapsule() -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func glassCard() -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.clear)
|
||||
.glassEffect(
|
||||
.regular,
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
} else {
|
||||
TelegramGlassRoundedRect(cornerRadius: 14)
|
||||
}
|
||||
TelegramGlassRoundedRect(cornerRadius: 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +568,11 @@ private struct ChatListDialogContent: View {
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
|
||||
// CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking.
|
||||
// Without this, ChatListDialogContent only observes viewModel (ObservableObject)
|
||||
// which never publishes objectWillChange for dialog mutations.
|
||||
// The read forces SwiftUI to re-evaluate body when dialogs dict changes.
|
||||
let _ = DialogRepository.shared.dialogs.count
|
||||
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
|
||||
let pinned = viewModel.allModePinned
|
||||
let unpinned = viewModel.allModeUnpinned
|
||||
@@ -636,6 +641,7 @@ private struct ChatListDialogContent: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.scrollIndicators(.hidden)
|
||||
.modifier(ClassicSwipeActionsModifier())
|
||||
// Scroll-to-top: tap "Chats" in toolbar
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
|
||||
// Scroll to first dialog ID (pinned or unpinned)
|
||||
@@ -712,7 +718,7 @@ struct SyncAwareChatRow: View {
|
||||
|
||||
if !dialog.isSavedMessages {
|
||||
Button {
|
||||
viewModel.toggleMute(dialog)
|
||||
withAnimation { viewModel.toggleMute(dialog) }
|
||||
} label: {
|
||||
Label(
|
||||
dialog.isMuted ? "Unmute" : "Mute",
|
||||
@@ -724,7 +730,7 @@ struct SyncAwareChatRow: View {
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
viewModel.togglePin(dialog)
|
||||
withAnimation { viewModel.togglePin(dialog) }
|
||||
} label: {
|
||||
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
@@ -777,6 +783,22 @@ private struct DeviceApprovalBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS 26+ Classic Swipe Actions
|
||||
|
||||
/// iOS 26: disable Liquid Glass on the List so swipe action buttons use
|
||||
/// solid colors (same as iOS < 26). Uses UIAppearance override.
|
||||
private struct ClassicSwipeActionsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear {
|
||||
if #available(iOS 26, *) {
|
||||
// Disable glass on UITableView-backed List swipe actions.
|
||||
let appearance = UITableView.appearance()
|
||||
appearance.backgroundColor = .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Chat List") {
|
||||
ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false))
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
@@ -141,7 +141,7 @@ private extension ChatRowView {
|
||||
if isTyping && !dialog.isSavedMessages {
|
||||
return "typing..."
|
||||
}
|
||||
if dialog.lastMessage.isEmpty {
|
||||
if dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
||||
@@ -195,11 +195,11 @@ private extension ChatRowView {
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
|
||||
// Desktop parity: delivery icon and unread badge are
|
||||
// mutually exclusive — badge hidden when lastMessageFromMe.
|
||||
// Also hidden during sync (desktop hides badges while
|
||||
// protocolState == SYNCHRONIZATION).
|
||||
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe && !isSyncing {
|
||||
// Show unread badge whenever there are unread messages.
|
||||
// Previously hidden when lastMessageFromMe (desktop parity),
|
||||
// but this caused invisible unreads when user sent a reply
|
||||
// without reading prior incoming messages first.
|
||||
if dialog.unreadCount > 0 && !isSyncing {
|
||||
unreadBadge
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,13 +71,9 @@ struct RequestChatsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
// Use TelegramGlass* for ALL iOS versions — SwiftUI .glassEffect() blocks touches.
|
||||
private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
|
||||
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
|
||||
|
||||
Reference in New Issue
Block a user