Пересылка фото: перешифровка + загрузка на CDN, коллаж для пересланных фото, открытие в просмотрщике
This commit is contained in:
@@ -417,7 +417,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -433,7 +433,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -456,7 +456,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -472,7 +472,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -130,7 +130,11 @@ enum MessageCrypto {
|
||||
|
||||
/// Android parity helper:
|
||||
/// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`.
|
||||
/// Emulates Android's `String(bytes, UTF_8).toByteArray(ISO_8859_1)` round-trip.
|
||||
/// Uses BOTH WHATWG and Android UTF-8 decoders — returns candidates for each.
|
||||
/// WHATWG and Android decoders handle invalid UTF-8 differently → different bytes.
|
||||
static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data {
|
||||
// Primary: WHATWG decoder (matches Java's Modified UTF-8 for most cases)
|
||||
let decoded = String(decoding: utf8Bytes, as: UTF8.self)
|
||||
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
||||
return latin1
|
||||
@@ -138,6 +142,15 @@ enum MessageCrypto {
|
||||
return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") })
|
||||
}
|
||||
|
||||
/// Alternative key recovery using Android UTF-8 decoder.
|
||||
static func androidUtf8BytesToLatin1BytesAlt(_ utf8Bytes: Data) -> Data {
|
||||
let decoded = bytesToAndroidUtf8String(utf8Bytes)
|
||||
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
||||
return latin1
|
||||
}
|
||||
return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") })
|
||||
}
|
||||
|
||||
// MARK: - Attachment Password Helpers
|
||||
|
||||
/// Returns password candidates from a stored attachment password string.
|
||||
@@ -176,122 +189,83 @@ enum MessageCrypto {
|
||||
///
|
||||
/// Used for PBKDF2 password derivation from random key+nonce bytes
|
||||
/// to match Android's attachment encryption.
|
||||
/// Exact port of Android's `bytesToBufferPolyfillUtf8String()` from MessageCrypto.kt:1419-1502.
|
||||
/// Uses feross/buffer npm polyfill UTF-8 decoding semantics.
|
||||
/// Key difference from previous implementation: on invalid sequence, ALWAYS advance by 1 byte
|
||||
/// and emit 1× U+FFFD (not variable bytes/U+FFFD count).
|
||||
static func bytesToAndroidUtf8String(_ bytes: Data) -> String {
|
||||
var result = ""
|
||||
result.reserveCapacity(bytes.count)
|
||||
var i = bytes.startIndex
|
||||
var codePoints: [Int] = []
|
||||
codePoints.reserveCapacity(bytes.count)
|
||||
var index = 0
|
||||
let end = bytes.count
|
||||
|
||||
while i < bytes.endIndex {
|
||||
let b0 = Int(bytes[i])
|
||||
while index < end {
|
||||
let firstByte = Int(bytes[index]) & 0xFF
|
||||
var codePoint: Int? = nil
|
||||
var bytesPerSequence: Int
|
||||
|
||||
if b0 <= 0x7F {
|
||||
// ASCII
|
||||
result.append(Character(UnicodeScalar(b0)!))
|
||||
i += 1
|
||||
} else if b0 <= 0xBF {
|
||||
// Orphan continuation byte
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
} else if b0 <= 0xDF {
|
||||
// 2-byte sequence
|
||||
if i + 1 >= bytes.endIndex {
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
} else {
|
||||
let b1 = Int(bytes[i + 1])
|
||||
if b1 & 0xC0 != 0x80 {
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
} else {
|
||||
let cp = ((b0 & 0x1F) << 6) | (b1 & 0x3F)
|
||||
if cp < 0x80 || b0 == 0xC0 || b0 == 0xC1 {
|
||||
// Overlong
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
} else {
|
||||
result.append(Character(UnicodeScalar(cp)!))
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
} else if b0 <= 0xEF {
|
||||
// 3-byte sequence
|
||||
if i + 2 >= bytes.endIndex {
|
||||
let remaining = bytes.endIndex - i
|
||||
for _ in 0..<remaining { result.append("\u{FFFD}") }
|
||||
i = bytes.endIndex
|
||||
} else {
|
||||
let b1 = Int(bytes[i + 1])
|
||||
let b2 = Int(bytes[i + 2])
|
||||
|
||||
if b1 & 0xC0 != 0x80 {
|
||||
// Invalid first continuation
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
} else if b2 & 0xC0 != 0x80 {
|
||||
// Invalid second continuation — emit for first two bytes
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
i += 2
|
||||
} else {
|
||||
let cp = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F)
|
||||
if cp < 0x800 || (cp >= 0xD800 && cp <= 0xDFFF) {
|
||||
// Overlong or surrogate
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
} else {
|
||||
result.append(Character(UnicodeScalar(cp)!))
|
||||
}
|
||||
i += 3
|
||||
}
|
||||
}
|
||||
} else if b0 <= 0xF7 {
|
||||
// 4-byte sequence
|
||||
if i + 3 >= bytes.endIndex {
|
||||
let remaining = bytes.endIndex - i
|
||||
for _ in 0..<remaining { result.append("\u{FFFD}") }
|
||||
i = bytes.endIndex
|
||||
} else {
|
||||
let b1 = Int(bytes[i + 1])
|
||||
let b2 = Int(bytes[i + 2])
|
||||
let b3 = Int(bytes[i + 3])
|
||||
|
||||
if b1 & 0xC0 != 0x80 {
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
} else if b2 & 0xC0 != 0x80 {
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
i += 2
|
||||
} else if b3 & 0xC0 != 0x80 {
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
i += 3
|
||||
} else {
|
||||
let cp = ((b0 & 0x07) << 18) | ((b1 & 0x3F) << 12)
|
||||
| ((b2 & 0x3F) << 6) | (b3 & 0x3F)
|
||||
if cp < 0x10000 || cp > 0x10FFFF {
|
||||
// Overlong or out-of-range
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
result.append("\u{FFFD}")
|
||||
} else {
|
||||
result.append(Character(UnicodeScalar(cp)!))
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
if firstByte > 0xEF {
|
||||
bytesPerSequence = 4
|
||||
} else if firstByte > 0xDF {
|
||||
bytesPerSequence = 3
|
||||
} else if firstByte > 0xBF {
|
||||
bytesPerSequence = 2
|
||||
} else {
|
||||
// Invalid starter byte (0xF8-0xFF)
|
||||
result.append("\u{FFFD}")
|
||||
i += 1
|
||||
bytesPerSequence = 1
|
||||
}
|
||||
|
||||
if index + bytesPerSequence <= end {
|
||||
switch bytesPerSequence {
|
||||
case 1:
|
||||
if firstByte < 0x80 {
|
||||
codePoint = firstByte
|
||||
}
|
||||
case 2:
|
||||
let secondByte = Int(bytes[index + 1]) & 0xFF
|
||||
if (secondByte & 0xC0) == 0x80 {
|
||||
let temp = ((firstByte & 0x1F) << 6) | (secondByte & 0x3F)
|
||||
if temp > 0x7F {
|
||||
codePoint = temp
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
let secondByte = Int(bytes[index + 1]) & 0xFF
|
||||
let thirdByte = Int(bytes[index + 2]) & 0xFF
|
||||
if (secondByte & 0xC0) == 0x80 && (thirdByte & 0xC0) == 0x80 {
|
||||
let temp = ((firstByte & 0x0F) << 12) | ((secondByte & 0x3F) << 6) | (thirdByte & 0x3F)
|
||||
if temp > 0x7FF && (temp < 0xD800 || temp > 0xDFFF) {
|
||||
codePoint = temp
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
let secondByte = Int(bytes[index + 1]) & 0xFF
|
||||
let thirdByte = Int(bytes[index + 2]) & 0xFF
|
||||
let fourthByte = Int(bytes[index + 3]) & 0xFF
|
||||
if (secondByte & 0xC0) == 0x80 && (thirdByte & 0xC0) == 0x80 && (fourthByte & 0xC0) == 0x80 {
|
||||
let temp = ((firstByte & 0x0F) << 18) | ((secondByte & 0x3F) << 12) | ((thirdByte & 0x3F) << 6) | (fourthByte & 0x3F)
|
||||
if temp > 0xFFFF && temp < 0x110000 {
|
||||
codePoint = temp
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if codePoint == nil {
|
||||
codePoint = 0xFFFD
|
||||
bytesPerSequence = 1
|
||||
} else if codePoint! > 0xFFFF {
|
||||
let adjusted = codePoint! - 0x10000
|
||||
codePoints.append(((adjusted >> 10) & 0x3FF) | 0xD800)
|
||||
codePoint = 0xDC00 | (adjusted & 0x3FF)
|
||||
}
|
||||
|
||||
codePoints.append(codePoint!)
|
||||
index += bytesPerSequence
|
||||
}
|
||||
|
||||
return result
|
||||
return String(codePoints.map { Character(UnicodeScalar($0 < 0xD800 || $0 > 0xDFFF ? $0 : 0xFFFD)!) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,11 +346,16 @@ private extension MessageCrypto {
|
||||
}
|
||||
|
||||
// Android parity: String(bytes, UTF_8) + toByteArray(ISO_8859_1)
|
||||
// Try BOTH WHATWG and Android UTF-8 decoders — they handle invalid
|
||||
// sequences differently, producing different raw key bytes.
|
||||
let originalBytes = androidUtf8BytesToLatin1Bytes(decryptedBytes)
|
||||
guard originalBytes.count >= 56 else { continue }
|
||||
if seen.insert(originalBytes).inserted {
|
||||
if originalBytes.count >= 56, seen.insert(originalBytes).inserted {
|
||||
candidates.append(originalBytes)
|
||||
}
|
||||
let altBytes = androidUtf8BytesToLatin1BytesAlt(decryptedBytes)
|
||||
if altBytes.count >= 56, altBytes != originalBytes, seen.insert(altBytes).inserted {
|
||||
candidates.append(altBytes)
|
||||
}
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else {
|
||||
|
||||
@@ -369,6 +369,18 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Attachment Update (post-upload)
|
||||
|
||||
/// Updates attachment previews after CDN upload completes.
|
||||
/// Called from sendMessageWithAttachments after optimistic message was shown.
|
||||
func updateAttachments(messageId: String, attachments: [MessageAttachment]) {
|
||||
guard let dialogKey = messageToDialog[messageId] else { return }
|
||||
updateMessages(for: dialogKey) { messages in
|
||||
guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return }
|
||||
messages[index].attachments = attachments
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stress Test (Debug only)
|
||||
|
||||
/// Inserts a pre-built message for stress testing. Skips encryption/dedup.
|
||||
|
||||
@@ -229,7 +229,10 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Attachment password: Android-style UTF-8 decoder (1:1 parity with Android).
|
||||
// Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
|
||||
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
|
||||
// Node.js Buffer.toString('utf-8') ≈ feross/buffer polyfill ≈ bytesToAndroidUtf8String.
|
||||
// WHATWG TextDecoder differs for ~47% of random 56-byte keys.
|
||||
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
|
||||
@@ -365,10 +368,10 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity.
|
||||
// Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in
|
||||
// failed multi-byte sequences. Desktop WHATWG is slightly different but both work
|
||||
// because Desktop tries both variants when decrypting.
|
||||
// Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
|
||||
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
|
||||
// Node.js Buffer.toString('utf-8') ≈ feross/buffer polyfill ≈ bytesToAndroidUtf8String.
|
||||
// WHATWG TextDecoder differs for ~47% of random 56-byte keys.
|
||||
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
#if DEBUG
|
||||
@@ -430,46 +433,24 @@ final class SessionManager {
|
||||
))
|
||||
}
|
||||
|
||||
// Android parity: cache original images BEFORE upload so they display
|
||||
// instantly in the chat bubble. Without this, photo doesn't appear until
|
||||
// upload completes (can take seconds on slow connection).
|
||||
// Android parity: cache original images and show optimistic message BEFORE upload.
|
||||
// Android: addMessageSafely(optimisticMessage) → then background upload.
|
||||
// Without this, photo doesn't appear until upload completes (5-10 seconds).
|
||||
for item in encryptedAttachments {
|
||||
if item.original.type == .image, let image = UIImage(data: item.original.data) {
|
||||
AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||
of: (Int, String).self
|
||||
) { group in
|
||||
for (index, item) in encryptedAttachments.enumerated() {
|
||||
group.addTask {
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: item.original.id,
|
||||
content: item.encryptedData
|
||||
)
|
||||
return (index, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results, preserving original order.
|
||||
var tags = [Int: String]()
|
||||
for try await (index, tag) in group {
|
||||
tags[index] = tag
|
||||
}
|
||||
|
||||
return encryptedAttachments.enumerated().map { index, item in
|
||||
let tag = tags[index] ?? ""
|
||||
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
|
||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
|
||||
return MessageAttachment(
|
||||
id: item.original.id,
|
||||
preview: preview,
|
||||
blob: "",
|
||||
type: item.original.type
|
||||
)
|
||||
}
|
||||
// Build placeholder attachments (no tag yet — filled after upload).
|
||||
// preview = "::blurhash" (no tag prefix), blob = "".
|
||||
let placeholderAttachments = encryptedAttachments.map { item in
|
||||
MessageAttachment(
|
||||
id: item.original.id,
|
||||
preview: item.preview.isEmpty ? "" : "::\(item.preview)",
|
||||
blob: "",
|
||||
type: item.original.type
|
||||
)
|
||||
}
|
||||
|
||||
// Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket).
|
||||
@@ -507,62 +488,78 @@ final class SessionManager {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Build PacketMessage with attachments
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
packet.content = encrypted.content
|
||||
packet.chachaKey = encrypted.chachaKey
|
||||
packet.timestamp = timestamp
|
||||
packet.privateKey = hash
|
||||
packet.messageId = messageId
|
||||
packet.aesChachaKey = aesChachaKey
|
||||
packet.attachments = messageAttachments
|
||||
// ── Optimistic UI: show message INSTANTLY with placeholder attachments ──
|
||||
// Android parity: addMessageSafely(optimisticMessage) before upload.
|
||||
var optimisticPacket = PacketMessage()
|
||||
optimisticPacket.fromPublicKey = currentPublicKey
|
||||
optimisticPacket.toPublicKey = toPublicKey
|
||||
optimisticPacket.content = encrypted.content
|
||||
optimisticPacket.chachaKey = encrypted.chachaKey
|
||||
optimisticPacket.timestamp = timestamp
|
||||
optimisticPacket.privateKey = hash
|
||||
optimisticPacket.messageId = messageId
|
||||
optimisticPacket.aesChachaKey = aesChachaKey
|
||||
optimisticPacket.attachments = placeholderAttachments
|
||||
|
||||
// Ensure dialog exists
|
||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
||||
DialogRepository.shared.ensureDialog(
|
||||
opponentKey: toPublicKey,
|
||||
title: title,
|
||||
username: username,
|
||||
myPublicKey: currentPublicKey
|
||||
opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey
|
||||
)
|
||||
|
||||
// Optimistic UI update
|
||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||
let offlineAsSend = !isConnected
|
||||
|
||||
// messageText is already trimmed — "" for no caption, triggers "Photo"/"File" in updateFromMessage.
|
||||
let displayText = messageText
|
||||
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||
)
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet,
|
||||
myPublicKey: currentPublicKey,
|
||||
decryptedText: displayText,
|
||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
||||
fromSync: offlineAsSend
|
||||
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
|
||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend
|
||||
)
|
||||
MessageRepository.shared.persistNow()
|
||||
|
||||
if offlineAsSend {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId, opponentKey: toPublicKey, status: .error
|
||||
)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||
}
|
||||
|
||||
// Saved Messages: local-only, no server send
|
||||
if toPublicKey == currentPublicKey {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(
|
||||
messageId: messageId, opponentKey: toPublicKey, status: .delivered
|
||||
)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Phase 2: Upload in background, then send packet ──
|
||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||
of: (Int, String).self
|
||||
) { group in
|
||||
for (index, item) in encryptedAttachments.enumerated() {
|
||||
group.addTask {
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: item.original.id, content: item.encryptedData
|
||||
)
|
||||
return (index, tag)
|
||||
}
|
||||
}
|
||||
var tags = [Int: String]()
|
||||
for try await (index, tag) in group { tags[index] = tag }
|
||||
return encryptedAttachments.enumerated().map { index, item in
|
||||
let tag = tags[index] ?? ""
|
||||
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
|
||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
|
||||
return MessageAttachment(id: item.original.id, preview: preview, blob: "", type: item.original.type)
|
||||
}
|
||||
}
|
||||
|
||||
// Update message with real attachment tags (preview with CDN tag)
|
||||
MessageRepository.shared.updateAttachments(messageId: messageId, attachments: messageAttachments)
|
||||
|
||||
// Build final packet for WebSocket send
|
||||
var packet = optimisticPacket
|
||||
packet.attachments = messageAttachments
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
@@ -613,7 +610,8 @@ final class SessionManager {
|
||||
replyMessages: [ReplyMessageData],
|
||||
toPublicKey: String,
|
||||
opponentTitle: String = "",
|
||||
opponentUsername: String = ""
|
||||
opponentUsername: String = "",
|
||||
forwardedImages: [String: Data] = [:] // [originalAttachmentId: jpegData]
|
||||
) async throws {
|
||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||
Self.logger.error("📤 Cannot send reply — missing keys")
|
||||
@@ -630,12 +628,96 @@ final class SessionManager {
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
// Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes.
|
||||
// Same as attachment password derivation.
|
||||
// Reply password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
|
||||
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
|
||||
let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📤 Reply password rawKey: \(encrypted.plainKeyAndNonce.hexString)")
|
||||
Self.logger.debug("📤 Reply password WHATWG UTF-8 (\(Array(replyPassword.utf8).count)b): \(Data(replyPassword.utf8).hexString)")
|
||||
#endif
|
||||
|
||||
// ── Android parity: re-upload forwarded photos to CDN ──
|
||||
// Android: ChatViewModel lines 2434-2477 — re-encrypts + uploads each photo.
|
||||
// Desktop: DialogProvider.tsx prepareAttachmentsToSend() — same pattern.
|
||||
// Without this, recipient tries to decrypt CDN blob with the wrong key.
|
||||
var attachmentIdMap: [String: (newId: String, newPreview: String)] = [:]
|
||||
|
||||
if !forwardedImages.isEmpty && toPublicKey != currentPublicKey {
|
||||
var fwdIndex = 0
|
||||
for (originalId, jpegData) in forwardedImages {
|
||||
let newAttId = "fwd_\(timestamp)_\(fwdIndex)"
|
||||
fwdIndex += 1
|
||||
|
||||
let dataURI = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
||||
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||
Data(dataURI.utf8),
|
||||
password: replyPassword
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📤 Forward re-upload: \(originalId) → \(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)")
|
||||
#endif
|
||||
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
id: newAttId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
|
||||
// Extract blurhash from original preview (format: "tag::blurhash")
|
||||
let originalPreview = replyMessages
|
||||
.flatMap { $0.attachments }
|
||||
.first(where: { $0.id == originalId })?.preview ?? ""
|
||||
let blurhash: String
|
||||
if let range = originalPreview.range(of: "::") {
|
||||
blurhash = String(originalPreview[range.upperBound...])
|
||||
} else {
|
||||
blurhash = ""
|
||||
}
|
||||
|
||||
let newPreview = "\(tag)::\(blurhash)"
|
||||
attachmentIdMap[originalId] = (newAttId, newPreview)
|
||||
|
||||
// Cache locally under new ID for ForwardedImagePreviewCell
|
||||
if let image = UIImage(data: jpegData) {
|
||||
AttachmentCache.shared.saveImage(image, forAttachmentId: newAttId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📤 Forward re-upload OK: \(newAttId) tag=\(tag) preview=\(newPreview)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update reply messages with new attachment IDs/previews ──
|
||||
let finalReplyMessages: [ReplyMessageData]
|
||||
if attachmentIdMap.isEmpty {
|
||||
finalReplyMessages = replyMessages
|
||||
} else {
|
||||
finalReplyMessages = replyMessages.map { msg in
|
||||
let updatedAttachments = msg.attachments.map { att -> ReplyAttachmentData in
|
||||
if let mapped = attachmentIdMap[att.id] {
|
||||
return ReplyAttachmentData(
|
||||
id: mapped.newId,
|
||||
type: att.type,
|
||||
preview: mapped.newPreview,
|
||||
blob: ""
|
||||
)
|
||||
}
|
||||
return att
|
||||
}
|
||||
return ReplyMessageData(
|
||||
message_id: msg.message_id,
|
||||
publicKey: msg.publicKey,
|
||||
message: msg.message,
|
||||
timestamp: msg.timestamp,
|
||||
attachments: updatedAttachments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the reply JSON blob
|
||||
let replyJSON = try JSONEncoder().encode(replyMessages)
|
||||
let replyJSON = try JSONEncoder().encode(finalReplyMessages)
|
||||
guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else {
|
||||
throw CryptoError.encryptionFailed
|
||||
}
|
||||
@@ -648,6 +730,14 @@ final class SessionManager {
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.debug("📤 Reply blob: \(replyJSON.count) raw → \(encryptedReplyBlob.count) encrypted bytes")
|
||||
// Self-test: decrypt with the same WHATWG password
|
||||
if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
|
||||
encryptedReplyBlob, password: replyPassword, requireCompression: true
|
||||
), String(data: selfTestData, encoding: .utf8) != nil {
|
||||
Self.logger.debug("📤 Reply blob self-test PASSED")
|
||||
} else {
|
||||
Self.logger.error("📤 Reply blob self-test FAILED")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Build reply attachment
|
||||
@@ -737,7 +827,7 @@ final class SessionManager {
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
|
||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos")
|
||||
}
|
||||
|
||||
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
||||
@@ -1222,52 +1312,8 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop parity: auto-download AVATAR attachments from transport server.
|
||||
let crypto = CryptoManager.shared
|
||||
for attachment in processedPacket.attachments where attachment.type == .avatar {
|
||||
let senderKey = packet.fromPublicKey
|
||||
let preview = attachment.preview
|
||||
let tag = preview.components(separatedBy: "::").first ?? preview
|
||||
guard !tag.isEmpty else { continue }
|
||||
let passwords = passwordCandidates
|
||||
|
||||
Task {
|
||||
do {
|
||||
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
|
||||
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
|
||||
var decryptedData: Data?
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password, requireCompression: true
|
||||
) {
|
||||
decryptedData = data
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fallback: try without requireCompression (legacy)
|
||||
if decryptedData == nil {
|
||||
for password in passwords {
|
||||
if let data = try? crypto.decryptWithPassword(
|
||||
encryptedString, password: password
|
||||
) {
|
||||
decryptedData = data
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let decryptedData else { throw TransportError.invalidResponse }
|
||||
if let base64String = String(data: decryptedData, encoding: .utf8) {
|
||||
AvatarRepository.shared.saveAvatarFromBase64(
|
||||
base64String, publicKey: senderKey
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error(
|
||||
"Failed to download/decrypt avatar from \(senderKey.prefix(12))…: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android parity: avatar attachments are NOT auto-downloaded here.
|
||||
// They are downloaded on-demand when MessageAvatarView renders in chat.
|
||||
}
|
||||
|
||||
// For outgoing messages received from the server (sent by another device
|
||||
|
||||
@@ -20,11 +20,17 @@ enum ReleaseNotes {
|
||||
**Фото и файлы**
|
||||
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
||||
|
||||
**Аватарки**
|
||||
Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу.
|
||||
|
||||
**Шифрование**
|
||||
Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем.
|
||||
|
||||
**Производительность**
|
||||
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
||||
|
||||
**Исправления**
|
||||
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
|
||||
Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -125,7 +125,7 @@ struct ChatTextInput: UIViewRepresentable {
|
||||
tv.backgroundColor = .clear
|
||||
tv.tintColor = UIColor(RosettaColors.primaryBlue)
|
||||
tv.isScrollEnabled = false
|
||||
tv.textContainerInset = UIEdgeInsets(top: 6, left: 2, bottom: 8, right: 0)
|
||||
tv.textContainerInset = UIEdgeInsets(top: 7, left: 2, bottom: 7, right: 0)
|
||||
tv.textContainer.lineFragmentPadding = 0
|
||||
tv.autocapitalizationType = .sentences
|
||||
tv.autocorrectionType = .default
|
||||
|
||||
@@ -429,6 +429,7 @@ private struct ChatDetailToolbarAvatar: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = AvatarRepository.shared.avatarVersion // observation dependency for reactive updates
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||
let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
|
||||
@@ -944,7 +945,7 @@ private extension ChatDetailView {
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return " "
|
||||
return "Message"
|
||||
}()
|
||||
|
||||
let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
@@ -973,11 +974,16 @@ private extension ChatDetailView {
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
|
||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
||||
ForEach(imageAttachments, id: \.id) { att in
|
||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
// Forwarded image attachments — Telegram-style collage (same layout as PhotoCollageView).
|
||||
if !imageAttachments.isEmpty {
|
||||
ForwardedPhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
outgoing: outgoing,
|
||||
maxWidth: imageContentWidth,
|
||||
onImageTap: { attId in openImageViewer(attachmentId: attId) }
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Forwarded file attachments.
|
||||
@@ -1015,9 +1021,14 @@ private extension ChatDetailView {
|
||||
Spacer().frame(height: 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.frame(minWidth: 130, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
if !imageAttachments.isEmpty && !hasCaption {
|
||||
// Photo-only forward: dark pill overlay (same as regular photo messages)
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
@@ -1026,19 +1037,25 @@ private extension ChatDetailView {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message)
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !imageAttachments.isEmpty ? { _ in
|
||||
// Open first forwarded image — user can swipe in gallery.
|
||||
if let firstId = imageAttachments.first?.id {
|
||||
openImageViewer(attachmentId: firstId)
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with
|
||||
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
|
||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — used by `forwardedFilePreview`.
|
||||
@ViewBuilder
|
||||
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
fixedHeight: nil,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
||||
)
|
||||
@@ -1088,7 +1105,14 @@ private extension ChatDetailView {
|
||||
@ViewBuilder
|
||||
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let previewText = reply.message.isEmpty ? "Attachment" : reply.message
|
||||
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" }
|
||||
return "Attachment"
|
||||
}()
|
||||
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||
// Check for image attachment to show thumbnail
|
||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||
@@ -1869,14 +1893,17 @@ private extension ChatDetailView {
|
||||
UIPasteboard.general.string = message.text
|
||||
})
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) {
|
||||
self.forwardingMessage = message
|
||||
self.showForwardPicker = true
|
||||
})
|
||||
// No forward for avatar messages (Android parity)
|
||||
if !message.attachments.contains(where: { $0.type == .avatar }) {
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) {
|
||||
self.forwardingMessage = message
|
||||
self.showForwardPicker = true
|
||||
})
|
||||
}
|
||||
|
||||
actions.append(BubbleContextAction(
|
||||
title: "Delete",
|
||||
@@ -1946,6 +1973,8 @@ private extension ChatDetailView {
|
||||
for message in messages {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
|
||||
|
||||
// Regular image attachments on the message itself.
|
||||
for attachment in message.attachments where attachment.type == .image {
|
||||
allImages.append(ViewableImageInfo(
|
||||
attachmentId: attachment.id,
|
||||
@@ -1954,6 +1983,24 @@ private extension ChatDetailView {
|
||||
caption: message.text
|
||||
))
|
||||
}
|
||||
|
||||
// Forwarded image attachments inside reply/forward blobs.
|
||||
for attachment in message.attachments where attachment.type == .messages {
|
||||
if let replyMessages = parseReplyBlob(attachment.blob) {
|
||||
for reply in replyMessages {
|
||||
let fwdSenderName = senderDisplayName(for: reply.publicKey)
|
||||
let fwdTimestamp = Date(timeIntervalSince1970: Double(reply.timestamp) / 1000)
|
||||
for att in reply.attachments where att.type == AttachmentType.image.rawValue {
|
||||
allImages.append(ViewableImageInfo(
|
||||
attachmentId: att.id,
|
||||
senderName: fwdSenderName,
|
||||
timestamp: fwdTimestamp,
|
||||
caption: reply.message
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||
@@ -2044,19 +2091,55 @@ private extension ChatDetailView {
|
||||
// MARK: - Forward
|
||||
|
||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||
// Desktop parity: forward uses same MESSAGES attachment as reply.
|
||||
// The forwarded message is encoded as a ReplyMessageData JSON blob.
|
||||
let forwardData = buildReplyData(from: message)
|
||||
// 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).
|
||||
let forwardDataList: [ReplyMessageData]
|
||||
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|
||||
if isForward,
|
||||
let att = replyAttachment,
|
||||
let innerMessages = parseReplyBlob(att.blob),
|
||||
!innerMessages.isEmpty {
|
||||
// Unwrap: forward the original messages, not the wrapper
|
||||
forwardDataList = innerMessages
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targetKey = targetRoute.publicKey
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Forward sends a space as text with the forwarded message as MESSAGES attachment
|
||||
try await SessionManager.shared.sendMessageWithReply(
|
||||
text: " ",
|
||||
replyMessages: [forwardData],
|
||||
replyMessages: forwardDataList,
|
||||
toPublicKey: targetKey,
|
||||
opponentTitle: targetRoute.title,
|
||||
opponentUsername: targetRoute.username
|
||||
opponentUsername: targetRoute.username,
|
||||
forwardedImages: forwardedImages
|
||||
)
|
||||
} catch {
|
||||
sendError = "Failed to forward message"
|
||||
@@ -2371,6 +2454,100 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedPhotoCollageView
|
||||
|
||||
/// Telegram-style collage layout for forwarded image attachments (same patterns as PhotoCollageView).
|
||||
/// Uses ForwardedImagePreviewCell for each cell instead of MessageImageView.
|
||||
private struct ForwardedPhotoCollageView: View {
|
||||
let attachments: [ReplyAttachmentData]
|
||||
let outgoing: Bool
|
||||
let maxWidth: CGFloat
|
||||
var onImageTap: ((String) -> Void)?
|
||||
|
||||
private let spacing: CGFloat = 2
|
||||
private let maxCollageHeight: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
collageContent(contentWidth: maxWidth)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func collageContent(contentWidth: CGFloat) -> some View {
|
||||
switch attachments.count {
|
||||
case 0:
|
||||
EmptyView()
|
||||
case 1:
|
||||
cell(attachments[0], width: contentWidth, height: min(contentWidth * 0.75, maxCollageHeight))
|
||||
case 2:
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 1.2, maxCollageHeight)
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
case 3:
|
||||
let rightWidth = contentWidth * 0.34
|
||||
let leftWidth = contentWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, maxCollageHeight)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: leftWidth, height: totalHeight)
|
||||
VStack(spacing: spacing) {
|
||||
cell(attachments[1], width: rightWidth, height: rightCellHeight)
|
||||
cell(attachments[2], width: rightWidth, height: rightCellHeight)
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
let cellWidth = (contentWidth - spacing) / 2
|
||||
let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2)
|
||||
VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[1], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[2], width: cellWidth, height: cellHeight)
|
||||
cell(attachments[3], width: cellWidth, height: cellHeight)
|
||||
}
|
||||
}
|
||||
default:
|
||||
let topCellWidth = (contentWidth - spacing) / 2
|
||||
let bottomCellWidth = (contentWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, maxCollageHeight * 0.55)
|
||||
let bottomHeight = min(bottomCellWidth * 0.85, maxCollageHeight * 0.45)
|
||||
VStack(spacing: spacing) {
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[0], width: topCellWidth, height: topHeight)
|
||||
cell(attachments[1], width: topCellWidth, height: topHeight)
|
||||
}
|
||||
HStack(spacing: spacing) {
|
||||
cell(attachments[2], width: bottomCellWidth, height: bottomHeight)
|
||||
if attachments.count > 3 {
|
||||
cell(attachments[3], width: bottomCellWidth, height: bottomHeight)
|
||||
}
|
||||
if attachments.count > 4 {
|
||||
cell(attachments[4], width: bottomCellWidth, height: bottomHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(_ attachment: ReplyAttachmentData, width: CGFloat, height: CGFloat) -> some View {
|
||||
ForwardedImagePreviewCell(
|
||||
attachment: attachment,
|
||||
width: width,
|
||||
fixedHeight: height,
|
||||
outgoing: outgoing,
|
||||
onTapCachedImage: { onImageTap?(attachment.id) }
|
||||
)
|
||||
.frame(width: width, height: height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardedImagePreviewCell
|
||||
|
||||
/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image
|
||||
@@ -2380,13 +2557,14 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
|
||||
private struct ForwardedImagePreviewCell: View {
|
||||
let attachment: ReplyAttachmentData
|
||||
let width: CGFloat
|
||||
var fixedHeight: CGFloat?
|
||||
let outgoing: Bool
|
||||
let onTapCachedImage: () -> Void
|
||||
|
||||
@State private var cachedImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
|
||||
private var imageHeight: CGFloat { min(width * 0.75, 200) }
|
||||
private var imageHeight: CGFloat { fixedHeight ?? min(width * 0.75, 200) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -2396,7 +2574,6 @@ private struct ForwardedImagePreviewCell: View {
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapCachedImage() }
|
||||
} else if let blur = blurImage {
|
||||
@@ -2405,10 +2582,8 @@ private struct ForwardedImagePreviewCell: View {
|
||||
.scaledToFill()
|
||||
.frame(width: width, height: imageHeight)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
// No image at all — show placeholder.
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
Rectangle()
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
.frame(width: width, height: imageHeight)
|
||||
.overlay {
|
||||
|
||||
@@ -4,14 +4,15 @@ import SwiftUI
|
||||
|
||||
/// Displays an avatar attachment inside a message bubble.
|
||||
///
|
||||
/// Desktop parity: `MessageAvatar.tsx` — shows a bordered card with circular avatar
|
||||
/// preview, "Avatar" title with lock icon, and descriptive text.
|
||||
/// Android parity: `AvatarAttachment` composable — download only on user tap,
|
||||
/// NOT auto-download. After successful download, saves to AvatarRepository.
|
||||
///
|
||||
/// States:
|
||||
/// 1. **Cached** — avatar already in AttachmentCache, display immediately
|
||||
/// 2. **Downloading** — show placeholder + spinner
|
||||
/// 3. **Downloaded** — display avatar, auto-saved to AvatarRepository
|
||||
/// 4. **Error** — "Avatar expired" or download error
|
||||
/// 2. **Not downloaded** — show placeholder + "Tap to download"
|
||||
/// 3. **Downloading** — show placeholder + spinner
|
||||
/// 4. **Downloaded** — display avatar, saved to AvatarRepository
|
||||
/// 5. **Error** — "Tap to retry"
|
||||
struct MessageAvatarView: View {
|
||||
|
||||
let attachment: MessageAttachment
|
||||
@@ -19,12 +20,19 @@ struct MessageAvatarView: View {
|
||||
let outgoing: Bool
|
||||
|
||||
@State private var avatarImage: UIImage?
|
||||
@State private var blurImage: UIImage?
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadError = false
|
||||
@State private var showAvatar = false
|
||||
|
||||
/// Avatar circle diameter (desktop parity: 60px).
|
||||
private let avatarSize: CGFloat = 56
|
||||
|
||||
/// Whether the avatar needs to be downloaded from CDN.
|
||||
private var needsDownload: Bool {
|
||||
avatarImage == nil && !isDownloading && !downloadError
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
// Avatar circle (left side)
|
||||
@@ -43,13 +51,7 @@ struct MessageAvatarView: View {
|
||||
.foregroundStyle(Color.green.opacity(0.8))
|
||||
}
|
||||
|
||||
// Description
|
||||
Text("An avatar image shared in the message.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(2)
|
||||
|
||||
// Download state indicator
|
||||
// Description / status
|
||||
if isDownloading {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView()
|
||||
@@ -59,12 +61,19 @@ struct MessageAvatarView: View {
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
} else if downloadError {
|
||||
Text("Avatar expired")
|
||||
.font(.system(size: 11))
|
||||
Text("Tap to retry")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
.padding(.top, 2)
|
||||
} else if avatarImage != nil {
|
||||
Text("Shared profile photo.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Tap to download")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +81,20 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
// BubbleContextMenuOverlay blocks all SwiftUI onTapGesture —
|
||||
// download triggered via notification from overlay's onTap callback.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
|
||||
if let id = notif.object as? String, id == attachment.id {
|
||||
if needsDownload || downloadError {
|
||||
downloadAvatar()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
loadFromCache()
|
||||
if avatarImage == nil {
|
||||
downloadAvatar()
|
||||
decodeBlurHash()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +110,37 @@ struct MessageAvatarView: View {
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
.scaleEffect(showAvatar ? 1.0 : 0.5)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.5), value: showAvatar)
|
||||
.onAppear { showAvatar = true }
|
||||
} else if let blurImage {
|
||||
// Android parity: blurhash preview in circle before download
|
||||
Image(uiImage: blurImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
.overlay {
|
||||
if isDownloading {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
} else if downloadError {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.4))
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Circle()
|
||||
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
|
||||
@@ -99,9 +149,13 @@ struct MessageAvatarView: View {
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.tint(.white.opacity(0.5))
|
||||
} else if downloadError {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 22))
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
}
|
||||
}
|
||||
@@ -109,16 +163,47 @@ struct MessageAvatarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BlurHash Decoding
|
||||
|
||||
/// Android parity: decode blurhash from preview as 32×32 placeholder.
|
||||
/// Shared static cache with half-eviction (same pattern as MessageImageView).
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
private func decodeBlurHash() {
|
||||
let hash = extractBlurHash(from: attachment.preview)
|
||||
guard !hash.isEmpty else { return }
|
||||
if let cached = Self.blurHashCache[hash] {
|
||||
blurImage = cached
|
||||
return
|
||||
}
|
||||
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
|
||||
if Self.blurHashCache.count > 200 {
|
||||
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
|
||||
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.blurHashCache[hash] = result
|
||||
blurImage = result
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] : ""
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
private func loadFromCache() {
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
avatarImage = cached
|
||||
showAvatar = true // No animation for cached — show immediately
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAvatar() {
|
||||
guard !isDownloading, avatarImage == nil else { return }
|
||||
guard !isDownloading else { return }
|
||||
|
||||
let tag = extractTag(from: attachment.preview)
|
||||
guard !tag.isEmpty else {
|
||||
@@ -148,6 +233,12 @@ struct MessageAvatarView: View {
|
||||
if let downloadedImage {
|
||||
avatarImage = downloadedImage
|
||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
||||
// Android parity: save avatar to sender's profile after download
|
||||
let senderKey = message.fromPublicKey
|
||||
if let jpegData = downloadedImage.jpegData(compressionQuality: 0.85) {
|
||||
let base64 = jpegData.base64EncodedString()
|
||||
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
|
||||
}
|
||||
} else {
|
||||
downloadError = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user