Пересылка фото: перешифровка + загрузка на CDN, коллаж для пересланных фото, открытие в просмотрщике
This commit is contained in:
@@ -417,7 +417,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.0;
|
MARKETING_VERSION = 1.2.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.0;
|
MARKETING_VERSION = 1.2.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ enum MessageCrypto {
|
|||||||
|
|
||||||
/// Android parity helper:
|
/// Android parity helper:
|
||||||
/// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`.
|
/// `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 {
|
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)
|
let decoded = String(decoding: utf8Bytes, as: UTF8.self)
|
||||||
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
||||||
return latin1
|
return latin1
|
||||||
@@ -138,6 +142,15 @@ enum MessageCrypto {
|
|||||||
return Data(decoded.unicodeScalars.map { $0.value <= 0xFF ? UInt8($0.value) : UInt8(ascii: "?") })
|
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
|
// MARK: - Attachment Password Helpers
|
||||||
|
|
||||||
/// Returns password candidates from a stored attachment password string.
|
/// 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
|
/// Used for PBKDF2 password derivation from random key+nonce bytes
|
||||||
/// to match Android's attachment encryption.
|
/// 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 {
|
static func bytesToAndroidUtf8String(_ bytes: Data) -> String {
|
||||||
var result = ""
|
var codePoints: [Int] = []
|
||||||
result.reserveCapacity(bytes.count)
|
codePoints.reserveCapacity(bytes.count)
|
||||||
var i = bytes.startIndex
|
var index = 0
|
||||||
|
let end = bytes.count
|
||||||
|
|
||||||
while i < bytes.endIndex {
|
while index < end {
|
||||||
let b0 = Int(bytes[i])
|
let firstByte = Int(bytes[index]) & 0xFF
|
||||||
|
var codePoint: Int? = nil
|
||||||
|
var bytesPerSequence: Int
|
||||||
|
|
||||||
if b0 <= 0x7F {
|
if firstByte > 0xEF {
|
||||||
// ASCII
|
bytesPerSequence = 4
|
||||||
result.append(Character(UnicodeScalar(b0)!))
|
} else if firstByte > 0xDF {
|
||||||
i += 1
|
bytesPerSequence = 3
|
||||||
} else if b0 <= 0xBF {
|
} else if firstByte > 0xBF {
|
||||||
// Orphan continuation byte
|
bytesPerSequence = 2
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Invalid starter byte (0xF8-0xFF)
|
bytesPerSequence = 1
|
||||||
result.append("\u{FFFD}")
|
|
||||||
i += 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)
|
// 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)
|
let originalBytes = androidUtf8BytesToLatin1Bytes(decryptedBytes)
|
||||||
guard originalBytes.count >= 56 else { continue }
|
if originalBytes.count >= 56, seen.insert(originalBytes).inserted {
|
||||||
if seen.insert(originalBytes).inserted {
|
|
||||||
candidates.append(originalBytes)
|
candidates.append(originalBytes)
|
||||||
}
|
}
|
||||||
|
let altBytes = androidUtf8BytesToLatin1BytesAlt(decryptedBytes)
|
||||||
|
if altBytes.count >= 56, altBytes != originalBytes, seen.insert(altBytes).inserted {
|
||||||
|
candidates.append(altBytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !candidates.isEmpty else {
|
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)
|
// MARK: - Stress Test (Debug only)
|
||||||
|
|
||||||
/// Inserts a pre-built message for stress testing. Skips encryption/dedup.
|
/// Inserts a pre-built message for stress testing. Skips encryption/dedup.
|
||||||
|
|||||||
@@ -229,7 +229,10 @@ final class SessionManager {
|
|||||||
recipientPublicKeyHex: toPublicKey
|
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)
|
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||||
|
|
||||||
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
|
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
|
||||||
@@ -365,10 +368,10 @@ final class SessionManager {
|
|||||||
recipientPublicKeyHex: toPublicKey
|
recipientPublicKeyHex: toPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity.
|
// Attachment password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
|
||||||
// Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in
|
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
|
||||||
// failed multi-byte sequences. Desktop WHATWG is slightly different but both work
|
// Node.js Buffer.toString('utf-8') ≈ feross/buffer polyfill ≈ bytesToAndroidUtf8String.
|
||||||
// because Desktop tries both variants when decrypting.
|
// WHATWG TextDecoder differs for ~47% of random 56-byte keys.
|
||||||
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -430,46 +433,24 @@ final class SessionManager {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android parity: cache original images BEFORE upload so they display
|
// Android parity: cache original images and show optimistic message BEFORE upload.
|
||||||
// instantly in the chat bubble. Without this, photo doesn't appear until
|
// Android: addMessageSafely(optimisticMessage) → then background upload.
|
||||||
// upload completes (can take seconds on slow connection).
|
// Without this, photo doesn't appear until upload completes (5-10 seconds).
|
||||||
for item in encryptedAttachments {
|
for item in encryptedAttachments {
|
||||||
if item.original.type == .image, let image = UIImage(data: item.original.data) {
|
if item.original.type == .image, let image = UIImage(data: item.original.data) {
|
||||||
AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
|
AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
// Build placeholder attachments (no tag yet — filled after upload).
|
||||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
// preview = "::blurhash" (no tag prefix), blob = "".
|
||||||
of: (Int, String).self
|
let placeholderAttachments = encryptedAttachments.map { item in
|
||||||
) { group in
|
MessageAttachment(
|
||||||
for (index, item) in encryptedAttachments.enumerated() {
|
id: item.original.id,
|
||||||
group.addTask {
|
preview: item.preview.isEmpty ? "" : "::\(item.preview)",
|
||||||
let tag = try await TransportManager.shared.uploadFile(
|
blob: "",
|
||||||
id: item.original.id,
|
type: item.original.type
|
||||||
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 aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket).
|
// Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket).
|
||||||
@@ -507,62 +488,78 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Build PacketMessage with attachments
|
// ── Optimistic UI: show message INSTANTLY with placeholder attachments ──
|
||||||
var packet = PacketMessage()
|
// Android parity: addMessageSafely(optimisticMessage) before upload.
|
||||||
packet.fromPublicKey = currentPublicKey
|
var optimisticPacket = PacketMessage()
|
||||||
packet.toPublicKey = toPublicKey
|
optimisticPacket.fromPublicKey = currentPublicKey
|
||||||
packet.content = encrypted.content
|
optimisticPacket.toPublicKey = toPublicKey
|
||||||
packet.chachaKey = encrypted.chachaKey
|
optimisticPacket.content = encrypted.content
|
||||||
packet.timestamp = timestamp
|
optimisticPacket.chachaKey = encrypted.chachaKey
|
||||||
packet.privateKey = hash
|
optimisticPacket.timestamp = timestamp
|
||||||
packet.messageId = messageId
|
optimisticPacket.privateKey = hash
|
||||||
packet.aesChachaKey = aesChachaKey
|
optimisticPacket.messageId = messageId
|
||||||
packet.attachments = messageAttachments
|
optimisticPacket.aesChachaKey = aesChachaKey
|
||||||
|
optimisticPacket.attachments = placeholderAttachments
|
||||||
|
|
||||||
// Ensure dialog exists
|
|
||||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
||||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: toPublicKey,
|
opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey
|
||||||
title: title,
|
|
||||||
username: username,
|
|
||||||
myPublicKey: currentPublicKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optimistic UI update
|
|
||||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||||
let offlineAsSend = !isConnected
|
let offlineAsSend = !isConnected
|
||||||
|
|
||||||
// messageText is already trimmed — "" for no caption, triggers "Photo"/"File" in updateFromMessage.
|
|
||||||
let displayText = messageText
|
let displayText = messageText
|
||||||
|
|
||||||
DialogRepository.shared.updateFromMessage(
|
DialogRepository.shared.updateFromMessage(
|
||||||
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||||
)
|
)
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet,
|
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
|
||||||
myPublicKey: currentPublicKey,
|
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend
|
||||||
decryptedText: displayText,
|
|
||||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
|
||||||
fromSync: offlineAsSend
|
|
||||||
)
|
)
|
||||||
|
MessageRepository.shared.persistNow()
|
||||||
|
|
||||||
if offlineAsSend {
|
if offlineAsSend {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
||||||
DialogRepository.shared.updateDeliveryStatus(
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
||||||
messageId: messageId, opponentKey: toPublicKey, status: .error
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved Messages: local-only, no server send
|
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
DialogRepository.shared.updateDeliveryStatus(
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||||
messageId: messageId, opponentKey: toPublicKey, status: .delivered
|
|
||||||
)
|
|
||||||
return
|
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)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
@@ -613,7 +610,8 @@ final class SessionManager {
|
|||||||
replyMessages: [ReplyMessageData],
|
replyMessages: [ReplyMessageData],
|
||||||
toPublicKey: String,
|
toPublicKey: String,
|
||||||
opponentTitle: String = "",
|
opponentTitle: String = "",
|
||||||
opponentUsername: String = ""
|
opponentUsername: String = "",
|
||||||
|
forwardedImages: [String: Data] = [:] // [originalAttachmentId: jpegData]
|
||||||
) async throws {
|
) async throws {
|
||||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||||
Self.logger.error("📤 Cannot send reply — missing keys")
|
Self.logger.error("📤 Cannot send reply — missing keys")
|
||||||
@@ -630,12 +628,96 @@ final class SessionManager {
|
|||||||
recipientPublicKeyHex: toPublicKey
|
recipientPublicKeyHex: toPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes.
|
// Reply password: feross/buffer UTF-8 (matches Node.js Buffer.toString('utf-8')
|
||||||
// Same as attachment password derivation.
|
// used by Desktop in useDialogFiber.ts and useSynchronize.ts).
|
||||||
let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
|
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
|
// 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 {
|
guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else {
|
||||||
throw CryptoError.encryptionFailed
|
throw CryptoError.encryptionFailed
|
||||||
}
|
}
|
||||||
@@ -648,6 +730,14 @@ final class SessionManager {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Self.logger.debug("📤 Reply blob: \(replyJSON.count) raw → \(encryptedReplyBlob.count) encrypted bytes")
|
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
|
#endif
|
||||||
|
|
||||||
// Build reply attachment
|
// Build reply attachment
|
||||||
@@ -737,7 +827,7 @@ final class SessionManager {
|
|||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
MessageRepository.shared.persistNow()
|
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).
|
/// 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.
|
// Android parity: avatar attachments are NOT auto-downloaded here.
|
||||||
let crypto = CryptoManager.shared
|
// They are downloaded on-demand when MessageAvatarView renders in chat.
|
||||||
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)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For outgoing messages received from the server (sent by another device
|
// For outgoing messages received from the server (sent by another device
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ enum ReleaseNotes {
|
|||||||
**Фото и файлы**
|
**Фото и файлы**
|
||||||
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
||||||
|
|
||||||
|
**Аватарки**
|
||||||
|
Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу.
|
||||||
|
|
||||||
|
**Шифрование**
|
||||||
|
Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем.
|
||||||
|
|
||||||
**Производительность**
|
**Производительность**
|
||||||
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
||||||
|
|
||||||
**Исправления**
|
**Исправления**
|
||||||
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
|
Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ struct ChatTextInput: UIViewRepresentable {
|
|||||||
tv.backgroundColor = .clear
|
tv.backgroundColor = .clear
|
||||||
tv.tintColor = UIColor(RosettaColors.primaryBlue)
|
tv.tintColor = UIColor(RosettaColors.primaryBlue)
|
||||||
tv.isScrollEnabled = false
|
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.textContainer.lineFragmentPadding = 0
|
||||||
tv.autocapitalizationType = .sentences
|
tv.autocapitalizationType = .sentences
|
||||||
tv.autocorrectionType = .default
|
tv.autocorrectionType = .default
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ private struct ChatDetailToolbarAvatar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = AvatarRepository.shared.avatarVersion // observation dependency for reactive updates
|
||||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
|
||||||
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
||||||
let colorIndex = RosettaColors.avatarColorIndex(for: 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 !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||||
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
|
||||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
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)
|
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(.leading, 11)
|
||||||
.padding(.top, 3)
|
.padding(.top, 3)
|
||||||
|
|
||||||
// Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview).
|
// Forwarded image attachments — Telegram-style collage (same layout as PhotoCollageView).
|
||||||
ForEach(imageAttachments, id: \.id) { att in
|
if !imageAttachments.isEmpty {
|
||||||
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
|
ForwardedPhotoCollageView(
|
||||||
.padding(.horizontal, 6)
|
attachments: imageAttachments,
|
||||||
.padding(.top, 4)
|
outgoing: outgoing,
|
||||||
|
maxWidth: imageContentWidth,
|
||||||
|
onImageTap: { attId in openImageViewer(attachmentId: attId) }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forwarded file attachments.
|
// Forwarded file attachments.
|
||||||
@@ -1015,9 +1021,14 @@ private extension ChatDetailView {
|
|||||||
Spacer().frame(height: 5)
|
Spacer().frame(height: 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
.frame(minWidth: 130, alignment: .leading)
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.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(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||||
@@ -1026,19 +1037,25 @@ private extension ChatDetailView {
|
|||||||
BubbleContextMenuOverlay(
|
BubbleContextMenuOverlay(
|
||||||
actions: bubbleActions(for: message),
|
actions: bubbleActions(for: message),
|
||||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
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)
|
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with
|
/// Wrapper that delegates to `ForwardedImagePreviewCell` — used by `forwardedFilePreview`.
|
||||||
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
|
||||||
ForwardedImagePreviewCell(
|
ForwardedImagePreviewCell(
|
||||||
attachment: attachment,
|
attachment: attachment,
|
||||||
width: width,
|
width: width,
|
||||||
|
fixedHeight: nil,
|
||||||
outgoing: outgoing,
|
outgoing: outgoing,
|
||||||
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
|
||||||
)
|
)
|
||||||
@@ -1088,7 +1105,14 @@ private extension ChatDetailView {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
||||||
let senderName = senderDisplayName(for: reply.publicKey)
|
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
|
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||||
// Check for image attachment to show thumbnail
|
// Check for image attachment to show thumbnail
|
||||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||||
@@ -1869,14 +1893,17 @@ private extension ChatDetailView {
|
|||||||
UIPasteboard.general.string = message.text
|
UIPasteboard.general.string = message.text
|
||||||
})
|
})
|
||||||
|
|
||||||
actions.append(BubbleContextAction(
|
// No forward for avatar messages (Android parity)
|
||||||
title: "Forward",
|
if !message.attachments.contains(where: { $0.type == .avatar }) {
|
||||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
actions.append(BubbleContextAction(
|
||||||
role: []
|
title: "Forward",
|
||||||
) {
|
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||||
self.forwardingMessage = message
|
role: []
|
||||||
self.showForwardPicker = true
|
) {
|
||||||
})
|
self.forwardingMessage = message
|
||||||
|
self.showForwardPicker = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
actions.append(BubbleContextAction(
|
actions.append(BubbleContextAction(
|
||||||
title: "Delete",
|
title: "Delete",
|
||||||
@@ -1946,6 +1973,8 @@ private extension ChatDetailView {
|
|||||||
for message in messages {
|
for message in messages {
|
||||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||||
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
|
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
|
||||||
|
|
||||||
|
// Regular image attachments on the message itself.
|
||||||
for attachment in message.attachments where attachment.type == .image {
|
for attachment in message.attachments where attachment.type == .image {
|
||||||
allImages.append(ViewableImageInfo(
|
allImages.append(ViewableImageInfo(
|
||||||
attachmentId: attachment.id,
|
attachmentId: attachment.id,
|
||||||
@@ -1954,6 +1983,24 @@ private extension ChatDetailView {
|
|||||||
caption: message.text
|
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 index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||||
let state = ImageViewerState(images: allImages, initialIndex: index)
|
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||||
@@ -2044,19 +2091,55 @@ private extension ChatDetailView {
|
|||||||
// MARK: - Forward
|
// MARK: - Forward
|
||||||
|
|
||||||
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
|
||||||
// Desktop parity: forward uses same MESSAGES attachment as reply.
|
// Android parity: unwrap nested forwards.
|
||||||
// The forwarded message is encoded as a ReplyMessageData JSON blob.
|
// If the message being forwarded is itself a forward, extract the inner
|
||||||
let forwardData = buildReplyData(from: message)
|
// 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
|
let targetKey = targetRoute.publicKey
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
// Forward sends a space as text with the forwarded message as MESSAGES attachment
|
|
||||||
try await SessionManager.shared.sendMessageWithReply(
|
try await SessionManager.shared.sendMessageWithReply(
|
||||||
text: " ",
|
text: " ",
|
||||||
replyMessages: [forwardData],
|
replyMessages: forwardDataList,
|
||||||
toPublicKey: targetKey,
|
toPublicKey: targetKey,
|
||||||
opponentTitle: targetRoute.title,
|
opponentTitle: targetRoute.title,
|
||||||
opponentUsername: targetRoute.username
|
opponentUsername: targetRoute.username,
|
||||||
|
forwardedImages: forwardedImages
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
sendError = "Failed to forward message"
|
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
|
// MARK: - ForwardedImagePreviewCell
|
||||||
|
|
||||||
/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image
|
/// 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 {
|
private struct ForwardedImagePreviewCell: View {
|
||||||
let attachment: ReplyAttachmentData
|
let attachment: ReplyAttachmentData
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
|
var fixedHeight: CGFloat?
|
||||||
let outgoing: Bool
|
let outgoing: Bool
|
||||||
let onTapCachedImage: () -> Void
|
let onTapCachedImage: () -> Void
|
||||||
|
|
||||||
@State private var cachedImage: UIImage?
|
@State private var cachedImage: UIImage?
|
||||||
@State private var blurImage: 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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -2396,7 +2574,6 @@ private struct ForwardedImagePreviewCell: View {
|
|||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: width, height: imageHeight)
|
.frame(width: width, height: imageHeight)
|
||||||
.clipped()
|
.clipped()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onTapCachedImage() }
|
.onTapGesture { onTapCachedImage() }
|
||||||
} else if let blur = blurImage {
|
} else if let blur = blurImage {
|
||||||
@@ -2405,10 +2582,8 @@ private struct ForwardedImagePreviewCell: View {
|
|||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: width, height: imageHeight)
|
.frame(width: width, height: imageHeight)
|
||||||
.clipped()
|
.clipped()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
} else {
|
} else {
|
||||||
// No image at all — show placeholder.
|
Rectangle()
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||||
.frame(width: width, height: imageHeight)
|
.frame(width: width, height: imageHeight)
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Displays an avatar attachment inside a message bubble.
|
/// Displays an avatar attachment inside a message bubble.
|
||||||
///
|
///
|
||||||
/// Desktop parity: `MessageAvatar.tsx` — shows a bordered card with circular avatar
|
/// Android parity: `AvatarAttachment` composable — download only on user tap,
|
||||||
/// preview, "Avatar" title with lock icon, and descriptive text.
|
/// NOT auto-download. After successful download, saves to AvatarRepository.
|
||||||
///
|
///
|
||||||
/// States:
|
/// States:
|
||||||
/// 1. **Cached** — avatar already in AttachmentCache, display immediately
|
/// 1. **Cached** — avatar already in AttachmentCache, display immediately
|
||||||
/// 2. **Downloading** — show placeholder + spinner
|
/// 2. **Not downloaded** — show placeholder + "Tap to download"
|
||||||
/// 3. **Downloaded** — display avatar, auto-saved to AvatarRepository
|
/// 3. **Downloading** — show placeholder + spinner
|
||||||
/// 4. **Error** — "Avatar expired" or download error
|
/// 4. **Downloaded** — display avatar, saved to AvatarRepository
|
||||||
|
/// 5. **Error** — "Tap to retry"
|
||||||
struct MessageAvatarView: View {
|
struct MessageAvatarView: View {
|
||||||
|
|
||||||
let attachment: MessageAttachment
|
let attachment: MessageAttachment
|
||||||
@@ -19,12 +20,19 @@ struct MessageAvatarView: View {
|
|||||||
let outgoing: Bool
|
let outgoing: Bool
|
||||||
|
|
||||||
@State private var avatarImage: UIImage?
|
@State private var avatarImage: UIImage?
|
||||||
|
@State private var blurImage: UIImage?
|
||||||
@State private var isDownloading = false
|
@State private var isDownloading = false
|
||||||
@State private var downloadError = false
|
@State private var downloadError = false
|
||||||
|
@State private var showAvatar = false
|
||||||
|
|
||||||
/// Avatar circle diameter (desktop parity: 60px).
|
/// Avatar circle diameter (desktop parity: 60px).
|
||||||
private let avatarSize: CGFloat = 56
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// Avatar circle (left side)
|
// Avatar circle (left side)
|
||||||
@@ -43,13 +51,7 @@ struct MessageAvatarView: View {
|
|||||||
.foregroundStyle(Color.green.opacity(0.8))
|
.foregroundStyle(Color.green.opacity(0.8))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
// Description / status
|
||||||
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
|
|
||||||
if isDownloading {
|
if isDownloading {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -59,12 +61,19 @@ struct MessageAvatarView: View {
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
|
||||||
} else if downloadError {
|
} else if downloadError {
|
||||||
Text("Avatar expired")
|
Text("Tap to retry")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(RosettaColors.error)
|
.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(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.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 {
|
.task {
|
||||||
loadFromCache()
|
loadFromCache()
|
||||||
if avatarImage == nil {
|
if avatarImage == nil {
|
||||||
downloadAvatar()
|
decodeBlurHash()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +110,37 @@ struct MessageAvatarView: View {
|
|||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
.frame(width: avatarSize, height: avatarSize)
|
||||||
.clipShape(Circle())
|
.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 {
|
} else {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
|
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
|
||||||
@@ -99,9 +149,13 @@ struct MessageAvatarView: View {
|
|||||||
if isDownloading {
|
if isDownloading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.white.opacity(0.5))
|
.tint(.white.opacity(0.5))
|
||||||
|
} else if downloadError {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 24))
|
||||||
.foregroundStyle(.white.opacity(0.3))
|
.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
|
// MARK: - Download
|
||||||
|
|
||||||
private func loadFromCache() {
|
private func loadFromCache() {
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||||
avatarImage = cached
|
avatarImage = cached
|
||||||
|
showAvatar = true // No animation for cached — show immediately
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadAvatar() {
|
private func downloadAvatar() {
|
||||||
guard !isDownloading, avatarImage == nil else { return }
|
guard !isDownloading else { return }
|
||||||
|
|
||||||
let tag = extractTag(from: attachment.preview)
|
let tag = extractTag(from: attachment.preview)
|
||||||
guard !tag.isEmpty else {
|
guard !tag.isEmpty else {
|
||||||
@@ -148,6 +233,12 @@ struct MessageAvatarView: View {
|
|||||||
if let downloadedImage {
|
if let downloadedImage {
|
||||||
avatarImage = downloadedImage
|
avatarImage = downloadedImage
|
||||||
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
|
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 {
|
} else {
|
||||||
downloadError = true
|
downloadError = true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user