Уведомления, Real-time синхронизация, фотки, reply and forward

This commit is contained in:
2026-03-17 03:51:29 +05:00
parent 624038915d
commit 1f442e1298
26 changed files with 2711 additions and 431 deletions

View File

@@ -11,6 +11,7 @@
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -34,6 +35,7 @@
853F29A02F4B63D20092AD05 /* P256K in Frameworks */, 853F29A02F4B63D20092AD05 /* P256K in Frameworks */,
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */,
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */, F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */,
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -80,6 +82,7 @@
853F29A12F4B63D20092AD05 /* P256K */, 853F29A12F4B63D20092AD05 /* P256K */,
F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */, F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */,
F1A000042F6F00010092AD05 /* FirebaseMessaging */, F1A000042F6F00010092AD05 /* FirebaseMessaging */,
F1A000072F6F00010092AD05 /* FirebaseCrashlytics */,
); );
productName = Rosetta; productName = Rosetta;
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
@@ -272,7 +275,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 = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -288,7 +291,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.1.6;
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 = "";
@@ -311,7 +314,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 = 16; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -327,7 +330,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.1.6;
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 = "";
@@ -413,6 +416,11 @@
package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseMessaging; productName = FirebaseMessaging;
}; };
F1A000072F6F00010092AD05 /* FirebaseCrashlytics */ = {
isa = XCSwiftPackageProductDependency;
package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCrashlytics;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 853F295A2F4B50410092AD05 /* Project object */; rootObject = 853F295A2F4B50410092AD05 /* Project object */;

View File

@@ -105,58 +105,51 @@ final class CryptoManager: @unchecked Sendable {
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
} }
nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data { /// - Parameter requireCompression: When `true`, skips the uncompressed fallback.
/// Use for attachment blobs which are ALWAYS compressed (zlibDeflate/rawDeflate).
/// The uncompressed fallback accepts ANY AES-CBC output as valid, which returns
/// garbage when the password is wrong breaking multi-candidate password loops.
nonisolated func decryptWithPassword(
_ encrypted: String,
password: String,
requireCompression: Bool = false
) throws -> Data {
let parts = encrypted.components(separatedBy: ":") let parts = encrypted.components(separatedBy: ":")
guard parts.count == 2, guard parts.count == 2,
let iv = Data(base64Encoded: parts[0]), let iv = Data(base64Encoded: parts[0]),
let ciphertext = Data(base64Encoded: parts[1]) else { let ciphertext = Data(base64Encoded: parts[1]) else {
print("🔐 [decrypt] ❌ Malformed: parts=\(encrypted.components(separatedBy: ":").count) encrypted.prefix=\(encrypted.prefix(60))")
throw CryptoError.invalidData("Malformed encrypted string") throw CryptoError.invalidData("Malformed encrypted string")
} }
print("🔐 [decrypt] iv=\(iv.count)bytes ciphertext=\(ciphertext.count)bytes passwordUTF8=\(Array(password.utf8).count)bytes passwordChars=\(password.count)")
// SHA256 first: desktop CryptoJS v4 + both iOS encrypt functions use SHA256.
// SHA1 fallback: legacy messages encrypted before CryptoJS v4 migration.
// SHA256 MUST be first wrong-key AES-CBC can randomly produce valid
// PKCS7 padding (~1/256 chance) and garbage may survive zlib inflate,
// causing false-positive decryption with corrupt data.
let prfOrder: [CCPseudoRandomAlgorithm] = [ let prfOrder: [CCPseudoRandomAlgorithm] = [
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop CryptoJS v4 + iOS CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy fallback CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1),
] ]
// 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate) // 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate)
for prf in prfOrder { for prf in prfOrder {
let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1"
do { do {
let result = try decryptWithPassword( return try decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password, ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: true prf: prf, expectsCompressed: true
) )
print("🔐 [decrypt] ✅ \(prfName)+compressed succeeded, result=\(result.count)bytes, first4=\(result.prefix(4).map { String(format: "%02x", $0) }.joined())") } catch { }
return result }
} catch {
print("🔐 [decrypt] ⚠️ \(prfName)+compressed failed: \(error)") // 2) Fallback: AES-CBC without compression (very old/legacy payloads).
// Skipped when requireCompression is true prevents wrong-password garbage
// from being accepted as valid data.
if !requireCompression {
for prf in prfOrder {
do {
return try decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: false
)
} catch { }
} }
} }
// 2) Fallback: AES-CBC without compression (very old/legacy payloads)
for prf in prfOrder {
let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1"
do {
let result = try decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: false
)
print("🔐 [decrypt] ✅ \(prfName)+uncompressed succeeded, result=\(result.count)bytes, first4=\(result.prefix(4).map { String(format: "%02x", $0) }.joined())")
return result
} catch {
print("🔐 [decrypt] ⚠️ \(prfName)+uncompressed failed: \(error)")
}
}
print("🔐 [decrypt] ❌ ALL paths failed")
throw CryptoError.decryptionFailed throw CryptoError.decryptionFailed
} }
@@ -180,7 +173,6 @@ private extension CryptoManager {
prf: CCPseudoRandomAlgorithm, prf: CCPseudoRandomAlgorithm,
expectsCompressed: Bool expectsCompressed: Bool
) throws -> Data { ) throws -> Data {
let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1"
let key = CryptoPrimitives.pbkdf2( let key = CryptoPrimitives.pbkdf2(
password: password, password: password,
salt: "rosetta", salt: "rosetta",
@@ -188,9 +180,7 @@ private extension CryptoManager {
keyLength: 32, keyLength: 32,
prf: prf prf: prf
) )
print("🔐 [decrypt-inner] \(prfName) pbkdf2Key=\(key.prefix(8).map { String(format: "%02x", $0) }.joined()) passwordUTF8Len=\(Array(password.utf8).count)")
let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv) let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv)
print("🔐 [decrypt-inner] \(prfName) aesDecrypted=\(decrypted.count)bytes first16=\(decrypted.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " "))")
if expectsCompressed { if expectsCompressed {
return try CryptoPrimitives.rawInflate(decrypted) return try CryptoPrimitives.rawInflate(decrypted)
} }

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import CommonCrypto import CommonCrypto
import Compression import Compression
import zlib
// MARK: - CryptoPrimitives // MARK: - CryptoPrimitives
@@ -122,34 +123,28 @@ enum CryptoPrimitives {
extension CryptoPrimitives { extension CryptoPrimitives {
/// Zlib-wrapped deflate compression (0x78 header + raw deflate + adler32 trailer). /// Zlib-wrapped deflate compression using the system C zlib library (`compress2()`).
/// Compatible with desktop `pako.deflate()` and Node.js `zlib.deflateSync()`. /// Produces standard RFC 1950 zlib output compatible with desktop `pako.inflate()`
/// Desktop CryptoJS uses `pako.deflate()` which produces zlib-wrapped output; /// and Node.js `zlib.inflateSync()`.
/// `pako.inflate()` on the desktop expects this format raw deflate will fail. ///
/// Previously used Apple's `compression_encode_buffer(COMPRESSION_ZLIB)` (raw deflate)
/// with a manual zlib wrapper that output was incompatible with pako.inflate().
static func zlibDeflate(_ data: Data) throws -> Data { static func zlibDeflate(_ data: Data) throws -> Data {
let raw = try rawDeflate(data) let sourceLen = uLong(data.count)
var result = Data(capacity: 2 + raw.count + 4) var destLen = compressBound(sourceLen)
// zlib header: CMF=0x78 (deflate method, 32K window), FLG=0x9C (default level, check bits) var dest = Data(count: Int(destLen))
result.append(contentsOf: [0x78, 0x9C] as [UInt8])
result.append(raw)
// Adler-32 checksum of the original uncompressed data (big-endian)
let checksum = adler32(data)
result.append(UInt8((checksum >> 24) & 0xFF))
result.append(UInt8((checksum >> 16) & 0xFF))
result.append(UInt8((checksum >> 8) & 0xFF))
result.append(UInt8(checksum & 0xFF))
return result
}
/// Adler-32 checksum (used for zlib trailer). let status = dest.withUnsafeMutableBytes { destPtr in
private static func adler32(_ data: Data) -> UInt32 { data.withUnsafeBytes { srcPtr in
var a: UInt32 = 1 guard let dBase = destPtr.bindMemory(to: Bytef.self).baseAddress,
var b: UInt32 = 0 let sBase = srcPtr.bindMemory(to: Bytef.self).baseAddress else {
for byte in data { return Z_MEM_ERROR
a = (a &+ UInt32(byte)) % 65521 }
b = (b &+ a) % 65521 return compress2(dBase, &destLen, sBase, sourceLen, Z_DEFAULT_COMPRESSION)
}
} }
return (b << 16) | a guard status == Z_OK else { throw CryptoError.compressionFailed }
return dest.prefix(Int(destLen))
} }
/// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)). /// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)).

View File

@@ -21,11 +21,14 @@ enum MessageCrypto {
/// - encryptedKey: Base64-encoded ECDH-encrypted key+nonce (`iv:encryptedKey:ephemeralPrivateKey`). /// - encryptedKey: Base64-encoded ECDH-encrypted key+nonce (`iv:encryptedKey:ephemeralPrivateKey`).
/// - myPrivateKeyHex: Recipient's secp256k1 private key (hex). /// - myPrivateKeyHex: Recipient's secp256k1 private key (hex).
/// - Returns: Decrypted plaintext string. /// - Returns: Decrypted plaintext string.
static func decryptIncoming( /// Decrypts an incoming message and returns both plaintext and the working key+nonce.
/// The returned `keyAndNonce` is the candidate that successfully decrypted the message
/// critical for deriving the correct attachment password.
static func decryptIncomingFull(
ciphertext: String, ciphertext: String,
encryptedKey: String, encryptedKey: String,
myPrivateKeyHex: String myPrivateKeyHex: String
) throws -> String { ) throws -> (text: String, keyAndNonce: Data) {
let keyCandidates = try decryptKeyFromSenderCandidates( let keyCandidates = try decryptKeyFromSenderCandidates(
encryptedKey: encryptedKey, encryptedKey: encryptedKey,
myPrivateKeyHex: myPrivateKeyHex myPrivateKeyHex: myPrivateKeyHex
@@ -34,7 +37,8 @@ enum MessageCrypto {
var lastError: Error? var lastError: Error?
for keyAndNonce in keyCandidates where keyAndNonce.count >= 56 { for keyAndNonce in keyCandidates where keyAndNonce.count >= 56 {
do { do {
return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: keyAndNonce) let text = try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: keyAndNonce)
return (text, keyAndNonce)
} catch { } catch {
lastError = error lastError = error
} }
@@ -46,6 +50,18 @@ enum MessageCrypto {
throw CryptoError.invalidData("Failed to decrypt message content with all key candidates") throw CryptoError.invalidData("Failed to decrypt message content with all key candidates")
} }
static func decryptIncoming(
ciphertext: String,
encryptedKey: String,
myPrivateKeyHex: String
) throws -> String {
try decryptIncomingFull(
ciphertext: ciphertext,
encryptedKey: encryptedKey,
myPrivateKeyHex: myPrivateKeyHex
).text
}
/// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient. /// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient.
/// - Parameters: /// - Parameters:
/// - plaintext: The message text. /// - plaintext: The message text.
@@ -91,14 +107,24 @@ enum MessageCrypto {
} }
/// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path). /// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path).
/// Used for decrypting MESSAGES-type attachment blobs (desktop parity). /// Verifies each candidate by attempting XChaCha20 decryption to find the correct one.
/// Falls back to first candidate if ciphertext is unavailable.
static func extractDecryptedKeyData( static func extractDecryptedKeyData(
encryptedKey: String, encryptedKey: String,
myPrivateKeyHex: String myPrivateKeyHex: String,
verifyCiphertext: String? = nil
) -> Data? { ) -> Data? {
guard let candidates = try? decryptKeyFromSenderCandidates( guard let candidates = try? decryptKeyFromSenderCandidates(
encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex
) else { return nil } ) else { return nil }
// When ciphertext is available, verify each candidate via XChaCha20 decryption
if let ciphertext = verifyCiphertext {
for candidate in candidates where candidate.count >= 56 {
if (try? decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: candidate)) != nil {
return candidate
}
}
}
return candidates.first return candidates.first
} }
@@ -111,6 +137,162 @@ 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: "?") })
} }
// MARK: - Attachment Password Helpers
/// Returns password candidates from a stored attachment password string.
/// New format: `"rawkey:<hex>"` derives Android (`bytesToJsUtf8String`) + WHATWG passwords.
/// Legacy format: plain string used as-is (backward compat with persisted messages).
static func attachmentPasswordCandidates(from stored: String) -> [String] {
if stored.hasPrefix("rawkey:") {
let hex = String(stored.dropFirst("rawkey:".count))
let keyData = Data(hexString: hex)
let androidPwd = bytesToAndroidUtf8String(keyData)
let whatwgPwd = String(decoding: keyData, as: UTF8.self)
// Latin-1 variant: backward compat with iOS builds that used .isoLatin1 encoding
let latin1Pwd = String(bytes: keyData, encoding: .isoLatin1)
var candidates = [androidPwd, whatwgPwd]
if let latin1Pwd, latin1Pwd != androidPwd, latin1Pwd != whatwgPwd {
candidates.append(latin1Pwd)
}
// Deduplicate while preserving order
var seen = Set<String>()
return candidates.filter { seen.insert($0).inserted }
}
return [stored]
}
// MARK: - Android-Compatible UTF-8 Decoder
/// Port of Android `bytesToJsUtf8String()` manual UTF-8 decoder that emits
/// ONE U+FFFD PER BYTE consumed in failed multi-byte sequences.
///
/// This differs from WHATWG (Swift's `String(decoding:as:UTF8.self)`) which emits
/// ONE U+FFFD per "maximal subpart" of an ill-formed subsequence.
///
/// Example: bytes `[0xEF, 0x98, 0xF7]` (3-byte starter, valid continuation, invalid 2nd):
/// - WHATWG: 1× U+FFFD (for maximal subpart [EF,98]), then re-scan F7
/// - Android: 2× U+FFFD (one for EF, one for 98), then re-scan F7
///
/// Used for PBKDF2 password derivation from random key+nonce bytes
/// to match Android's attachment encryption.
static func bytesToAndroidUtf8String(_ bytes: Data) -> String {
var result = ""
result.reserveCapacity(bytes.count)
var i = bytes.startIndex
while i < bytes.endIndex {
let b0 = Int(bytes[i])
if b0 <= 0x7F {
// ASCII
result.append(Character(UnicodeScalar(b0)!))
i += 1
} else if b0 <= 0xBF {
// Orphan continuation byte
result.append("\u{FFFD}")
i += 1
} else if b0 <= 0xDF {
// 2-byte sequence
if i + 1 >= bytes.endIndex {
result.append("\u{FFFD}")
i += 1
} else {
let b1 = Int(bytes[i + 1])
if b1 & 0xC0 != 0x80 {
result.append("\u{FFFD}")
i += 1
} else {
let cp = ((b0 & 0x1F) << 6) | (b1 & 0x3F)
if cp < 0x80 || b0 == 0xC0 || b0 == 0xC1 {
// Overlong
result.append("\u{FFFD}")
result.append("\u{FFFD}")
} else {
result.append(Character(UnicodeScalar(cp)!))
}
i += 2
}
}
} else if b0 <= 0xEF {
// 3-byte sequence
if i + 2 >= bytes.endIndex {
let remaining = bytes.endIndex - i
for _ in 0..<remaining { result.append("\u{FFFD}") }
i = bytes.endIndex
} else {
let b1 = Int(bytes[i + 1])
let b2 = Int(bytes[i + 2])
if b1 & 0xC0 != 0x80 {
// Invalid first continuation
result.append("\u{FFFD}")
i += 1
} else if b2 & 0xC0 != 0x80 {
// Invalid second continuation emit for first two bytes
result.append("\u{FFFD}")
result.append("\u{FFFD}")
i += 2
} else {
let cp = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F)
if cp < 0x800 || (cp >= 0xD800 && cp <= 0xDFFF) {
// Overlong or surrogate
result.append("\u{FFFD}")
result.append("\u{FFFD}")
result.append("\u{FFFD}")
} else {
result.append(Character(UnicodeScalar(cp)!))
}
i += 3
}
}
} else if b0 <= 0xF7 {
// 4-byte sequence
if i + 3 >= bytes.endIndex {
let remaining = bytes.endIndex - i
for _ in 0..<remaining { result.append("\u{FFFD}") }
i = bytes.endIndex
} else {
let b1 = Int(bytes[i + 1])
let b2 = Int(bytes[i + 2])
let b3 = Int(bytes[i + 3])
if b1 & 0xC0 != 0x80 {
result.append("\u{FFFD}")
i += 1
} else if b2 & 0xC0 != 0x80 {
result.append("\u{FFFD}")
result.append("\u{FFFD}")
i += 2
} else if b3 & 0xC0 != 0x80 {
result.append("\u{FFFD}")
result.append("\u{FFFD}")
result.append("\u{FFFD}")
i += 3
} else {
let cp = ((b0 & 0x07) << 18) | ((b1 & 0x3F) << 12)
| ((b2 & 0x3F) << 6) | (b3 & 0x3F)
if cp < 0x10000 || cp > 0x10FFFF {
// Overlong or out-of-range
result.append("\u{FFFD}")
result.append("\u{FFFD}")
result.append("\u{FFFD}")
result.append("\u{FFFD}")
} else {
result.append(Character(UnicodeScalar(cp)!))
}
i += 4
}
}
} else {
// Invalid starter byte (0xF8-0xFF)
result.append("\u{FFFD}")
i += 1
}
}
return result
}
} }
// MARK: - ECDH Key Exchange // MARK: - ECDH Key Exchange

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
/// Single message inside a direct chat dialog. /// Single message inside a direct chat dialog.
struct ChatMessage: Identifiable, Codable, Sendable { struct ChatMessage: Identifiable, Codable, Sendable, Equatable {
let id: String let id: String
var fromPublicKey: String var fromPublicKey: String
var toPublicKey: String var toPublicKey: String

View File

@@ -90,6 +90,7 @@ final class DialogRepository {
_sortedKeysCache = nil _sortedKeysCache = nil
storagePassword = "" storagePassword = ""
UNUserNotificationCenter.current().setBadgeCount(0) UNUserNotificationCenter.current().setBadgeCount(0)
UserDefaults.standard.set(0, forKey: "app_badge_count")
guard !currentAccount.isEmpty else { return } guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount let accountToReset = currentAccount
@@ -487,6 +488,7 @@ final class DialogRepository {
private func updateAppBadge() { private func updateAppBadge() {
let total = dialogs.values.reduce(0) { $0 + $1.unreadCount } let total = dialogs.values.reduce(0) { $0 + $1.unreadCount }
UNUserNotificationCenter.current().setBadgeCount(total) UNUserNotificationCenter.current().setBadgeCount(total)
UserDefaults.standard.set(total, forKey: "app_badge_count")
} }
private static func dialogsFileName(for accountPublicKey: String) -> String { private static func dialogsFileName(for accountPublicKey: String) -> String {

View File

@@ -136,6 +136,12 @@ final class MessageRepository: ObservableObject {
// no ACK will arrive treat as .delivered immediately. // no ACK will arrive treat as .delivered immediately.
let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered)
// Check BEFORE inserting if this ID was already known (from a previous
// session or evicted from the 40-cap), the message was previously seen.
// During sync, treat previously-seen opponent messages as read to avoid
// inflating unread counts (server doesn't re-send PacketRead during sync).
let wasKnownBefore = allKnownMessageIds.contains(messageId)
messageToDialog[messageId] = dialogKey messageToDialog[messageId] = dialogKey
allKnownMessageIds.insert(messageId) allKnownMessageIds.insert(messageId)
@@ -157,6 +163,11 @@ final class MessageRepository: ObservableObject {
return return
} }
// During sync, previously-known messages (evicted from 40-cap but still
// in allKnownMessageIds) are historical mark as read. Without this,
// reconcileUnreadCounts() would count them as unread and show wrong badges.
let syncRestoredRead = fromSync && wasKnownBefore && !fromMe
messages.append( messages.append(
ChatMessage( ChatMessage(
id: messageId, id: messageId,
@@ -165,7 +176,7 @@ final class MessageRepository: ObservableObject {
text: decryptedText, text: decryptedText,
timestamp: timestamp, timestamp: timestamp,
deliveryStatus: outgoingStatus, deliveryStatus: outgoingStatus,
isRead: incomingRead || fromMe, isRead: incomingRead || fromMe || syncRestoredRead,
attachments: packet.attachments, attachments: packet.attachments,
attachmentPassword: attachmentPassword attachmentPassword: attachmentPassword
) )
@@ -253,17 +264,18 @@ final class MessageRepository: ObservableObject {
schedulePersist() schedulePersist()
} }
/// Android parity: resolve outgoing WAITING messages after reconnect. /// Resolve outgoing messages that need retry after reconnect.
/// Returns messages safe to retry and ids that expired (must be marked as error in dialog state). /// Includes both `.waiting` and `.error` messages error messages are
func resolveWaitingOutgoingMessages( /// retried on reconnect because they likely failed due to connection loss.
/// Messages older than `maxRetryAgeMs` are skipped (stale, user should retry manually).
func resolveRetryableOutgoingMessages(
myPublicKey: String, myPublicKey: String,
nowMs: Int64, nowMs: Int64,
maxAgeMs: Int64 maxRetryAgeMs: Int64
) -> (retryable: [ChatMessage], expired: [(messageId: String, dialogKey: String)]) { ) -> [ChatMessage] {
var retryable: [ChatMessage] = [] var retryable: [ChatMessage] = []
var expired: [(messageId: String, dialogKey: String)] = []
var hasMutations = false var hasMutations = false
let cutoff = nowMs - maxAgeMs let cutoff = nowMs - maxRetryAgeMs
for (dialogKey, currentMessages) in messagesByDialog { for (dialogKey, currentMessages) in messagesByDialog {
var messages = currentMessages var messages = currentMessages
@@ -272,14 +284,16 @@ final class MessageRepository: ObservableObject {
for index in messages.indices { for index in messages.indices {
let message = messages[index] let message = messages[index]
guard message.fromPublicKey == myPublicKey else { continue } guard message.fromPublicKey == myPublicKey else { continue }
guard message.deliveryStatus == .waiting else { continue } guard message.deliveryStatus == .waiting || message.deliveryStatus == .error else { continue }
// Skip messages older than the retry window (stale).
guard message.timestamp >= cutoff else { continue }
if message.timestamp < cutoff { retryable.append(message)
messages[index].deliveryStatus = .error
expired.append((messageId: message.id, dialogKey: dialogKey)) // Reset .error back to .waiting so UI shows clock icon during retry.
if message.deliveryStatus == .error {
messages[index].deliveryStatus = .waiting
mutated = true mutated = true
} else {
retryable.append(message)
} }
} }
@@ -293,7 +307,7 @@ final class MessageRepository: ObservableObject {
schedulePersist() schedulePersist()
} }
return (retryable: retryable, expired: expired) return retryable
} }
/// Delete a single message by ID and persist the change. /// Delete a single message by ID and persist the change.

View File

@@ -63,13 +63,34 @@ enum AttachmentType: Int, Codable {
case avatar = 3 case avatar = 3
} }
struct MessageAttachment: Codable { struct MessageAttachment: Codable, Equatable {
var id: String = "" var id: String = ""
var preview: String = "" var preview: String = ""
var blob: String = "" var blob: String = ""
var type: AttachmentType = .image var type: AttachmentType = .image
} }
// MARK: - Reply Message Data (desktop parity)
/// JSON structure for reply/forward message blobs (AttachmentType.messages).
/// Desktop: `MessageReply` in `useReplyMessages.ts`.
/// Android: `ReplyMessage` in `ChatViewModel.kt`.
struct ReplyMessageData: Codable {
let message_id: String
let publicKey: String
let message: String
let timestamp: Int64
let attachments: [ReplyAttachmentData]
}
/// Attachment inside a reply/forward blob.
struct ReplyAttachmentData: Codable {
let id: String
let type: Int
let preview: String
let blob: String
}
// MARK: - Search User // MARK: - Search User
struct SearchUser { struct SearchUser {

View File

@@ -105,50 +105,17 @@ final class ProtocolManager: @unchecked Sendable {
} }
/// Verify connection health after returning from background. /// Verify connection health after returning from background.
/// If connection appears alive, sends a WebSocket ping to confirm. /// Always force reconnect after background, the socket is likely dead
/// If ping fails or times out (2s), forces immediate reconnection. /// and a 2s ping timeout just delays the inevitable.
func reconnectIfNeeded() { func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return } guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// Don't interrupt active handshake // Don't interrupt active handshake
if connectionState == .handshaking { return } if connectionState == .handshaking { return }
if connectionState == .authenticated && client.isConnected { Self.logger.info("Foreground reconnect — force reconnecting")
// Connection looks alive verify with ping (2s timeout) handshakeComplete = false
Self.logger.info("Verifying connection with ping...") heartbeatTask?.cancel()
let pingTimeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled, let self else { return }
Self.logger.info("Ping timeout — connection dead, force reconnecting")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
}
client.sendPing { [weak self] error in
pingTimeoutTask.cancel()
guard let self else { return }
if let error {
Self.logger.info("Ping failed: \(error.localizedDescription) — force reconnecting")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
} else {
Self.logger.info("Ping succeeded — connection alive")
}
}
return
}
// Not authenticated or not connected force reconnect immediately
Self.logger.info("Force reconnect from foreground")
connectionState = .connecting connectionState = .connecting
client.forceReconnect() client.forceReconnect()
} }
@@ -234,6 +201,18 @@ final class ProtocolManager: @unchecked Sendable {
client.onDataReceived = { [weak self] data in client.onDataReceived = { [weak self] data in
self?.handleIncomingData(data) self?.handleIncomingData(data)
} }
// Instant reconnect when network is restored (Wi-Fi cellular, airplane mode off, etc.)
client.onNetworkRestored = { [weak self] in
guard let self, self.savedPublicKey != nil else { return }
Self.logger.info("Network restored — force reconnecting")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
}
} }
// MARK: - Handshake // MARK: - Handshake
@@ -399,17 +378,21 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = true handshakeComplete = true
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
Task { @MainActor in
self.connectionState = .authenticated
}
flushPacketQueue() flushPacketQueue()
startHeartbeat(interval: packet.heartbeatInterval) startHeartbeat(interval: packet.heartbeatInterval)
// Desktop parity: request transport server URL after handshake. // Desktop parity: request transport server URL after handshake.
sendPacketDirect(PacketRequestTransport()) sendPacketDirect(PacketRequestTransport())
onHandshakeCompleted?(packet) // CRITICAL: set .authenticated and fire callback in ONE MainActor task.
// Previously these were separate tasks Swift doesn't guarantee FIFO
// ordering of unstructured tasks, so requestSynchronize() could race
// with the state change and silently drop the sync request.
let callback = self.onHandshakeCompleted
Task { @MainActor in
self.connectionState = .authenticated
callback?(packet)
}
case .needDeviceVerification: case .needDeviceVerification:
handshakeComplete = false handshakeComplete = false
@@ -429,8 +412,8 @@ final class ProtocolManager: @unchecked Sendable {
private func startHeartbeat(interval: Int) { private func startHeartbeat(interval: Int) {
heartbeatTask?.cancel() heartbeatTask?.cancel()
// Desktop parity: heartbeat at half the server-specified interval. // Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
let intervalNs = UInt64(interval) * 1_000_000_000 / 2 let intervalNs = UInt64(interval) * 1_000_000_000 / 3
heartbeatTask = Task { heartbeatTask = Task {
// Send first heartbeat immediately // Send first heartbeat immediately

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import Network
import os import os
/// Native URLSession-based WebSocket client for Rosetta protocol. /// Native URLSession-based WebSocket client for Rosetta protocol.
@@ -14,16 +15,41 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var hasNotifiedConnected = false private var hasNotifiedConnected = false
private(set) var isConnected = false private(set) var isConnected = false
private var disconnectHandledForCurrentSocket = false private var disconnectHandledForCurrentSocket = false
/// Android parity: exponential backoff counter, reset on successful connection.
private var reconnectAttempts = 0
/// NWPathMonitor for instant reconnect on network changes (Wi-Fi cellular, etc.).
private let networkMonitor = NWPathMonitor()
private var lastNetworkPath: NWPath.Status?
var onConnected: (() -> Void)? var onConnected: (() -> Void)?
var onDisconnected: ((Error?) -> Void)? var onDisconnected: ((Error?) -> Void)?
var onDataReceived: ((Data) -> Void)? var onDataReceived: ((Data) -> Void)?
/// Called when network becomes available and we need to reconnect.
var onNetworkRestored: (() -> Void)?
override init() { override init() {
super.init() super.init()
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.waitsForConnectivity = true config.waitsForConnectivity = true
session = URLSession(configuration: config, delegate: self, delegateQueue: nil) session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
startNetworkMonitor()
}
private func startNetworkMonitor() {
networkMonitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let previous = self.lastNetworkPath
self.lastNetworkPath = path.status
// Only trigger on transition to .satisfied (network restored).
// Skip the initial callback (previous == nil).
if path.status == .satisfied, previous != nil, previous != .satisfied {
Self.logger.info("Network restored — triggering reconnect")
self.onNetworkRestored?()
}
}
networkMonitor.start(queue: DispatchQueue(label: "com.rosetta.networkMonitor"))
} }
// MARK: - Connection // MARK: - Connection
@@ -119,6 +145,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
disconnectHandledForCurrentSocket = false disconnectHandledForCurrentSocket = false
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil reconnectTask = nil
reconnectAttempts = 0
onConnected?() onConnected?()
} }
@@ -169,11 +196,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return } guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return } guard reconnectTask == nil else { return }
// Fixed 5-second reconnect interval (desktop parity) // Android parity: exponential backoff 1s, 2s, 4s, 8s, 16s (cap).
let delaySeconds: Double = 5.0 reconnectAttempts += 1
let exponent = min(reconnectAttempts - 1, 4)
let delayMs = min(1000 * (1 << exponent), 16000)
reconnectTask = Task { [weak self] in reconnectTask = Task { [weak self] in
Self.logger.info("Reconnecting in 5s...") Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
guard let self, !isManuallyClosed, !Task.isCancelled else { return } guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil self.reconnectTask = nil
self.connect() self.connect()

View File

@@ -2,6 +2,7 @@ import Foundation
import Observation import Observation
import os import os
import UIKit import UIKit
import CommonCrypto
/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. /// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle.
@Observable @Observable
@@ -239,7 +240,7 @@ final class SessionManager {
// Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments) // Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments)
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 4)) ?? "" let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
let preview = "\(tag)::\(blurhash)" let preview = "\(tag)::\(blurhash)"
// Build aesChachaKey (same as regular messages) // Build aesChachaKey (same as regular messages)
@@ -287,7 +288,8 @@ final class SessionManager {
) )
MessageRepository.shared.upsertFromMessagePacket( MessageRepository.shared.upsertFromMessagePacket(
packet, myPublicKey: currentPublicKey, decryptedText: " ", packet, myPublicKey: currentPublicKey, decryptedText: " ",
attachmentPassword: latin1String, fromSync: offlineAsSend attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
fromSync: offlineAsSend
) )
if offlineAsSend { if offlineAsSend {
@@ -340,11 +342,24 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey recipientPublicKeyHex: toPublicKey
) )
// Derive attachment password from plainKeyAndNonce (desktop: key.toString('utf-8')) // Attachment password: WHATWG UTF-8 of raw key+nonce bytes.
// Must use UTF-8 decoding with replacement characters (U+FFFD for invalid sequences) // Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation.
// to match Node.js Buffer.toString('utf-8') behavior exactly. let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Previously used .isoLatin1 which produced different PBKDF2 keys for bytes > 0x7F.
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) #if DEBUG
// Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS.
// To verify on desktop, run in dev console:
// CryptoJS.PBKDF2("<password>", "rosetta", {keySize:8, iterations:1000}).toString()
// and compare with the pbkdf2Key logged below.
let pwdUtf8Bytes = Array(attachmentPassword.utf8)
let pbkdf2Key = CryptoPrimitives.pbkdf2(
password: attachmentPassword, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
Self.logger.debug("📎 rawKey: \(encrypted.plainKeyAndNonce.hexString)")
Self.logger.debug("📎 pwdUTF8(\(pwdUtf8Bytes.count)b): \(Data(pwdUtf8Bytes).hexString)")
Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
#endif
// Process each attachment: encrypt upload build metadata // Process each attachment: encrypt upload build metadata
var messageAttachments: [MessageAttachment] = [] var messageAttachments: [MessageAttachment] = []
@@ -352,25 +367,70 @@ final class SessionManager {
// Build data URI (desktop: FileReader.readAsDataURL) // Build data URI (desktop: FileReader.readAsDataURL)
let dataURI = buildDataURI(attachment) let dataURI = buildDataURI(attachment)
#if DEBUG
Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)")
#endif
// Encrypt blob with desktop-compatible encryption // Encrypt blob with desktop-compatible encryption
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8), Data(dataURI.utf8),
password: latin1String password: attachmentPassword
) )
#if DEBUG
// Log IV and ciphertext prefix for cross-platform verification.
let blobParts = encryptedBlob.components(separatedBy: ":")
if blobParts.count == 2, let ivData = Data(base64Encoded: blobParts[0]) {
Self.logger.debug("📎 blob IV: \(ivData.hexString), ct(\(blobParts[1].count) b64chars)")
}
// Self-test: decrypt with the SAME WHATWG password.
if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
encryptedBlob, password: attachmentPassword, requireCompression: true
), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true {
Self.logger.debug("📎 Blob self-test PASSED")
} else {
Self.logger.error("📎 Blob self-test FAILED — blob may not decrypt on desktop")
}
#endif
// Upload to transport server // Upload to transport server
let uploadData = Data(encryptedBlob.utf8)
let uploadHash = CryptoManager.shared.sha256(uploadData)
let tag = try await TransportManager.shared.uploadFile( let tag = try await TransportManager.shared.uploadFile(
id: attachment.id, id: attachment.id,
content: Data(encryptedBlob.utf8) content: uploadData
) )
#if DEBUG
Self.logger.debug("📎 Uploaded tag=\(tag), \(uploadData.count) bytes, sha256=\(uploadHash.hexString)")
// Transport round-trip verification: download the blob back and compare SHA256.
// This catches CDN corruption, partial uploads, and encoding issues.
do {
let verifyData = try await TransportManager.shared.downloadFile(tag: tag)
let verifyHash = CryptoManager.shared.sha256(verifyData)
if uploadHash == verifyHash {
Self.logger.debug("📎 Transport verify PASS: tag=\(tag), \(verifyData.count) bytes")
} else {
Self.logger.error("📎 ❌ TRANSPORT MISMATCH tag=\(tag): uploaded \(uploadData.count)b sha=\(uploadHash.hexString), downloaded \(verifyData.count)b sha=\(verifyHash.hexString)")
// Log first 100 bytes of each for comparison
let upPrefix = String(data: uploadData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
let downStr = String(data: verifyData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
Self.logger.error("📎 ❌ Upload prefix: \(upPrefix)")
Self.logger.error("📎 ❌ Download prefix: \(downStr)")
}
} catch {
Self.logger.error("📎 ❌ Transport verify FAILED to download tag=\(tag): \(error)")
}
#endif
// Build preview string (format depends on type) // Build preview string (format depends on type)
// Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files // Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files
let preview: String let preview: String
switch attachment.type { switch attachment.type {
case .image: case .image:
// Generate blurhash from thumbnail (desktop: encode(imageData, width, height, 4, 4)) // Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3))
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 4)) ?? "" let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
preview = "\(tag)::\(blurhash)" preview = "\(tag)::\(blurhash)"
case .file: case .file:
// Desktop: preview = "tag::size::filename" // Desktop: preview = "tag::size::filename"
@@ -389,13 +449,41 @@ final class SessionManager {
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)") Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)")
} }
// Build aesChachaKey (for sync/backup same as regular messages) // Build aesChachaKey (for sync/backup same encoding as makeOutgoingPacket).
let aesChachaPayload = Data(latin1String.utf8) // MUST use Latin-1 (not WHATWG UTF-8) so desktop can recover original raw bytes
// via Buffer.from(decryptedString, 'binary') which takes the low byte of each char.
// Latin-1 maps every byte 0x00-0xFF to its codepoint losslessly.
// WHATWG UTF-8 replaces invalid sequences with U+FFFD (codepoint 0xFFFD)
// Buffer.from('\uFFFD', 'binary') recovers 0xFD, not the original byte.
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed
}
let aesChachaPayload = Data(latin1ForSync.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload, aesChachaPayload,
password: privKey password: privKey
) )
#if DEBUG
// aesChachaKey round-trip self-test: simulates EXACT desktop sync chain.
do {
let rtDecrypted = try CryptoManager.shared.decryptWithPassword(
aesChachaKey, password: privKey, requireCompression: true
)
guard let rtString = String(data: rtDecrypted, encoding: .utf8) else {
Self.logger.error("📎 aesChachaKey FAILED — not valid UTF-8")
throw CryptoError.decryptionFailed
}
// Simulate Buffer.from(string, 'binary').toString('utf-8')
let rtRawBytes = Data(rtString.unicodeScalars.map { UInt8($0.value & 0xFF) })
let rtPassword = String(decoding: rtRawBytes, as: UTF8.self)
let match = rtPassword == attachmentPassword
Self.logger.debug("📎 aesChachaKey roundtrip: \(match ? "PASS" : "FAIL") (\(rtRawBytes.count) bytes recovered)")
} catch {
Self.logger.error("📎 aesChachaKey roundtrip FAILED: \(error)")
}
#endif
// Build PacketMessage with attachments // Build PacketMessage with attachments
var packet = PacketMessage() var packet = PacketMessage()
packet.fromPublicKey = currentPublicKey packet.fromPublicKey = currentPublicKey
@@ -432,7 +520,7 @@ final class SessionManager {
packet, packet,
myPublicKey: currentPublicKey, myPublicKey: currentPublicKey,
decryptedText: displayText, decryptedText: displayText,
attachmentPassword: latin1String, attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
fromSync: offlineAsSend fromSync: offlineAsSend
) )
@@ -488,6 +576,145 @@ final class SessionManager {
} }
} }
/// Sends a message with a reply/forward blob (AttachmentType.messages).
///
/// Desktop parity: `prepareAttachmentsToSend()` in `DialogProvider.tsx`
/// for MESSAGES type: `encodeWithPassword(chacha_key_utf8, JSON.stringify(reply))`.
/// Android: `ChatViewModel.sendMessageWithReply()`.
///
/// The reply blob is a JSON array of `ReplyMessageData` objects, encrypted with
/// `encryptWithPasswordDesktopCompat()` using the WHATWG UTF-8 of plainKeyAndNonce.
func sendMessageWithReply(
text: String,
replyMessages: [ReplyMessageData],
toPublicKey: String,
opponentTitle: String = "",
opponentUsername: String = ""
) async throws {
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
Self.logger.error("📤 Cannot send reply — missing keys")
throw CryptoError.decryptionFailed
}
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
// Encrypt message text (use single space if empty desktop parity)
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text
let encrypted = try MessageCrypto.encryptOutgoing(
plaintext: messageText,
recipientPublicKeyHex: toPublicKey
)
// Desktop parity: reply blob password = WHATWG UTF-8 of raw plainKeyAndNonce bytes.
// Same as attachment password derivation.
let replyPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Build the reply JSON blob
let replyJSON = try JSONEncoder().encode(replyMessages)
guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else {
throw CryptoError.encryptionFailed
}
// Encrypt reply blob with desktop-compatible encryption
let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
replyJSON,
password: replyPassword
)
#if DEBUG
Self.logger.debug("📤 Reply blob: \(replyJSON.count) raw → \(encryptedReplyBlob.count) encrypted bytes")
#endif
// Build reply attachment
let replyAttachmentId = "reply_\(timestamp)"
let replyAttachment = MessageAttachment(
id: replyAttachmentId,
preview: "",
blob: encryptedReplyBlob,
type: .messages
)
// Build aesChachaKey (same as sendMessageWithAttachments)
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed
}
let aesChachaPayload = Data(latin1ForSync.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload,
password: privKey
)
// Build packet for wire (encrypted blob)
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 = [replyAttachment]
// Build a local copy with decrypted blob for UI storage
// (incoming messages get decrypted in handleIncomingMessage; outgoing must be pre-decrypted)
let localReplyAttachment = MessageAttachment(
id: replyAttachmentId,
preview: "",
blob: replyJSONString, // Decrypted JSON for local rendering
type: .messages
)
var localPacket = packet
localPacket.attachments = [localReplyAttachment]
// 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
)
// Optimistic UI update use localPacket (decrypted blob) for storage
let isConnected = ProtocolManager.shared.connectionState == .authenticated
let offlineAsSend = !isConnected
let displayText = messageText == " " ? " " : messageText
DialogRepository.shared.updateFromMessage(
localPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
)
MessageRepository.shared.upsertFromMessagePacket(
localPacket,
myPublicKey: currentPublicKey,
decryptedText: displayText,
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
fromSync: offlineAsSend
)
if offlineAsSend {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, opponentKey: toPublicKey, status: .error
)
}
// Saved Messages: local-only
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, opponentKey: toPublicKey, status: .delivered
)
return
}
ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet)
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
}
/// 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).
func sendTypingIndicator(toPublicKey: String) { func sendTypingIndicator(toPublicKey: String) {
guard toPublicKey != currentPublicKey, guard toPublicKey != currentPublicKey,
@@ -735,7 +962,7 @@ final class SessionManager {
// Desktop parity (useSynchronize.ts): await whenFinish() then // Desktop parity (useSynchronize.ts): await whenFinish() then
// save server cursor and request next batch. // save server cursor and request next batch.
await self.waitForInboundQueueToDrain() await self.waitForInboundQueueToDrain()
let serverCursor = self.normalizeSyncTimestamp(packet.timestamp) let serverCursor = packet.timestamp
self.saveLastSyncTimestamp(serverCursor) self.saveLastSyncTimestamp(serverCursor)
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
self.requestSynchronize(cursor: serverCursor) self.requestSynchronize(cursor: serverCursor)
@@ -846,11 +1073,7 @@ final class SessionManager {
let fromMe = packet.fromPublicKey == myKey let fromMe = packet.fromPublicKey == myKey
// DEBUG: log every call to processIncomingMessage
print("🔍 [processIncoming] msgId=\(packet.messageId.prefix(8))… from=\(packet.fromPublicKey.prefix(12))… to=\(packet.toPublicKey.prefix(12))… fromMe=\(fromMe) content=\(packet.content.count)chars chacha=\(packet.chachaKey.count)chars aesChacha=\(packet.aesChachaKey.count)chars attachments=\(packet.attachments.count)")
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else {
print("🔍 [processIncoming] ❌ SKIPPED by isSupportedDirectMessagePacket — myKey=\(myKey.prefix(12))")
return return
} }
@@ -864,7 +1087,7 @@ final class SessionManager {
) )
guard let result = decryptResult else { guard let result = decryptResult else {
print("🔍 [processIncoming] ❌ decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8)) isOwnMessage=\(fromMe) privateKeyHex=\(currentPrivateKeyHex != nil ? "present" : "NIL") content.isEmpty=\(packet.content.isEmpty) chachaKey.isEmpty=\(packet.chachaKey.isEmpty) aesChachaKey.isEmpty=\(packet.aesChachaKey.isEmpty)") Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))")
return return
} }
let text = result.text let text = result.text
@@ -883,42 +1106,78 @@ final class SessionManager {
} }
if let keyData = result.rawKeyData { if let keyData = result.rawKeyData {
// Desktop parity: Buffer.toString('utf-8') replaces invalid UTF-8 bytes with U+FFFD. // Store raw key data as hex for on-demand password derivation at download time.
// Must match sending side encoding for correct PBKDF2 key derivation. // Android and Desktop/iOS use different UTF-8 decoders for password derivation,
let attachmentPassword = String(decoding: keyData, as: UTF8.self) // so we need both variants. `attachmentPasswordCandidates()` derives them.
resolvedAttachmentPassword = attachmentPassword resolvedAttachmentPassword = "rawkey:" + keyData.hexString
print("🔑 [attachPwd] rawKeyData(\(keyData.count)bytes)=\(keyData.map { String(format: "%02x", $0) }.joined(separator: " "))") let passwordCandidates = MessageCrypto.attachmentPasswordCandidates(
print("🔑 [attachPwd] passwordUTF8(\(Array(attachmentPassword.utf8).count)bytes)=\(Array(attachmentPassword.utf8).map { String(format: "%02x", $0) }.joined(separator: " "))") from: resolvedAttachmentPassword!
print("🔑 [attachPwd] passwordChars=\(attachmentPassword.count)") )
Self.logger.debug("attachPwd: rawKeyData(\(keyData.count)bytes) candidates=\(passwordCandidates.count)")
for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
let blob = processedPacket.attachments[i].blob let blob = processedPacket.attachments[i].blob
if !blob.isEmpty, guard !blob.isEmpty else { continue }
let decrypted = try? CryptoManager.shared.decryptWithPassword(blob, password: attachmentPassword), var decrypted = false
let decryptedString = String(data: decrypted, encoding: .utf8) { // Try with requireCompression first to avoid wrong-key garbage
processedPacket.attachments[i].blob = decryptedString for password in passwordCandidates {
if let data = try? CryptoManager.shared.decryptWithPassword(
blob, password: password, requireCompression: true
),
let decryptedString = String(data: data, encoding: .utf8) {
processedPacket.attachments[i].blob = decryptedString
decrypted = true
break
}
}
// Fallback: try without requireCompression (legacy uncompressed)
if !decrypted {
for password in passwordCandidates {
if let data = try? CryptoManager.shared.decryptWithPassword(
blob, password: password
),
let decryptedString = String(data: data, encoding: .utf8) {
processedPacket.attachments[i].blob = decryptedString
break
}
}
} }
} }
// Desktop parity: auto-download AVATAR attachments from transport server. // Desktop parity: auto-download AVATAR attachments from transport server.
// Flow: extract tag from preview download from transport decrypt with chacha key save.
let crypto = CryptoManager.shared let crypto = CryptoManager.shared
for attachment in processedPacket.attachments where attachment.type == .avatar { for attachment in processedPacket.attachments where attachment.type == .avatar {
let senderKey = packet.fromPublicKey let senderKey = packet.fromPublicKey
let preview = attachment.preview let preview = attachment.preview
// Desktop parity: preview = "tag::blurhash"
let tag = preview.components(separatedBy: "::").first ?? preview let tag = preview.components(separatedBy: "::").first ?? preview
guard !tag.isEmpty else { continue } guard !tag.isEmpty else { continue }
let password = attachmentPassword let passwords = passwordCandidates
Task { Task {
do { do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
// Decrypt with the same password used for MESSAGES attachments var decryptedData: Data?
let decryptedData = try crypto.decryptWithPassword( for password in passwords {
encryptedString, password: password if let data = try? crypto.decryptWithPassword(
) encryptedString, password: password, requireCompression: true
// Decrypted data is the base64-encoded avatar image ) {
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) { if let base64String = String(data: decryptedData, encoding: .utf8) {
AvatarRepository.shared.saveAvatarFromBase64( AvatarRepository.shared.saveAvatarFromBase64(
base64String, publicKey: senderKey base64String, publicKey: senderKey
@@ -933,16 +1192,21 @@ final class SessionManager {
} }
} }
// For outgoing messages received from the server (sent by another device
// on the same account), treat as sync-equivalent so status = .delivered.
// Without this, real-time fromMe messages get .waiting timeout .error.
let effectiveFromSync = syncBatchInProgress || fromMe
DialogRepository.shared.updateFromMessage( DialogRepository.shared.updateFromMessage(
processedPacket, myPublicKey: myKey, decryptedText: text, processedPacket, myPublicKey: myKey, decryptedText: text,
fromSync: syncBatchInProgress, isNewMessage: !wasKnownBefore fromSync: effectiveFromSync, isNewMessage: !wasKnownBefore
) )
MessageRepository.shared.upsertFromMessagePacket( MessageRepository.shared.upsertFromMessagePacket(
processedPacket, processedPacket,
myPublicKey: myKey, myPublicKey: myKey,
decryptedText: text, decryptedText: text,
attachmentPassword: resolvedAttachmentPassword, attachmentPassword: resolvedAttachmentPassword,
fromSync: syncBatchInProgress fromSync: effectiveFromSync
) )
// Desktop parity: if we received a message from the opponent (not our own), // Desktop parity: if we received a message from the opponent (not our own),
@@ -1055,11 +1319,18 @@ final class SessionManager {
} }
private func requestSynchronize(cursor: Int64? = nil) { private func requestSynchronize(cursor: Int64? = nil) {
guard ProtocolManager.shared.connectionState == .authenticated else { return } // No connectionState guard: this method is only called from (1) handshake
// completion handler and (2) BATCH_END handler both inherently authenticated.
// The old `connectionState == .authenticated` guard caused a race condition:
// ProtocolManager sets .authenticated in a separate MainActor task, so if
// requestSynchronize() ran first, the guard silently dropped the sync request.
guard !syncRequestInFlight else { return } guard !syncRequestInFlight else { return }
syncRequestInFlight = true syncRequestInFlight = true
let lastSync = normalizeSyncTimestamp(cursor ?? loadLastSyncTimestamp()) // Desktop parity: pass server cursor as-is (seconds). NO normalization
// server uses seconds, converting to milliseconds made the server see a
// "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs.
let lastSync = cursor ?? loadLastSyncTimestamp()
var packet = PacketSync() var packet = PacketSync()
packet.status = .notNeeded packet.status = .notNeeded
@@ -1071,20 +1342,24 @@ final class SessionManager {
private func loadLastSyncTimestamp() -> Int64 { private func loadLastSyncTimestamp() -> Int64 {
guard !currentPublicKey.isEmpty else { return 0 } guard !currentPublicKey.isEmpty else { return 0 }
return Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey))
// Migration: old code normalized seconds milliseconds. If the stored value
// is in milliseconds (>= 1 trillion), convert back to seconds for server parity.
if stored >= 1_000_000_000_000 {
let corrected = stored / 1000
UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey)
return corrected
}
return stored
} }
private func saveLastSyncTimestamp(_ raw: Int64) { private func saveLastSyncTimestamp(_ raw: Int64) {
guard !currentPublicKey.isEmpty else { return } guard !currentPublicKey.isEmpty else { return }
let normalized = normalizeSyncTimestamp(raw) // Desktop parity: store server cursor as-is (seconds), no normalization.
guard normalized > 0 else { return } guard raw > 0 else { return }
let existing = loadLastSyncTimestamp() let existing = loadLastSyncTimestamp()
guard normalized > existing else { return } guard raw > existing else { return }
UserDefaults.standard.set(Int(normalized), forKey: syncCursorKey) UserDefaults.standard.set(Int(raw), forKey: syncCursorKey)
}
private func normalizeSyncTimestamp(_ raw: Int64) -> Int64 {
raw < 1_000_000_000_000 ? raw * 1000 : raw
} }
/// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption. /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption.
@@ -1096,51 +1371,45 @@ final class SessionManager {
let isOwnMessage = packet.fromPublicKey == myPublicKey let isOwnMessage = packet.fromPublicKey == myPublicKey
guard let privateKeyHex, !packet.content.isEmpty else { guard let privateKeyHex, !packet.content.isEmpty else {
print("🔍 [decrypt] ❌ Early return: privateKeyHex=\(privateKeyHex != nil ? "present" : "NIL") content.isEmpty=\(packet.content.isEmpty)")
return nil return nil
} }
// Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce). // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
if isOwnMessage, !packet.aesChachaKey.isEmpty { if isOwnMessage, !packet.aesChachaKey.isEmpty {
print("🔍 [decrypt] Trying AES-CHACHA path (own sync) for msgId=\(packet.messageId.prefix(8))")
do { do {
let decryptedPayload = try CryptoManager.shared.decryptWithPassword( let decryptedPayload = try CryptoManager.shared.decryptWithPassword(
packet.aesChachaKey, packet.aesChachaKey,
password: privateKeyHex password: privateKeyHex
) )
// decryptedPayload = UTF-8 bytes of Latin-1 string.
// androidUtf8BytesToLatin1Bytes recovers the raw 56-byte key+nonce.
let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload)
let text = try MessageCrypto.decryptIncomingWithPlainKey( let text = try MessageCrypto.decryptIncomingWithPlainKey(
ciphertext: packet.content, ciphertext: packet.content,
plainKeyAndNonce: keyAndNonce plainKeyAndNonce: keyAndNonce
) )
print("🔍 [decrypt] ✅ AES-CHACHA path succeeded, text=\(text.prefix(30))… rawKeyData=\(decryptedPayload.count) bytes") // Return raw 56 bytes (not the UTF-8 payload) so
return (text, decryptedPayload) // attachmentPasswordCandidates can correctly derive WHATWG/Android passwords.
return (text, keyAndNonce)
} catch { } catch {
print("🔍 [decrypt] ⚠️ AES-CHACHA path failed: \(error). Falling through to ECDH…") Self.logger.debug("AES-CHACHA sync path failed, falling through to ECDH…")
} }
} }
// ECDH path (works for opponent messages, may work for own if chachaKey targets us) // ECDH path (works for opponent messages, may work for own if chachaKey targets us)
guard !packet.chachaKey.isEmpty else { guard !packet.chachaKey.isEmpty else {
print("🔍 [decrypt] ❌ chachaKey is empty, no ECDH path available. isOwnMessage=\(isOwnMessage)")
return nil return nil
} }
print("🔍 [decrypt] Trying ECDH path for msgId=\(packet.messageId.prefix(8))… chachaKey=\(packet.chachaKey.prefix(30))")
do { do {
let text = try MessageCrypto.decryptIncoming( let (text, keyAndNonce) = try MessageCrypto.decryptIncomingFull(
ciphertext: packet.content, ciphertext: packet.content,
encryptedKey: packet.chachaKey, encryptedKey: packet.chachaKey,
myPrivateKeyHex: privateKeyHex myPrivateKeyHex: privateKeyHex
) )
let rawKeyData = try? MessageCrypto.extractDecryptedKeyData( return (text, keyAndNonce)
encryptedKey: packet.chachaKey,
myPrivateKeyHex: privateKeyHex
)
print("🔍 [decrypt] ✅ ECDH path succeeded, text=\(text.prefix(30))… rawKeyData=\(rawKeyData != nil ? "\(rawKeyData!.count) bytes" : "nil")")
return (text, rawKeyData)
} catch { } catch {
print("🔍 [decrypt] ❌ ECDH path failed: \(error)") Self.logger.warning("ECDH decrypt failed for msgId=\(packet.messageId.prefix(8)): \(error)")
return nil return nil
} }
} }
@@ -1338,24 +1607,16 @@ final class SessionManager {
else { return } else { return }
let now = Int64(Date().timeIntervalSince1970 * 1000) let now = Int64(Date().timeIntervalSince1970 * 1000)
let result = MessageRepository.shared.resolveWaitingOutgoingMessages( // Retry both .waiting and .error messages within a 5-minute window.
// Messages older than 5 minutes are stale user can retry manually.
let maxRetryAgeMs: Int64 = 5 * 60 * 1000
let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages(
myPublicKey: currentPublicKey, myPublicKey: currentPublicKey,
nowMs: now, nowMs: now,
maxAgeMs: maxOutgoingWaitingLifetimeMs maxRetryAgeMs: maxRetryAgeMs
) )
for expired in result.expired { for message in retryable {
// Update dialog status to error downgrade guards in
// DialogRepository.updateDeliveryStatus prevent regressions.
DialogRepository.shared.updateDeliveryStatus(
messageId: expired.messageId,
opponentKey: expired.dialogKey,
status: .error
)
resolveOutgoingRetry(messageId: expired.messageId)
}
for message in result.retryable {
if message.toPublicKey == currentPublicKey { if message.toPublicKey == currentPublicKey {
continue continue
} }
@@ -1363,6 +1624,13 @@ final class SessionManager {
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue } guard !text.isEmpty else { continue }
// Update dialog delivery status back to .waiting (shows clock icon).
DialogRepository.shared.updateDeliveryStatus(
messageId: message.id,
opponentKey: message.toPublicKey,
status: .waiting
)
do { do {
let packet = try makeOutgoingPacket( let packet = try makeOutgoingPacket(
text: text, text: text,
@@ -1374,19 +1642,17 @@ final class SessionManager {
) )
ProtocolManager.shared.sendPacket(packet) ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet) registerOutgoingRetry(for: packet)
Self.logger.info("Retrying message \(message.id.prefix(8))… to \(message.toPublicKey.prefix(12))")
} catch { } catch {
Self.logger.error("Failed to retry waiting message \(message.id): \(error.localizedDescription)") Self.logger.error("Failed to retry message \(message.id): \(error.localizedDescription)")
// Mark message as error so it doesn't stay stuck at .waiting forever.
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error) MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .error)
let opponentKey = message.toPublicKey
DialogRepository.shared.updateDeliveryStatus( DialogRepository.shared.updateDeliveryStatus(
messageId: message.id, messageId: message.id,
opponentKey: opponentKey, opponentKey: message.toPublicKey,
status: .error status: .error
) )
} }
} }
} }
private func flushPendingReadReceipts() { private func flushPendingReadReceipts() {

View File

@@ -13,9 +13,9 @@ enum BlurHashEncoder {
/// ///
/// - Parameters: /// - Parameters:
/// - image: Source image (will be downscaled internally for performance). /// - image: Source image (will be downscaled internally for performance).
/// - numberOfComponents: AC components (x, y). Desktop uses (4, 4). /// - numberOfComponents: AC components (x, y). Android parity: `BlurHash.encode(bitmap, 4, 3)`.
/// - Returns: BlurHash string, or `nil` if encoding fails. /// - Returns: BlurHash string, or `nil` if encoding fails.
static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 4)) -> String? { static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 3)) -> String? {
let (componentX, componentY) = components let (componentX, componentY) = components
guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil } guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil }
@@ -116,7 +116,7 @@ enum BlurHashEncoder {
// MARK: - sRGB <-> Linear // MARK: - sRGB <-> Linear
private static func sRGBToLinear(_ value: UInt8) -> Float { static func sRGBToLinear(_ value: UInt8) -> Float {
let v = Float(value) / 255 let v = Float(value) / 255
if v <= 0.04045 { if v <= 0.04045 {
return v / 12.92 return v / 12.92
@@ -125,7 +125,7 @@ enum BlurHashEncoder {
} }
} }
private static func linearToSRGB(_ value: Float) -> Int { static func linearToSRGB(_ value: Float) -> Int {
let v = max(0, min(1, value)) let v = max(0, min(1, value))
if v <= 0.0031308 { if v <= 0.0031308 {
return Int(v * 12.92 * 255 + 0.5) return Int(v * 12.92 * 255 + 0.5)
@@ -178,12 +178,156 @@ enum BlurHashEncoder {
} }
} }
// MARK: - BlurHash Decoder
/// Pure Swift BlurHash decoder matching the canonical woltapp/blurhash reference implementation.
/// https://github.com/woltapp/blurhash/blob/master/Swift/BlurHashDecode.swift
///
/// Key differences from previous broken implementation:
/// - Length formula: `4 + 2 * numX * numY` (NOT `4 + 2 * (numX * numY - 1)` which was off by 2)
/// - Pixel format: 3 bytes/pixel RGB with CGImageAlphaInfo.none (canonical)
/// - Buffer: CFDataCreateMutable (canonical)
/// - Punch parameter for color intensity control (Android parity)
enum BlurHashDecoder {
/// Decodes a BlurHash string into a UIImage.
///
/// - Parameters:
/// - blurHash: The BlurHash string to decode.
/// - width: Output image width in pixels (default 32 small, blurred anyway).
/// - height: Output image height in pixels (default 32).
/// - punch: Color intensity multiplier (default 1). Android parity.
/// - Returns: A UIImage placeholder, or `nil` if decoding fails.
static func decode(blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
guard blurHash.count >= 6 else { return nil }
let sizeFlag = decodeBase83(blurHash, from: 0, length: 1)
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
// Canonical formula: 1(sizeFlag) + 1(maxVal) + 4(DC) + 2*(N-1)(AC) = 4 + 2*N
let expectedLength = 4 + 2 * numX * numY
guard blurHash.count == expectedLength else { return nil }
let quantisedMaximumValue = decodeBase83(blurHash, from: 1, length: 1)
let maximumValue = Float(quantisedMaximumValue + 1) / 166
let colors: [(Float, Float, Float)] = (0..<numX * numY).map { i in
if i == 0 {
let value = decodeBase83(blurHash, from: 2, length: 4)
return decodeDC(value)
} else {
let value = decodeBase83(blurHash, from: 4 + i * 2, length: 2)
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
// Canonical: 3 bytes per pixel (RGB, no alpha), CFDataCreateMutable buffer
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
for y in 0..<height {
for x in 0..<width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for j in 0..<numY {
for i in 0..<numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width))
* cos(Float.pi * Float(y) * Float(j) / Float(height))
let color = colors[i + j * numX]
r += color.0 * basis
g += color.1 * basis
b += color.2 * basis
}
}
let offset = 3 * x + y * bytesPerRow
pixels[offset] = UInt8(clamping: BlurHashEncoder.linearToSRGB(r))
pixels[offset + 1] = UInt8(clamping: BlurHashEncoder.linearToSRGB(g))
pixels[offset + 2] = UInt8(clamping: BlurHashEncoder.linearToSRGB(b))
}
}
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(
width: width, height: height,
bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil, shouldInterpolate: true, intent: .defaultIntent
) else { return nil }
return UIImage(cgImage: cgImage)
}
// MARK: - DC / AC Decoding
private static func decodeDC(_ value: Int) -> (Float, Float, Float) {
let r = value >> 16
let g = (value >> 8) & 255
let b = value & 255
return (sRGBToLinear(r), sRGBToLinear(g), sRGBToLinear(b))
}
private static func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
return (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
}
private static func signPow(_ value: Float, _ exp: Float) -> Float {
copysign(pow(abs(value), exp), value)
}
private static func sRGBToLinear<T: BinaryInteger>(_ value: T) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}
// MARK: - Base83 Decoding (string-index based, matching canonical)
private static let base83Lookup: [Character: Int] = {
let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
var lookup = [Character: Int]()
for (i, ch) in chars.enumerated() {
lookup[ch] = i
}
return lookup
}()
private static func decodeBase83(_ string: String, from start: Int, length: Int) -> Int {
let startIdx = string.index(string.startIndex, offsetBy: start)
let endIdx = string.index(startIdx, offsetBy: length)
var value = 0
for char in string[startIdx..<endIdx] {
value = value * 83 + (base83Lookup[char] ?? 0)
}
return value
}
}
// MARK: - UIImage Extension // MARK: - UIImage Extension
extension UIImage { extension UIImage {
/// Generates a BlurHash string from this image. /// Generates a BlurHash string from this image.
/// Desktop parity: `encode(imageData.data, width, height, 4, 4)`. /// Android parity: `BlurHash.encode(bitmap, 4, 3)`.
func blurHash(numberOfComponents components: (Int, Int) = (4, 4)) -> String? { func blurHash(numberOfComponents components: (Int, Int) = (4, 3)) -> String? {
return BlurHashEncoder.encode(image: self, numberOfComponents: components) return BlurHashEncoder.encode(image: self, numberOfComponents: components)
} }
/// Creates a UIImage from a BlurHash string.
/// Canonical woltapp/blurhash decoder with punch parameter (Android parity).
static func fromBlurHash(_ blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
return BlurHashDecoder.decode(blurHash: blurHash, width: width, height: height, punch: punch)
}
} }

View File

@@ -2,8 +2,8 @@ import Foundation
/// Centralized protocol constants matching the Desktop reference implementation. /// Centralized protocol constants matching the Desktop reference implementation.
enum ProtocolConstants { enum ProtocolConstants {
/// Auto-reconnect delay in seconds. /// Auto-reconnect initial delay in seconds (exponential backoff: 1, 2, 4, 8, 16).
static let reconnectIntervalS: TimeInterval = 5 static let reconnectIntervalS: TimeInterval = 1
/// Number of messages loaded per batch (scroll-to-top pagination). /// Number of messages loaded per batch (scroll-to-top pagination).
static let maxMessagesLoad = 20 static let maxMessagesLoad = 20

View File

@@ -5,22 +5,30 @@ import Foundation
enum ReleaseNotes { enum ReleaseNotes {
/// Current release notes entries, newest first. /// Current release notes entries, newest first.
/// Each entry contains a version string and a list of changes. /// Each entry contains a version string and either a `body` (free-form markdown)
/// or a `changes` list (auto-formatted as bullets).
static let entries: [Entry] = [ static let entries: [Entry] = [
Entry( Entry(
version: appVersion, version: appVersion,
changes: [ body: """
"Performance optimizations for chat list rendering", **Синхронизация**
"Improved empty chat placeholder centering", - Исправлена критическая ошибка, из-за которой синхронизация могла не запускаться после подключения к серверу
"Added release notes in Updates settings", - Исправлена ошибка с курсором синхронизации — теперь курсор передаётся без преобразования, как в Desktop
"Optimized dialog sort cache for faster UI updates", - Исправлены ложные непрочитанные сообщения после синхронизации
"Draft messages with cross-session persistence",
"Chat pinning, muting, and swipe actions", **Мульти-девайс**
"Biometric authentication (Face ID / Touch ID)", - Сообщения с другого устройства того же аккаунта теперь корректно показываются со статусом «доставлено»
"Online status and typing indicators",
"Push notifications support", **UI (iOS 26)**
"End-to-end encrypted messaging with XChaCha20-Poly1305" - Исправлен баг с размытием экрана чата при скролле
]
**Swipe-to-Reply** — свайп влево по сообщению для ответа, как в Telegram
**Reply Quote** — обновлённый дизайн цитаты ответа. Если ответ на фото — миниатюра из BlurHash
**Навигация по цитате** — тап на цитату скроллит к оригиналу с плавной подсветкой
**Коллаж фотографий** — несколько фото в сообщении отображаются в сетке в стиле Telegram
**Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов
**Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия
"""
) )
] ]
@@ -38,6 +46,12 @@ enum ReleaseNotes {
/// Sent as a system message from the "Rosetta Updates" account. /// Sent as a system message from the "Rosetta Updates" account.
static var releaseNoticeText: String { static var releaseNoticeText: String {
guard let latest = entries.first else { return "" } guard let latest = entries.first else { return "" }
if let body = latest.body {
return "**Update v\(latest.version)**\n\n\(body)"
}
// Fallback: auto-format from changes array.
var lines = ["**Update v\(latest.version)**"] var lines = ["**Update v\(latest.version)**"]
for change in latest.changes { for change in latest.changes {
lines.append("- \(change)") lines.append("- \(change)")
@@ -49,7 +63,8 @@ enum ReleaseNotes {
struct Entry: Identifiable { struct Entry: Identifiable {
let version: String let version: String
let changes: [String] var changes: [String] = []
var body: String? = nil
var id: String { version } var id: String { version }
} }

View File

@@ -0,0 +1,187 @@
import SwiftUI
import UIKit
/// Action model for context menu buttons.
struct BubbleContextAction {
let title: String
let image: UIImage?
let role: UIMenuElement.Attributes
let handler: () -> Void
}
/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble.
///
/// Uses a **window snapshot** approach instead of UIHostingController preview:
/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window
/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil`
/// 3. UIKit lifts the snapshot in-place no horizontal shift, no re-rendering issues
///
/// Also supports an optional `onTap` callback that fires on single tap.
/// This is needed because the overlay UIView intercepts all touch events,
/// preventing SwiftUI `onTapGesture` on content below from firing.
struct BubbleContextMenuOverlay: UIViewRepresentable {
let actions: [BubbleContextAction]
let previewShape: MessageBubbleShape
let readStatusText: String?
/// Called when user single-taps the bubble (e.g., to open fullscreen image).
var onTap: (() -> Void)?
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
var replyQuoteHeight: CGFloat = 0
/// Called when user taps the reply quote area at the top of the bubble.
var onReplyQuoteTap: (() -> Void)?
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(interaction)
// Single tap recognizer coexists with context menu's long press.
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
view.addGestureRecognizer(tap)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.actions = actions
context.coordinator.previewShape = previewShape
context.coordinator.readStatusText = readStatusText
context.coordinator.onTap = onTap
context.coordinator.replyQuoteHeight = replyQuoteHeight
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
}
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
final class Coordinator: NSObject, UIContextMenuInteractionDelegate {
var actions: [BubbleContextAction]
var previewShape: MessageBubbleShape
var readStatusText: String?
var onTap: (() -> Void)?
var replyQuoteHeight: CGFloat = 0
var onReplyQuoteTap: (() -> Void)?
private var snapshotView: UIImageView?
init(overlay: BubbleContextMenuOverlay) {
self.actions = overlay.actions
self.previewShape = overlay.previewShape
self.readStatusText = overlay.readStatusText
self.onTap = overlay.onTap
self.replyQuoteHeight = overlay.replyQuoteHeight
self.onReplyQuoteTap = overlay.onReplyQuoteTap
}
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
// Route taps in the reply quote region to the reply handler.
if replyQuoteHeight > 0, let view = recognizer.view {
let location = recognizer.location(in: view)
if location.y < replyQuoteHeight {
onReplyQuoteTap?()
return
}
}
onTap?()
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
captureSnapshot(for: interaction)
return UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil,
actionProvider: { [weak self] _ in
self?.buildMenu()
}
)
}
// MARK: - Snapshot
private func captureSnapshot(for interaction: UIContextMenuInteraction) {
guard let view = interaction.view, let window = view.window else { return }
let frameInWindow = view.convert(view.bounds, to: window)
let renderer = UIGraphicsImageRenderer(size: view.bounds.size)
let image = renderer.image { ctx in
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
}
let sv = UIImageView(image: image)
sv.frame = view.bounds
view.addSubview(sv)
self.snapshotView = sv
}
// MARK: - Menu
private func buildMenu() -> UIMenu {
var sections: [UIMenuElement] = []
if let readStatus = readStatusText {
let readAction = UIAction(
title: readStatus,
image: UIImage(systemName: "checkmark"),
attributes: .disabled
) { _ in }
sections.append(UIMenu(options: .displayInline, children: [readAction]))
}
let menuActions = actions.map { action in
UIAction(
title: action.title,
image: action.image,
attributes: action.role
) { _ in
action.handler()
}
}
sections.append(UIMenu(options: .displayInline, children: menuActions))
return UIMenu(children: sections)
}
// MARK: - Targeted Preview (lift & dismiss)
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
guard let sv = snapshotView else { return nil }
return makeTargetedPreview(for: sv)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
guard let sv = snapshotView else { return nil }
return makeTargetedPreview(for: sv)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: (any UIContextMenuInteractionAnimating)?
) {
animator?.addCompletion { [weak self] in
self?.snapshotView?.removeFromSuperview()
self?.snapshotView = nil
}
}
private func makeTargetedPreview(for view: UIView) -> UITargetedPreview {
let params = UIPreviewParameters()
let shapePath = previewShape.path(in: view.bounds)
params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath)
params.backgroundColor = .clear
return UITargetedPreview(view: view, parameters: params)
}
}
}

View File

@@ -82,10 +82,20 @@ struct ChatDetailView: View {
@State private var showAttachmentPanel = false @State private var showAttachmentPanel = false
@State private var pendingAttachments: [PendingAttachment] = [] @State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false @State private var showOpponentProfile = false
@State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage?
@State private var messageToDelete: ChatMessage?
/// Attachment ID for full-screen image viewer (nil = dismissed).
@State private var fullScreenAttachmentId: String?
/// ID of message to scroll to (set when tapping a reply quote).
@State private var scrollToMessageId: String?
/// ID of message currently highlighted after scroll-to-reply navigation.
@State private var highlightedMessageId: String?
private var currentPublicKey: String { /// Cached at view init never changes during a session. Avoids @Observable
SessionManager.shared.currentPublicKey /// observation on SessionManager that would re-render all cells on any state change.
} private let currentPublicKey: String = SessionManager.shared.currentPublicKey
private var dialog: Dialog? { private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey] DialogRepository.shared.dialogs[route.publicKey]
@@ -262,6 +272,39 @@ struct ChatDetailView: View {
.navigationDestination(isPresented: $showOpponentProfile) { .navigationDestination(isPresented: $showOpponentProfile) {
OpponentProfileView(route: route) OpponentProfileView(route: route)
} }
.sheet(isPresented: $showForwardPicker) {
ForwardChatPickerView { targetRoute in
showForwardPicker = false
guard let message = forwardingMessage else { return }
forwardingMessage = nil
forwardMessage(message, to: targetRoute)
}
}
.fullScreenCover(isPresented: Binding(
get: { fullScreenAttachmentId != nil },
set: { if !$0 { fullScreenAttachmentId = nil } }
)) {
FullScreenImageFromCache(
attachmentId: fullScreenAttachmentId ?? "",
onDismiss: { fullScreenAttachmentId = nil }
)
}
.alert("Delete Message", isPresented: Binding(
get: { messageToDelete != nil },
set: { if !$0 { messageToDelete = nil } }
)) {
Button("Delete", role: .destructive) {
if let message = messageToDelete {
removeMessage(message)
messageToDelete = nil
}
}
Button("Cancel", role: .cancel) {
messageToDelete = nil
}
} message: {
Text("Are you sure you want to delete this message? This action cannot be undone.")
}
} }
} }
@@ -630,15 +673,16 @@ private extension ChatDetailView {
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } } .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
ForEach(messages.indices.reversed(), id: \.self) { index in // PERF: use message.id as ForEach identity (stable).
let message = messages[index] // Integer indices shift on every insert, forcing full diff.
ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in
let position = bubblePosition(for: index)
messageRow( messageRow(
message, message,
maxBubbleWidth: maxBubbleWidth, maxBubbleWidth: maxBubbleWidth,
position: bubblePosition(for: index) position: position
) )
.scaleEffect(x: 1, y: -1) // flip each row back to normal .scaleEffect(x: 1, y: -1) // flip each row back to normal
.id(message.id)
// Unread Messages separator (Telegram style). // Unread Messages separator (Telegram style).
// In inverted scroll, "above" visually = after in code. // In inverted scroll, "above" visually = after in code.
@@ -652,6 +696,9 @@ private extension ChatDetailView {
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.bottom, messagesTopInset) // visual top (near nav bar) .padding(.bottom, messagesTopInset) // visual top (near nav bar)
} }
// iOS 26: disable default scroll edge blur in inverted scroll the top+bottom
// effects overlap and blur the entire screen.
.modifier(DisableScrollEdgeEffectModifier())
.scaleEffect(x: 1, y: -1) // INVERTED SCROLL bottom-anchored by nature .scaleEffect(x: 1, y: -1) // INVERTED SCROLL bottom-anchored by nature
// Parent .ignoresSafeArea(.keyboard) handles keyboard no scroll-level ignore needed. // Parent .ignoresSafeArea(.keyboard) handles keyboard no scroll-level ignore needed.
// Composer is overlay (not safeAreaInset), so no .container ignore needed either. // Composer is overlay (not safeAreaInset), so no .container ignore needed either.
@@ -675,6 +722,25 @@ private extension ChatDetailView {
guard focused else { return } guard focused else { return }
SessionManager.shared.recordUserInteraction() SessionManager.shared.recordUserInteraction()
} }
// Scroll-to-reply: navigate to the original message and highlight it briefly.
.onChange(of: scrollToMessageId) { _, targetId in
guard let targetId else { return }
scrollToMessageId = nil
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(targetId, anchor: .center)
}
// Brief highlight glow after scroll completes.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeIn(duration: 0.2)) {
highlightedMessageId = targetId
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 0.5)) {
highlightedMessageId = nil
}
}
}
}
// No keyboard scroll handlers needed inverted scroll keeps bottom anchored. // No keyboard scroll handlers needed inverted scroll keeps bottom anchored.
scroll scroll
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
@@ -723,50 +789,185 @@ private extension ChatDetailView {
// Desktop parity: render image, file, and avatar attachments in the bubble. // Desktop parity: render image, file, and avatar attachments in the bubble.
let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar } let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar }
if visibleAttachments.isEmpty { Group {
// Text-only message (original path) if visibleAttachments.isEmpty {
textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) // Text-only message (original path)
} else { textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
// Attachment message: images/files + optional caption } else {
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) // Attachment message: images/files + optional caption
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
}
} }
// Telegram-style swipe-to-reply: skip gesture entirely for system chats.
.modifier(ConditionalSwipeToReply(
enabled: !route.isSavedMessages && !route.isSystemAccount,
onReply: {
self.replyingToMessage = message
self.isInputFocused = true
}
))
// Highlight overlay for scroll-to-reply navigation.
.overlay {
if highlightedMessageId == message.id {
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.12))
.allowsHitTesting(false)
}
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.top, (position == .single || position == .top) ? 6 : 2)
.padding(.bottom, 0)
} }
/// Text-only message bubble (original design). /// Text-only message bubble (original design).
/// If the message has a MESSAGES attachment (reply/forward), shows the quoted message above text.
@ViewBuilder @ViewBuilder
private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let messageText = message.text.isEmpty ? " " : message.text let messageText = message.text.isEmpty ? " " : message.text
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first
// Telegram-style compact bubble: inline time+status at bottom-trailing. VStack(alignment: .leading, spacing: 0) {
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). // Reply/forward quote (if present)
Text(parsedMarkdown(messageText)) if let reply = replyData {
.font(.system(size: 17, weight: .regular)) replyQuoteView(reply: reply, outgoing: outgoing)
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
} }
// Tail protrusion space: the unified shape draws the tail in this padding area
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) // Telegram-style compact bubble: inline time+status at bottom-trailing.
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
// Single unified background: body + tail drawn in one fill (no seam) Text(parsedMarkdown(messageText))
.background { bubbleBackground(outgoing: outgoing, position: position) } .font(.system(size: 17, weight: .regular))
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) .tracking(-0.43)
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) .multilineTextAlignment(.leading)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0) .lineSpacing(0)
.padding(.top, (position == .single || position == .top) ? 6 : 2) .fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 0) .padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
// Tail protrusion space: the unified shape draws the tail in this padding area
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
// Single unified background: body + tail drawn in one fill (no seam)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in self.scrollToMessageId = reply.message_id }
}
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
/// PERF: static cache for decoded reply blobs avoids JSON decode on every re-render.
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
/// Parses a decrypted MESSAGES blob into `ReplyMessageData` array.
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
guard !blob.isEmpty else { return nil }
if let cached = Self.replyBlobCache[blob] { return cached }
guard let data = blob.data(using: .utf8) else { return nil }
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) }
Self.replyBlobCache[blob] = result
return result
}
/// Telegram-style reply quote rendered above message text inside the bubble.
/// Matches Figma spec: 4px corners, 3px accent bar, 15pt font, semi-transparent bg.
/// Tapping scrolls to the original message and briefly highlights it.
@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 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 })
let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
let parts = att.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : att.preview
return hash.isEmpty ? nil : hash
}()
// Tap is handled at UIKit level via BubbleContextMenuOverlay.onReplyQuoteTap.
HStack(spacing: 0) {
// 3px accent bar
RoundedRectangle(cornerRadius: 1.5)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 4)
// Optional image thumbnail for media replies (32×32)
// PERF: uses static cache BlurHash decode is expensive (DCT transform).
if let hash = blurHash,
let image = Self.cachedBlurHash(hash, width: 32, height: 32) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 1) {
Text(senderName)
.font(.system(size: 15, weight: .semibold))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
.lineLimit(1)
Text(previewText)
.font(.system(size: 15, weight: .regular))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.padding(.leading, 6)
Spacer(minLength: 0)
}
.frame(height: 41)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
.padding(.horizontal, 5)
.padding(.top, 5)
.padding(.bottom, 0)
}
/// Resolves a public key to a display name for reply/forward quotes.
/// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the
/// body path uses route data instead. Only the current opponent is resolved.
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey {
return "You"
}
// Current chat opponent use route (non-observable, stable).
if publicKey == route.publicKey {
return route.title.isEmpty ? String(publicKey.prefix(8)) + "" : route.title
}
return String(publicKey.prefix(8)) + ""
} }
/// Attachment message bubble: images/files with optional text caption. /// Attachment message bubble: images/files with optional text caption.
///
/// Telegram-style layout:
/// - **Image-only**: image fills bubble edge-to-edge, timestamp overlaid as dark pill
/// - **Image + text**: image at top, caption below, normal timestamp in caption area
/// - **File/Avatar**: padded inside bubble, normal timestamp
@ViewBuilder @ViewBuilder
private func attachmentBubble( private func attachmentBubble(
message: ChatMessage, message: ChatMessage,
@@ -777,26 +978,32 @@ private extension ChatDetailView {
position: BubblePosition position: BubblePosition
) -> some View { ) -> some View {
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
let imageAttachments = attachments.filter { $0.type == .image }
let otherAttachments = attachments.filter { $0.type != .image }
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Attachment views // Image attachments Telegram-style collage layout
ForEach(attachments, id: \.id) { attachment in if !imageAttachments.isEmpty {
PhotoCollageView(
attachments: imageAttachments,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
position: position
)
}
// Non-image attachments (file, avatar) padded
ForEach(otherAttachments, id: \.id) { attachment in
switch attachment.type { switch attachment.type {
case .image:
MessageImageView(
attachment: attachment,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .file: case .file:
MessageFileView( MessageFileView(
attachment: attachment, attachment: attachment,
message: message, message: message,
outgoing: outgoing outgoing: outgoing
) )
.padding(.horizontal, 4)
.padding(.top, 4) .padding(.top, 4)
case .avatar: case .avatar:
MessageAvatarView( MessageAvatarView(
@@ -811,7 +1018,7 @@ private extension ChatDetailView {
} }
} }
// Caption text (if any) // Caption text below image
if hasCaption { if hasCaption {
Text(parsedMarkdown(message.text)) Text(parsedMarkdown(message.text))
.font(.system(size: 17, weight: .regular)) .font(.system(size: 17, weight: .regular))
@@ -822,23 +1029,37 @@ private extension ChatDetailView {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11) .padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48) .padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 4) .padding(.top, 6)
.padding(.bottom, 5) .padding(.bottom, 5)
} }
} }
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading) .frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing) if isImageOnly {
// Telegram-style: dark pill overlay on image
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)
.background { bubbleBackground(outgoing: outgoing, position: position) } .background { bubbleBackground(outgoing: outgoing, position: position) }
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !imageAttachments.isEmpty ? {
// Open the first image attachment in fullscreen viewer
if let firstImage = imageAttachments.first {
fullScreenAttachmentId = firstImage.id
}
} : nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.top, (position == .single || position == .top) ? 6 : 2)
.padding(.bottom, 0)
} }
/// Timestamp + delivery status overlay for both text and attachment bubbles. /// Timestamp + delivery status overlay for both text and attachment bubbles.
@@ -865,6 +1086,46 @@ private extension ChatDetailView {
.padding(.bottom, 5) .padding(.bottom, 5)
} }
/// Figma "Media=True" timestamp: dark semi-transparent pill overlaid on images.
@ViewBuilder
private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(.white)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
mediaDeliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
.padding(.trailing, 6)
.padding(.bottom, 6)
}
// MARK: - BlurHash Cache
/// PERF: static cache for decoded BlurHash images. Hash strings are immutable,
/// so results never need invalidation. Avoids DCT decode on every re-render.
@MainActor private static var blurHashCache: [String: UIImage] = [:]
@MainActor
private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
let key = "\(hash)_\(width)x\(height)"
if let cached = blurHashCache[key] { return cached }
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) }
blurHashCache[key] = image
return image
}
// MARK: - Text Parsing (Markdown + Emoji) // MARK: - Text Parsing (Markdown + Emoji)
/// Static cache for parsed markdown + emoji. Message text is immutable, /// Static cache for parsed markdown + emoji. Message text is immutable,
@@ -919,6 +1180,11 @@ private extension ChatDetailView {
var composer: some View { var composer: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
// Reply preview bar (Telegram-style)
if let replyMessage = replyingToMessage {
replyBar(for: replyMessage)
}
// Attachment preview strip shows selected images/files before send // Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty { if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments) AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
@@ -1200,6 +1466,29 @@ private extension ChatDetailView {
} }
} }
/// Delivery indicator with white tint for on-image media overlay.
@ViewBuilder
func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View {
switch status {
case .read:
DoubleCheckmarkShape()
.fill(Color.white)
.frame(width: 16, height: 8.7)
case .delivered:
SingleCheckmarkShape()
.fill(Color.white.opacity(0.8))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
@ViewBuilder @ViewBuilder
func errorMenu(for message: ChatMessage) -> some View { func errorMenu(for message: ChatMessage) -> some View {
Menu { Menu {
@@ -1220,6 +1509,139 @@ private extension ChatDetailView {
} }
} }
// MARK: - Context Menu
/// Clean bubble preview for context menu no `.frame(maxWidth: .infinity)`, no outer paddings.
@ViewBuilder
func bubblePreview(message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let hasTail = position == .single || position == .bottom
let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar }
if visibleAttachments.isEmpty {
let messageText = message.text.isEmpty ? " " : message.text
Text(parsedMarkdown(messageText))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
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) }
.contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing))
.frame(maxWidth: maxBubbleWidth)
} else {
// Attachment preview reuse full bubble, clip to shape
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
let imageAttachments = visibleAttachments.filter { $0.type == .image }
let otherAttachments = visibleAttachments.filter { $0.type != .image }
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) {
if !imageAttachments.isEmpty {
PhotoCollageView(
attachments: imageAttachments,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
position: position
)
}
ForEach(otherAttachments, id: \.id) { attachment in
switch attachment.type {
case .file:
MessageFileView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 4).padding(.top, 4)
case .avatar:
MessageAvatarView(attachment: attachment, message: message, outgoing: outgoing).padding(.horizontal, 6).padding(.top, 4)
default: EmptyView()
}
}
if hasCaption {
Text(parsedMarkdown(message.text))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 6)
.padding(.bottom, 5)
}
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
if isImageOnly {
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) }
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
.contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing))
.frame(maxWidth: maxBubbleWidth)
}
}
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
guard outgoing, message.deliveryStatus == .read else { return nil }
return "Read"
}
func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var actions: [BubbleContextAction] = []
actions.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) {
self.replyingToMessage = message
self.isInputFocused = true
})
actions.append(BubbleContextAction(
title: "Copy",
image: UIImage(systemName: "doc.on.doc"),
role: []
) {
UIPasteboard.general.string = message.text
})
actions.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),
role: []
) {
self.forwardingMessage = message
self.showForwardPicker = true
})
actions.append(BubbleContextAction(
title: "Delete",
image: UIImage(systemName: "trash"),
role: .destructive
) {
self.messageToDelete = message
})
return actions
}
func retryMessage(_ message: ChatMessage) { func retryMessage(_ message: ChatMessage) {
let text = message.text let text = message.text
let toKey = message.toPublicKey let toKey = message.toPublicKey
@@ -1235,8 +1657,107 @@ private extension ChatDetailView {
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey) DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
} }
// MARK: - Reply Bar
@ViewBuilder
func replyBar(for message: ChatMessage) -> some View {
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
? "You"
: (dialog?.opponentTitle ?? route.title)
let previewText = message.text.isEmpty
? (message.attachments.isEmpty ? "" : "Attachment")
: message.text
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(RosettaColors.figmaBlue)
.frame(width: 3, height: 36)
VStack(alignment: .leading, spacing: 1) {
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.figmaBlue)
.lineLimit(1)
Text(previewText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.padding(.leading, 8)
Spacer()
Button {
withAnimation(.easeOut(duration: 0.15)) {
replyingToMessage = nil
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.frame(width: 30, height: 30)
}
}
.padding(.horizontal, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// 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)
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],
toPublicKey: targetKey,
opponentTitle: targetRoute.title,
opponentUsername: targetRoute.username
)
} catch {
sendError = "Failed to forward message"
}
}
}
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
// Convert ChatMessage attachments to ReplyAttachmentData (text-only for now)
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
// Skip MESSAGES attachments in nested replies (don't nest replies recursively)
guard att.type != .messages else { return nil }
return ReplyAttachmentData(
id: att.id,
type: att.type.rawValue,
preview: att.preview,
blob: "" // Blob cleared for reply (desktop parity)
)
}
return ReplyMessageData(
message_id: message.id,
publicKey: message.fromPublicKey,
message: message.text,
timestamp: message.timestamp,
attachments: replyAttachments
)
}
/// PERF: static cache for formatted timestamps avoids Date + DateFormatter per cell per render.
@MainActor private static var timeCache: [Int64: String] = [:]
func messageTime(_ timestamp: Int64) -> String { func messageTime(_ timestamp: Int64) -> String {
Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) if let cached = Self.timeCache[timestamp] { return cached }
let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) }
Self.timeCache[timestamp] = result
return result
} }
func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) {
@@ -1297,6 +1818,7 @@ private extension ChatDetailView {
func sendCurrentMessage() { func sendCurrentMessage() {
let message = trimmedMessage let message = trimmedMessage
let attachments = pendingAttachments let attachments = pendingAttachments
let replyMessage = replyingToMessage
// Must have either text or attachments // Must have either text or attachments
guard !message.isEmpty || !attachments.isEmpty else { return } guard !message.isEmpty || !attachments.isEmpty else { return }
@@ -1306,6 +1828,7 @@ private extension ChatDetailView {
shouldScrollOnNextMessage = true shouldScrollOnNextMessage = true
messageText = "" messageText = ""
pendingAttachments = [] pendingAttachments = []
replyingToMessage = nil
sendError = nil sendError = nil
// Desktop parity: delete draft after sending. // Desktop parity: delete draft after sending.
DraftManager.shared.deleteDraft(for: route.publicKey) DraftManager.shared.deleteDraft(for: route.publicKey)
@@ -1321,6 +1844,16 @@ private extension ChatDetailView {
opponentTitle: route.title, opponentTitle: route.title,
opponentUsername: route.username opponentUsername: route.username
) )
} else if let replyMsg = replyMessage {
// Desktop parity: reply sends MESSAGES attachment with quoted message JSON
let replyData = buildReplyData(from: replyMsg)
try await SessionManager.shared.sendMessageWithReply(
text: message,
replyMessages: [replyData],
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else { } else {
// Text-only message (existing path) // Text-only message (existing path)
try await SessionManager.shared.sendMessage( try await SessionManager.shared.sendMessage(
@@ -1465,6 +1998,8 @@ enum TelegramIconPath {
static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"# static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"#
static let replyArrow = #"M7.73438 12.6367C7.8125 12.5586 7.87109 12.4674 7.91016 12.3633C7.94922 12.2721 7.96875 12.168 7.96875 12.0508V9.375C9.375 9.375 10.5599 9.58333 11.5234 10C12.6172 10.4948 13.4635 11.276 14.0625 12.3438C14.1536 12.513 14.2773 12.6432 14.4336 12.7344C14.5768 12.8255 14.7526 12.8711 14.9609 12.8711C15.1172 12.8711 15.2604 12.819 15.3906 12.7148C15.4948 12.6237 15.5729 12.5065 15.625 12.3633C15.6771 12.2201 15.7031 12.0768 15.7031 11.9336C15.7031 10.6185 15.5599 9.45312 15.2734 8.4375C14.974 7.39583 14.5182 6.51042 13.9062 5.78125C13.6068 5.41667 13.2617 5.09115 12.8711 4.80469C12.4805 4.53125 12.0508 4.29688 11.582 4.10156C11.0482 3.88021 10.4557 3.72396 9.80469 3.63281C9.27083 3.55469 8.65885 3.51562 7.96875 3.51562V0.859375C7.96875 0.703125 7.92969 0.559896 7.85156 0.429688C7.78646 0.299479 7.68229 0.195312 7.53906 0.117188C7.40885 0.0390625 7.26562 0 7.10938 0C6.92708 0 6.74479 0.0520833 6.5625 0.15625C6.43229 0.234375 6.28255 0.351562 6.11328 0.507812L0.371094 5.68359C0.292969 5.7487 0.234375 5.8138 0.195312 5.87891C0.143229 5.94401 0.104167 6.00911 0.078125 6.07422C0.0520833 6.13932 0.0325521 6.19792 0.0195312 6.25C0.00651042 6.3151 0 6.3737 0 6.42578C0 6.49089 0.00651042 6.55599 0.0195312 6.62109C0.0325521 6.67318 0.0520833 6.73177 0.078125 6.79688C0.104167 6.86198 0.143229 6.92708 0.195312 6.99219C0.234375 7.05729 0.292969 7.1224 0.371094 7.1875L6.11328 12.4023C6.29557 12.5716 6.46484 12.6888 6.62109 12.7539C6.69922 12.793 6.77734 12.819 6.85547 12.832C6.94661 12.8581 7.03125 12.8711 7.10938 12.8711C7.22656 12.8711 7.33724 12.8516 7.44141 12.8125C7.55859 12.7734 7.65625 12.7148 7.73438 12.6367Z"#
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"# static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
} }
@@ -1480,6 +2015,21 @@ private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
} }
} }
/// iOS 26: scroll edge blur is on by default in inverted scroll (scaleEffect y: -1)
/// both top+bottom edge effects overlap and blur the entire screen.
/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer).
/// Keep ScrollView's bottom edge (= visual top after inversion, near nav bar) for a
/// nice fade effect when scrolling through older messages.
private struct DisableScrollEdgeEffectModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content.scrollEdgeEffectHidden(true, for: .top)
} else {
content
}
}
}
#Preview { #Preview {
NavigationStack { NavigationStack {

View File

@@ -0,0 +1,70 @@
import SwiftUI
/// Sheet for picking a chat to forward a message to.
/// Shows all existing dialogs sorted by last message time.
struct ForwardChatPickerView: View {
let onSelect: (ChatRoute) -> Void
@Environment(\.dismiss) private var dismiss
private var dialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
}
var body: some View {
let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)")
NavigationStack {
List(dialogs) { dialog in
Button {
onSelect(ChatRoute(dialog: dialog))
} label: {
HStack(spacing: 12) {
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 42,
isSavedMessages: dialog.isSavedMessages
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if dialog.effectiveVerified > 0 && !dialog.isSavedMessages {
VerifiedBadge(verified: dialog.effectiveVerified, size: 14)
}
}
if !dialog.opponentUsername.isEmpty && !dialog.isSavedMessages {
Text("@\(dialog.opponentUsername)")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.contentShape(Rectangle())
}
.listRowBackground(RosettaColors.Dark.surface)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(RosettaColors.Dark.background)
.navigationTitle("Forward to...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
}
.preferredColorScheme(.dark)
}
}

View File

@@ -0,0 +1,204 @@
import SwiftUI
// MARK: - FullScreenImageViewer
/// Full-screen image viewer with pinch zoom, double-tap zoom, and swipe-to-dismiss.
///
/// Android parity: `ImageViewerScreen.kt` zoom (1x5x), double-tap (2.5x),
/// vertical swipe dismiss, background fade, tap to toggle controls.
struct FullScreenImageViewer: View {
let image: UIImage
let onDismiss: () -> Void
/// Current zoom scale (1.0 = fit, up to maxScale).
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
/// Pan offset when zoomed.
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
/// Vertical drag offset for dismiss gesture (only when not zoomed).
@State private var dismissOffset: CGFloat = 0
/// Whether the UI controls (close button) are visible.
@State private var showControls = true
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 5.0
private let doubleTapScale: CGFloat = 2.5
private let dismissThreshold: CGFloat = 150
var body: some View {
ZStack {
// Background: fades as user drags to dismiss
Color.black
.opacity(backgroundOpacity)
.ignoresSafeArea()
// Zoomable image
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(x: offset.width, y: offset.height + dismissOffset)
.gesture(dragGesture)
.gesture(pinchGesture)
.onTapGesture(count: 2) {
doubleTap()
}
.onTapGesture(count: 1) {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
// Close button
if showControls {
VStack {
HStack {
Spacer()
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.padding(.trailing, 16)
.padding(.top, 8)
}
Spacer()
}
.transition(.opacity)
}
}
}
// MARK: - Background Opacity
private var backgroundOpacity: Double {
let progress = min(abs(dismissOffset) / 300, 1.0)
return 1.0 - progress * 0.6
}
// MARK: - Double Tap Zoom
private func doubleTap() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
if scale > 1.05 {
scale = 1.0
lastScale = 1.0
offset = .zero
lastOffset = .zero
} else {
scale = doubleTapScale
lastScale = doubleTapScale
offset = .zero
lastOffset = .zero
}
}
}
// MARK: - Pinch Gesture
private var pinchGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = lastScale * value
scale = min(max(newScale, minScale * 0.5), maxScale)
}
.onEnded { _ in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
if scale < minScale { scale = minScale }
lastScale = scale
if scale <= 1.0 {
offset = .zero
lastOffset = .zero
}
}
}
}
// MARK: - Drag Gesture
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if scale > 1.05 {
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
} else {
dismissOffset = value.translation.height
}
}
.onEnded { _ in
if scale > 1.05 {
lastOffset = offset
} else {
if abs(dismissOffset) > dismissThreshold {
onDismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
dismissOffset = 0
}
}
}
}
}
}
// MARK: - FullScreenImageFromCache
/// Wrapper that loads an image from `AttachmentCache` by attachment ID and
/// presents it in `FullScreenImageViewer`. Handles cache-miss gracefully.
///
/// Used as `fullScreenCover` content the attachment ID is a stable value
/// passed as a parameter, avoiding @State capture issues with UIImage.
struct FullScreenImageFromCache: View {
let attachmentId: String
let onDismiss: () -> Void
var body: some View {
if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) {
FullScreenImageViewer(image: image, onDismiss: onDismiss)
} else {
// Cache miss show error with close button
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 16) {
Image(systemName: "photo")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Image not available")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.5))
}
VStack {
HStack {
Spacer()
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.padding(.trailing, 16)
.padding(.top, 8)
}
Spacer()
}
}
}
}
}

View File

@@ -130,13 +130,11 @@ struct MessageAvatarView: View {
return return
} }
guard let password = message.attachmentPassword, !password.isEmpty else { guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
print("🎭 [AvatarView] NO password for attachment \(attachment.id)")
downloadError = true downloadError = true
return return
} }
print("🎭 [AvatarView] Downloading avatar \(attachment.id), tag=\(tag.prefix(20))")
isDownloading = true isDownloading = true
downloadError = false downloadError = false
@@ -145,32 +143,11 @@ struct MessageAvatarView: View {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let decryptedData = try CryptoManager.shared.decryptWithPassword( let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
encryptedString, password: password let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
) )
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
throw TransportError.invalidResponse
}
let downloadedImage: UIImage?
if decryptedString.hasPrefix("data:") {
if let commaIndex = decryptedString.firstIndex(of: ",") {
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part) {
downloadedImage = UIImage(data: imageData)
} else {
downloadedImage = nil
}
} else {
downloadedImage = nil
}
} else if let imageData = Data(base64Encoded: decryptedString) {
downloadedImage = UIImage(data: imageData)
} else {
downloadedImage = UIImage(data: decryptedData)
}
await MainActor.run { await MainActor.run {
if let downloadedImage { if let downloadedImage {
avatarImage = downloadedImage avatarImage = downloadedImage
@@ -189,6 +166,43 @@ struct MessageAvatarView: View {
} }
} }
/// Tries each password candidate and validates the decrypted content is a real image.
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
) else { continue }
if let img = parseImageData(data) { return img }
}
// Fallback: try without requireCompression (legacy uncompressed payloads)
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) else { continue }
if let img = parseImageData(data) { return img }
}
return nil
}
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
private func parseImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8) {
if str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part),
let img = UIImage(data: imageData) {
return img
}
} else if let imageData = Data(base64Encoded: str),
let img = UIImage(data: imageData) {
return img
}
}
return UIImage(data: data)
}
/// Extracts the server tag from preview string. /// Extracts the server tag from preview string.
/// Format: "tag::blurhash" returns "tag". /// Format: "tag::blurhash" returns "tag".
private func extractTag(from preview: String) -> String { private func extractTag(from preview: String) -> String {

View File

@@ -130,7 +130,7 @@ struct MessageFileView: View {
private func downloadFile() { private func downloadFile() {
guard !isDownloading, !fileTag.isEmpty else { return } guard !isDownloading, !fileTag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else { guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
downloadError = true downloadError = true
return return
} }
@@ -142,9 +142,12 @@ struct MessageFileView: View {
do { do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: fileTag) let encryptedData = try await TransportManager.shared.downloadFile(tag: fileTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let decryptedData = try CryptoManager.shared.decryptWithPassword(
encryptedString, password: password let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
let decryptedData = decryptFileData(
encryptedString: encryptedString, passwords: passwords
) )
guard let decryptedData else { throw TransportError.invalidResponse }
// Parse data URI if present, otherwise use raw data // Parse data URI if present, otherwise use raw data
let fileData: Data let fileData: Data
@@ -175,6 +178,27 @@ struct MessageFileView: View {
} }
} }
/// Tries each password candidate with requireCompression to avoid wrong-key garbage.
private func decryptFileData(encryptedString: String, passwords: [String]) -> Data? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
) {
return data
}
}
// Fallback: try without requireCompression (legacy uncompressed payloads)
for password in passwords {
if let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) {
return data
}
}
return nil
}
// MARK: - Share // MARK: - Share
private func shareFile(_ url: URL) { private func shareFile(_ url: URL) {

View File

@@ -7,9 +7,13 @@ import SwiftUI
/// Desktop parity: `MessageImage.tsx` shows blur placeholder while downloading, /// Desktop parity: `MessageImage.tsx` shows blur placeholder while downloading,
/// full image after download, "Image expired" on error. /// full image after download, "Image expired" on error.
/// ///
/// Modes:
/// - **Standalone** (`collageSize == nil`): uses own min/max constraints + aspect ratio.
/// - **Collage cell** (`collageSize != nil`): fills the given frame (parent controls size).
///
/// States: /// States:
/// 1. **Cached** image already in AttachmentCache, display immediately /// 1. **Cached** image already in AttachmentCache, display immediately
/// 2. **Downloading** show placeholder + spinner /// 2. **Downloading** show blurhash placeholder + spinner
/// 3. **Downloaded** display image, tap for full-screen (future) /// 3. **Downloaded** display image, tap for full-screen (future)
/// 4. **Error** "Image expired" or download error /// 4. **Error** "Image expired" or download error
struct MessageImageView: View { struct MessageImageView: View {
@@ -17,51 +21,53 @@ struct MessageImageView: View {
let attachment: MessageAttachment let attachment: MessageAttachment
let message: ChatMessage let message: ChatMessage
let outgoing: Bool let outgoing: Bool
/// When set, the image fills this exact frame (used inside PhotoCollageView).
/// When nil, standalone mode with own size constraints.
var collageSize: CGSize? = nil
let maxWidth: CGFloat let maxWidth: CGFloat
/// Called when user taps a loaded image (opens full-screen viewer).
var onImageTap: ((UIImage) -> Void)?
@State private var image: UIImage? @State private var image: 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
/// Desktop parity: image bubble max dimensions. /// Whether this image is inside a collage (fills parent frame).
private let maxImageWidth: CGFloat = 240 private var isCollageCell: Bool { collageSize != nil }
private let maxImageHeight: CGFloat = 280
/// Telegram-style image constraints (standalone mode only).
private let maxImageWidth: CGFloat = 270
private let maxImageHeight: CGFloat = 320
private let minImageWidth: CGFloat = 140
private let minImageHeight: CGFloat = 100
/// Default placeholder size (standalone mode).
private let placeholderWidth: CGFloat = 200
private let placeholderHeight: CGFloat = 200
var body: some View { var body: some View {
Group { Group {
if let image { if let image {
Image(uiImage: image) imageContent(image)
.resizable()
.scaledToFit()
.frame(maxWidth: min(maxImageWidth, maxWidth - 20))
.frame(maxHeight: maxImageHeight)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else if isDownloading { } else if isDownloading {
placeholder placeholderView
.overlay { ProgressView().tint(.white) } .overlay { downloadingOverlay }
} else if downloadError { } else if downloadError {
placeholder placeholderView
.overlay { .overlay { errorOverlay }
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.system(size: 20))
.foregroundStyle(.white.opacity(0.5))
Text("Image expired")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.4))
}
}
} else { } else {
placeholder placeholderView
.overlay { .overlay { downloadArrowOverlay }
Image(systemName: "arrow.down.circle")
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.6))
}
.onTapGesture { downloadImage() } .onTapGesture { downloadImage() }
} }
} }
.task { .task {
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO)
decodeBlurHash()
loadFromCache() loadFromCache()
if image == nil { if image == nil {
downloadImage() downloadImage()
@@ -69,12 +75,121 @@ struct MessageImageView: View {
} }
} }
// MARK: - Overlay States (Desktop parity: MessageImage.tsx)
/// Desktop: dark 40x40 circle with ProgressView spinner.
private var downloadingOverlay: some View {
Circle()
.fill(Color.black.opacity(0.3))
.frame(width: 40, height: 40)
.overlay {
ProgressView()
.tint(.white)
.scaleEffect(0.9)
}
}
/// Desktop: dark rounded pill with "Image expired" + flame icon.
private var errorOverlay: some View {
HStack(spacing: 4) {
Text("Image expired")
.font(.system(size: 11))
.foregroundStyle(.white)
Image(systemName: "flame.fill")
.font(.system(size: 12))
.foregroundStyle(.white)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
/// Desktop: dark 40x40 circle with download arrow icon.
private var downloadArrowOverlay: some View {
Circle()
.fill(Color.black.opacity(0.3))
.frame(width: 40, height: 40)
.overlay {
Image(systemName: "arrow.down")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white)
}
}
// MARK: - Image Content
@ViewBuilder
private func imageContent(_ img: UIImage) -> some View {
if let size = collageSize {
// Collage mode: fill the given cell frame
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
.contentShape(Rectangle())
.onTapGesture { onImageTap?(img) }
} else {
// Standalone mode: respect aspect ratio constraints
let size = constrainedSize(for: img)
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
.contentShape(Rectangle())
.onTapGesture { onImageTap?(img) }
}
}
/// Calculates display size respecting min/max constraints and aspect ratio (standalone mode).
private func constrainedSize(for img: UIImage) -> CGSize {
let constrainedWidth = min(maxImageWidth, maxWidth)
let aspectRatio = img.size.width / max(img.size.height, 1)
let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width))
let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio))
let finalWidth = min(constrainedWidth, displayHeight * aspectRatio)
return CGSize(width: finalWidth, height: displayHeight)
}
// MARK: - Placeholder // MARK: - Placeholder
private var placeholder: some View { @ViewBuilder
RoundedRectangle(cornerRadius: 12) private var placeholderView: some View {
.fill(Color.white.opacity(0.08)) let size = resolvedPlaceholderSize
.frame(width: 200, height: 150) if let blurImage {
Image(uiImage: blurImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
Rectangle()
.fill(Color.white.opacity(0.08))
.frame(width: size.width, height: size.height)
}
}
/// Placeholder size: collage cell size if in collage, otherwise square default.
private var resolvedPlaceholderSize: CGSize {
if let size = collageSize {
return size
}
let w = min(placeholderWidth, min(maxImageWidth, maxWidth))
return CGSize(width: w, height: w)
}
// MARK: - BlurHash Decoding
/// Decodes the blurhash from the attachment preview string once and caches in @State.
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
private func decodeBlurHash() {
let hash = extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return }
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
blurImage = result
}
} }
// MARK: - Download // MARK: - Download
@@ -88,80 +203,42 @@ struct MessageImageView: View {
private func downloadImage() { private func downloadImage() {
guard !isDownloading, image == nil else { return } guard !isDownloading, image == nil else { return }
// Extract tag from preview ("tag::blurhash" tag)
let tag = extractTag(from: attachment.preview) let tag = extractTag(from: attachment.preview)
guard !tag.isEmpty else { guard !tag.isEmpty else {
print("🖼️ [ImageView] tag is empty for attachment \(attachment.id)")
downloadError = true downloadError = true
return return
} }
guard let password = message.attachmentPassword, !password.isEmpty else { guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
print("🖼️ [ImageView] NO password for attachment \(attachment.id), preview=\(attachment.preview.prefix(40))")
downloadError = true downloadError = true
return return
} }
print("🖼️ [ImageView] Downloading attachment \(attachment.id), tag=\(tag.prefix(20))…, passwordLen=\(password.count)")
isDownloading = true isDownloading = true
downloadError = false downloadError = false
Task { Task {
do { do {
// Download encrypted blob from transport server
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self) let encryptedString = String(decoding: encryptedData, as: UTF8.self)
print("🖼️ [ImageView] Downloaded \(encryptedData.count) bytes, encryptedString.prefix=\(encryptedString.prefix(80))")
print("🖼️ [ImageView] Password UTF-8 bytes: \(Array(password.utf8).prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))")
// Decrypt with attachment password // Try each password candidate; validate decrypted content to avoid false positives
let decryptedData = try CryptoManager.shared.decryptWithPassword( // from wrong-key AES-CBC that randomly produces valid PKCS7 + passable inflate.
encryptedString, password: password let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
) )
print("🖼️ [ImageView] Decrypted \(decryptedData.count) bytes, first20hex=\(decryptedData.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))")
// Parse data URI extract base64 UIImage
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
print("🖼️ [ImageView] ❌ Decrypted data is NOT valid UTF-8! first50hex=\(decryptedData.prefix(50).map { String(format: "%02x", $0) }.joined(separator: " "))")
throw TransportError.invalidResponse
}
let downloadedImage: UIImage?
if decryptedString.hasPrefix("data:") {
// Data URI format: "data:image/jpeg;base64,..."
if let commaIndex = decryptedString.firstIndex(of: ",") {
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part) {
downloadedImage = UIImage(data: imageData)
} else {
downloadedImage = nil
}
} else {
downloadedImage = nil
}
} else if let imageData = Data(base64Encoded: decryptedString) {
// Plain base64 (fallback)
downloadedImage = UIImage(data: imageData)
} else {
// Raw image data
downloadedImage = UIImage(data: decryptedData)
}
await MainActor.run { await MainActor.run {
if let downloadedImage { if let downloadedImage {
print("🖼️ [ImageView] ✅ Image decoded successfully for \(attachment.id)")
image = downloadedImage image = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
} else { } else {
print("🖼️ [ImageView] ❌ Failed to decode image data for \(attachment.id)")
downloadError = true downloadError = true
} }
isDownloading = false isDownloading = false
} }
} catch { } catch {
print("🖼️ [ImageView] ❌ Error for \(attachment.id): \(error.localizedDescription)")
await MainActor.run { await MainActor.run {
downloadError = true downloadError = true
isDownloading = false isDownloading = false
@@ -170,10 +247,59 @@ struct MessageImageView: View {
} }
} }
/// Tries each password candidate and validates the decrypted content is a real image.
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
) else { continue }
if let img = parseImageData(data) { return img }
}
// Fallback: try without requireCompression (legacy uncompressed payloads)
for password in passwords {
guard let data = try? crypto.decryptWithPassword(
encryptedString, password: password
) else { continue }
if let img = parseImageData(data) { return img }
}
return nil
}
/// Parses decrypted data as an image: data URI, plain base64, or raw image bytes.
private func parseImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8) {
if str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part),
let img = UIImage(data: imageData) {
return img
}
} else if let imageData = Data(base64Encoded: str),
let img = UIImage(data: imageData) {
return img
}
}
// Raw image data
return UIImage(data: data)
}
// MARK: - Preview Parsing
/// Extracts the server tag from preview string. /// Extracts the server tag from preview string.
/// Format: "tag::blurhash" or "tag::" returns "tag". /// Format: "tag::blurhash" or "tag::" returns "tag".
private func extractTag(from preview: String) -> String { private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::") let parts = preview.components(separatedBy: "::")
return parts.first ?? preview return parts.first ?? preview
} }
/// Extracts the blurhash from preview string.
/// Format: "tag::blurhash" returns "blurhash".
private func extractBlurHash(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.count > 1 ? parts[1] : ""
}
} }

View File

@@ -0,0 +1,243 @@
import SwiftUI
// MARK: - PhotoCollageView
/// Telegram-style photo collage layout for 15 image attachments.
///
/// Patterns:
/// - 1 photo: full width, aspect ratio preserved
/// - 2 photos: side by side, equal width, same height
/// - 3 photos: large left (2/3) + two stacked right (1/3)
/// - 4 photos: 2×2 grid
/// - 5 photos: 2 top + 3 bottom
///
/// A thin `borderWidth` padding lets the parent bubble's background color
/// show through as a colored border around the images (Telegram-style).
/// Inner corners match the outer `MessageBubbleShape` radii minus `borderWidth`.
struct PhotoCollageView: View {
let attachments: [MessageAttachment]
let message: ChatMessage
let outgoing: Bool
let maxWidth: CGFloat
let position: BubblePosition
/// Called when user taps a loaded image.
var onImageTap: ((UIImage) -> Void)?
/// Padding between images and bubble edge bubble background shows through.
private let borderWidth: CGFloat = 2
/// Gap between images in a multi-image grid.
private let spacing: CGFloat = 2
/// Bubble fill color used as gap color between collage cells.
private var bubbleColor: Color {
outgoing ? RosettaColors.figmaBlue : Color(hex: 0x2C2C2E)
}
/// Maximum collage height.
private let maxCollageHeight: CGFloat = 320
var body: some View {
let contentWidth = maxWidth - borderWidth * 2
collageContent(contentWidth: contentWidth)
.clipShape(InnerBubbleClipShape(position: position, outgoing: outgoing, inset: borderWidth))
.padding(borderWidth)
}
// MARK: - Content Router
@ViewBuilder
private func collageContent(contentWidth: CGFloat) -> some View {
switch attachments.count {
case 0:
EmptyView()
case 1:
singleImage(contentWidth: contentWidth)
case 2:
twoImages(contentWidth: contentWidth)
case 3:
threeImages(contentWidth: contentWidth)
case 4:
fourImages(contentWidth: contentWidth)
default:
fiveImages(contentWidth: contentWidth)
}
}
// MARK: - 1 Photo: Full Width
private func singleImage(contentWidth: CGFloat) -> some View {
MessageImageView(
attachment: attachments[0],
message: message,
outgoing: outgoing,
maxWidth: contentWidth,
onImageTap: onImageTap
)
}
// MARK: - 2 Photos: Side by Side
private func twoImages(contentWidth: CGFloat) -> some View {
let cellWidth = (contentWidth - spacing) / 2
let cellHeight = min(cellWidth * 1.2, maxCollageHeight)
return HStack(spacing: spacing) {
collageCell(attachments[0], width: cellWidth, height: cellHeight)
collageCell(attachments[1], width: cellWidth, height: cellHeight)
}
.frame(width: contentWidth, height: cellHeight)
.background(bubbleColor)
}
// MARK: - 3 Photos: 1 Large Left + 2 Stacked Right
private func threeImages(contentWidth: CGFloat) -> some View {
let rightWidth = contentWidth * 0.34
let leftWidth = contentWidth - spacing - rightWidth
let totalHeight = min(leftWidth * 1.1, maxCollageHeight)
let rightCellHeight = (totalHeight - spacing) / 2
return HStack(spacing: spacing) {
collageCell(attachments[0], width: leftWidth, height: totalHeight)
VStack(spacing: spacing) {
collageCell(attachments[1], width: rightWidth, height: rightCellHeight)
collageCell(attachments[2], width: rightWidth, height: rightCellHeight)
}
}
.frame(width: contentWidth, height: totalHeight)
.background(bubbleColor)
}
// MARK: - 4 Photos: 2×2 Grid
private func fourImages(contentWidth: CGFloat) -> some View {
let cellWidth = (contentWidth - spacing) / 2
let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2)
let totalHeight = cellHeight * 2 + spacing
return VStack(spacing: spacing) {
HStack(spacing: spacing) {
collageCell(attachments[0], width: cellWidth, height: cellHeight)
collageCell(attachments[1], width: cellWidth, height: cellHeight)
}
HStack(spacing: spacing) {
collageCell(attachments[2], width: cellWidth, height: cellHeight)
collageCell(attachments[3], width: cellWidth, height: cellHeight)
}
}
.frame(width: contentWidth, height: totalHeight)
.background(bubbleColor)
}
// MARK: - 5 Photos: 2 Top + 3 Bottom
private func fiveImages(contentWidth: CGFloat) -> some View {
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)
let totalHeight = topHeight + spacing + bottomHeight
return VStack(spacing: spacing) {
HStack(spacing: spacing) {
collageCell(attachments[0], width: topCellWidth, height: topHeight)
collageCell(attachments[1], width: topCellWidth, height: topHeight)
}
HStack(spacing: spacing) {
collageCell(attachments[2], width: bottomCellWidth, height: bottomHeight)
collageCell(attachments[3], width: bottomCellWidth, height: bottomHeight)
collageCell(attachments[4], width: bottomCellWidth, height: bottomHeight)
}
}
.frame(width: contentWidth, height: totalHeight)
.background(bubbleColor)
}
// MARK: - Collage Cell
@ViewBuilder
private func collageCell(
_ attachment: MessageAttachment,
width: CGFloat,
height: CGFloat
) -> some View {
MessageImageView(
attachment: attachment,
message: message,
outgoing: outgoing,
collageSize: CGSize(width: width, height: height),
maxWidth: width,
onImageTap: onImageTap
)
.frame(width: width, height: height)
.clipped()
}
}
// MARK: - Inner Bubble Clip Shape
/// Rounded rect that mirrors `MessageBubbleShape` corner radii but inset by `inset`.
/// Used to clip images inside the bubble so the border gap has matching corners.
struct InnerBubbleClipShape: Shape {
let position: BubblePosition
let outgoing: Bool
let inset: CGFloat
func path(in rect: CGRect) -> Path {
let r: CGFloat = max(18 - inset, 0)
let s: CGFloat = max(8 - inset, 0)
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR)
let cTR = min(tr, maxR)
let cBL = min(bl, maxR)
let cBR = min(br, maxR)
var p = Path()
p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR),
radius: cTR)
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY),
radius: cBR)
p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL),
radius: cBL)
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY),
radius: cTL)
p.closeSubpath()
return p
}
/// Same logic as `MessageBubbleShape.cornerRadii` but with inset-adjusted radii.
private func cornerRadii(r: CGFloat, s: CGFloat)
-> (topLeading: CGFloat, topTrailing: CGFloat,
bottomLeading: CGFloat, bottomTrailing: CGFloat) {
switch position {
case .single:
return (r, r, r, r)
case .top:
return outgoing ? (r, r, r, s) : (r, r, s, r)
case .mid:
return outgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom:
return outgoing ? (r, s, r, r) : (s, r, r, r)
}
}
}

View File

@@ -0,0 +1,143 @@
import SwiftUI
import UIKit
/// Telegram-style swipe-to-reply modifier for message bubbles.
/// Adds a left-swipe gesture that offsets the bubble and reveals a reply arrow icon.
/// On threshold crossing: light haptic feedback. On release past threshold: triggers reply.
///
/// Architecture: applied BETWEEN the inner bubble (`.frame(maxWidth: maxBubbleWidth)`)
/// and the outer full-width frame (`.frame(maxWidth: .infinity)`). The `.offset(x:)`
/// is visual-only (does not affect layout), so the `.overlay(alignment: .trailing)`
/// added after it is positioned at the bubble's ORIGINAL trailing edge. As the bubble
/// shifts left, the icon is revealed in the gap between the shifted bubble and the
/// original position.
struct SwipeToReplyModifier: ViewModifier {
let onReply: () -> Void
@State private var offset: CGFloat = 0
@State private var hasTriggeredHaptic = false
@State private var lockedAxis: SwipeAxis?
private enum SwipeAxis { case horizontal, vertical }
/// Minimum drag distance to trigger reply action.
private let threshold: CGFloat = 50
/// Offset where elastic resistance begins.
private let elasticCap: CGFloat = 80
/// Reply icon circle diameter.
private let iconSize: CGFloat = 34
func body(content: Content) -> some View {
content
.offset(x: offset)
.overlay(alignment: .trailing) {
replyIndicator
}
.simultaneousGesture(dragGesture)
}
// MARK: - Icon
/// Progress from 0 (hidden) to 1 (fully visible) based on drag offset.
private var iconProgress: CGFloat {
let absOffset = abs(offset)
guard absOffset > 4 else { return 0 }
return min((absOffset - 4) / (threshold - 4), 1)
}
@ViewBuilder
private var replyIndicator: some View {
Circle()
.fill(Color.white.opacity(0.12))
.frame(width: iconSize, height: iconSize)
.overlay {
TelegramVectorIcon(
pathData: TelegramIconPath.replyArrow,
viewBox: CGSize(width: 16, height: 13),
color: .white
)
.frame(width: 14, height: 11)
}
.scaleEffect(iconProgress)
.opacity(iconProgress)
}
// MARK: - Gesture
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 16, coordinateSpace: .local)
.onChanged { value in
// Lock axis on first significant movement to avoid
// interfering with vertical scroll or back-swipe navigation.
if lockedAxis == nil {
let dx = abs(value.translation.width)
let dy = abs(value.translation.height)
if dx > 16 || dy > 16 {
// Require clear horizontal dominance (2:1 ratio)
// AND must be leftward right swipe is back navigation.
let isLeftward = value.translation.width < 0
lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical
}
}
guard lockedAxis == .horizontal else { return }
// Only left swipe (negative)
let raw = min(value.translation.width, 0)
guard raw < 0 else {
if offset != 0 { offset = 0 }
return
}
// Elastic resistance past cap
let absRaw = abs(raw)
if absRaw > elasticCap {
let excess = absRaw - elasticCap
offset = -(elasticCap + excess * 0.15)
} else {
offset = raw
}
// Haptic at threshold (once per gesture)
if abs(offset) >= threshold, !hasTriggeredHaptic {
hasTriggeredHaptic = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
}
.onEnded { _ in
let shouldReply = abs(offset) >= threshold
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) {
offset = 0
}
lockedAxis = nil
hasTriggeredHaptic = false
if shouldReply {
onReply()
}
}
}
}
/// Conditionally applies swipe-to-reply. When disabled, passes content through unchanged
/// (no gesture, no icon, no animation).
struct ConditionalSwipeToReply: ViewModifier {
let enabled: Bool
let onReply: () -> Void
func body(content: Content) -> some View {
if enabled {
content.modifier(SwipeToReplyModifier(onReply: onReply))
} else {
content
}
}
}
// MARK: - View Extension
extension View {
/// Adds Telegram-style swipe-to-reply gesture to a message bubble.
func swipeToReply(onReply: @escaping () -> Void) -> some View {
modifier(SwipeToReplyModifier(onReply: onReply))
}
}

View File

@@ -383,44 +383,56 @@ private struct ToolbarTitleView: View {
} }
} }
/// Desktop parity: circular spinner + status text (Mantine `<Loader size={12}>` equivalent). /// Status text label without spinner (spinner is in ToolbarStoriesAvatar).
private struct ToolbarStatusLabel: View { private struct ToolbarStatusLabel: View {
let title: String let title: String
@State private var isSpinning = false
var body: some View { var body: some View {
HStack(spacing: 5) { Text(title)
Circle() .font(.system(size: 17, weight: .semibold))
.trim(from: 0.05, to: 0.75) .foregroundStyle(RosettaColors.Adaptive.text)
.stroke(RosettaColors.Adaptive.text, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
.frame(width: 12, height: 12)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isSpinning)
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.onAppear { isSpinning = true }
} }
} }
// MARK: - Toolbar Stories Avatar (observation-isolated) // MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager` and `SessionManager` in its own observation scope. /// Reads `AccountManager`, `SessionManager`, and `ProtocolManager` in its own observation scope.
/// Changes to these `@Observable` singletons only re-render this small view, /// Shows a spinning arc loader during connecting/syncing, then crossfades to avatar.
/// not the parent ChatListView / NavigationStack.
private struct ToolbarStoriesAvatar: View { private struct ToolbarStoriesAvatar: View {
@State private var isSpinning = false
var body: some View { var body: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? "" let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let state = ProtocolManager.shared.connectionState
let isSyncing = SessionManager.shared.syncBatchInProgress
let isLoading = state != .authenticated || isSyncing
let initials = RosettaColors.initials( let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk name: SessionManager.shared.displayName, publicKey: pk
) )
let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk) let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk)
// Reading avatarVersion triggers observation re-renders when any avatar is saved/removed.
let _ = AvatarRepository.shared.avatarVersion let _ = AvatarRepository.shared.avatarVersion
let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk) let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar) }
ZStack {
// Avatar visible when loaded
AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar)
.opacity(isLoading ? 0 : 1)
// Spinning arc loader visible during connecting/syncing
Circle()
.trim(from: 0.05, to: 0.78)
.stroke(
RosettaColors.figmaBlue,
style: StrokeStyle(lineWidth: 2, lineCap: .round)
)
.frame(width: 20, height: 20)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.opacity(isLoading ? 1 : 0)
}
.animation(.easeInOut(duration: 0.3), value: isLoading)
.onAppear { isSpinning = true }
.animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isSpinning)
} }
} }

View File

@@ -17,7 +17,7 @@
<key>IS_ADS_ENABLED</key> <key>IS_ADS_ENABLED</key>
<false></false> <false></false>
<key>IS_ANALYTICS_ENABLED</key> <key>IS_ANALYTICS_ENABLED</key>
<false></false> <true></true>
<key>IS_APPINVITE_ENABLED</key> <key>IS_APPINVITE_ENABLED</key>
<true></true> <true></true>
<key>IS_GCM_ENABLED</key> <key>IS_GCM_ENABLED</key>

View File

@@ -1,4 +1,5 @@
import FirebaseCore import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging import FirebaseMessaging
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@@ -60,6 +61,63 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
Messaging.messaging().apnsToken = deviceToken Messaging.messaging().apnsToken = deviceToken
} }
// MARK: - Background Push (Badge + Local Notification with Sound)
/// Called when a push notification arrives with `content-available: 1`.
/// Server does NOT send `sound` in APNs payload we always create a local
/// notification with `.default` sound to ensure vibration works.
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Foreground: WebSocket handles messages + haptic feedback skip.
guard application.applicationState != .active else {
completionHandler(.noData)
return
}
// Background/inactive: increment badge from persisted count.
let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count")
let newBadge = currentBadge + 1
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
let senderKey = userInfo["sender_public_key"] as? String ?? ""
let senderName = userInfo["sender_name"] as? String ?? "New message"
let messageText = userInfo["message"] as? String ?? "New message"
// Don't notify for muted chats.
let isMuted = Task { @MainActor in
DialogRepository.shared.dialogs[senderKey]?.isMuted == true
}
Task {
let muted = await isMuted.value
guard !muted else {
completionHandler(.newData)
return
}
let content = UNMutableNotificationContent()
content.title = senderName
content.body = messageText
content.sound = .default
content.badge = NSNumber(value: newBadge)
content.categoryIdentifier = "message"
if !senderKey.isEmpty {
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
}
let request = UNNotificationRequest(
identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))",
content: content,
trigger: nil
)
try? await UNUserNotificationCenter.current().add(request)
completionHandler(.newData)
}
}
// MARK: - MessagingDelegate // MARK: - MessagingDelegate
/// Called when FCM token is received or refreshed. /// Called when FCM token is received or refreshed.