Пересылка фото: перешифровка + загрузка на CDN, коллаж для пересланных фото, открытие в просмотрщике

This commit is contained in:
2026-03-20 21:20:11 +05:00
parent e75c6bac12
commit 224b8a2b54
8 changed files with 599 additions and 290 deletions

View File

@@ -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 = "";

View File

@@ -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
if firstByte > 0xEF {
bytesPerSequence = 4
} else if firstByte > 0xDF {
bytesPerSequence = 3
} else if firstByte > 0xBF {
bytesPerSequence = 2
} 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)!))
bytesPerSequence = 1
}
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)!))
if index + bytesPerSequence <= end {
switch bytesPerSequence {
case 1:
if firstByte < 0x80 {
codePoint = firstByte
}
i += 3
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
}
}
} 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
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
}
}
} else {
// Invalid starter byte (0xF8-0xFF)
result.append("\u{FFFD}")
i += 1
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
}
}
return result
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 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 {

View File

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

View File

@@ -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,47 +433,25 @@ 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(
// 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,
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,
preview: item.preview.isEmpty ? "" : "::\(item.preview)",
blob: "",
type: item.original.type
)
}
}
// Build aesChachaKey (for sync/backup same encoding as makeOutgoingPacket).
// MUST use Latin-1 (not WHATWG UTF-8) so desktop can recover original raw bytes
@@ -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

View File

@@ -20,11 +20,17 @@ enum ReleaseNotes {
**Фото и файлы**
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
**Аватарки**
Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу.
**Шифрование**
Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем.
**Производительность**
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
**Исправления**
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки.
"""
)
]

View File

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

View File

@@ -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,9 +974,14 @@ 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)
// 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)
}
@@ -1015,10 +1021,15 @@ private extension ChatDetailView {
Spacer().frame(height: 5)
}
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.frame(minWidth: 130, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
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)
.background { bubbleBackground(outgoing: outgoing, position: position) }
@@ -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,6 +1893,8 @@ private extension ChatDetailView {
UIPasteboard.general.string = message.text
})
// 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"),
@@ -1877,6 +1903,7 @@ private extension ChatDetailView {
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 {

View File

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