Фикс: имя файла в пересланных сообщениях, потеря фоток/файлов при пересылке forwarded-сообщений, Фоллбэк при unwrap forwarded-сообщения, защита БД от перезаписи синком

This commit is contained in:
2026-03-21 20:28:11 +05:00
parent 224b8a2b54
commit 65e5991f97
24 changed files with 2715 additions and 1037 deletions

View 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)
)
}
}

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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] : ""

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 {