Кросс-платформенное шифрование фото/аватаров, профиль собеседника, вложения в чате

This commit is contained in:
2026-03-16 05:57:07 +05:00
parent dd4642f251
commit 624038915d
43 changed files with 5212 additions and 656 deletions

View File

@@ -6,6 +6,10 @@
<false/>
<key>NSFaceIDUsageDescription</key>
<string>Rosetta uses Face ID to unlock your account securely without entering your password.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Rosetta needs access to your photo library to send images in chats.</string>
<key>NSCameraUsageDescription</key>
<string>Rosetta needs access to your camera to take and send photos in chats.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>

View File

@@ -272,7 +272,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -288,7 +288,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.3;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -311,7 +311,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -327,7 +327,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.3;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "updates.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -90,15 +90,15 @@ final class CryptoManager: @unchecked Sendable {
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
}
// MARK: - Desktop-Compatible Encryption (PBKDF2-SHA1 + zlibDeflate + AES-256-CBC)
// MARK: - Desktop-Compatible Encryption (PBKDF2-SHA256 + zlibDeflate + AES-256-CBC)
/// Desktop parity: CryptoJS PBKDF2 defaults to HMAC-SHA1, pako.deflate() is zlib-wrapped.
/// Desktop parity: CryptoJS v4 PBKDF2 defaults to HMAC-SHA256, pako.deflate() is zlib-wrapped.
/// Use ONLY for cross-platform data: aesChachaKey, avatar blobs sent to server.
nonisolated func encryptWithPasswordDesktopCompat(_ data: Data, password: String) throws -> String {
let compressed = try CryptoPrimitives.zlibDeflate(data)
let key = CryptoPrimitives.pbkdf2(
password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
let iv = try CryptoPrimitives.randomBytes(count: 16)
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
@@ -110,39 +110,53 @@ final class CryptoManager: @unchecked Sendable {
guard parts.count == 2,
let iv = Data(base64Encoded: parts[0]),
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")
}
// SHA256 first: all existing iOS data was encrypted with SHA256.
// SHA1 fallback: desktop CryptoJS default + encryptWithPasswordDesktopCompat.
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] = [
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // iOS encryptWithPassword
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Desktop / encryptWithPasswordDesktopCompat
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop CryptoJS v4 + iOS
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy fallback
]
// 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate)
for prf in prfOrder {
if let result = try? decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: true
) {
let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1"
do {
let result = try decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: true
)
print("🔐 [decrypt] ✅ \(prfName)+compressed succeeded, result=\(result.count)bytes, first4=\(result.prefix(4).map { String(format: "%02x", $0) }.joined())")
return result
} catch {
print("🔐 [decrypt] ⚠️ \(prfName)+compressed failed: \(error)")
}
}
// 2) Fallback: AES-CBC without compression (very old/legacy payloads)
for prf in prfOrder {
if let result = try? decryptWithPassword(
ciphertext: ciphertext, iv: iv, password: password,
prf: prf, expectsCompressed: false
) {
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
}
@@ -166,6 +180,7 @@ private extension CryptoManager {
prf: CCPseudoRandomAlgorithm,
expectsCompressed: Bool
) throws -> Data {
let prfName = prf == CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) ? "SHA256" : "SHA1"
let key = CryptoPrimitives.pbkdf2(
password: password,
salt: "rosetta",
@@ -173,7 +188,9 @@ private extension CryptoManager {
keyLength: 32,
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)
print("🔐 [decrypt-inner] \(prfName) aesDecrypted=\(decrypted.count)bytes first16=\(decrypted.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " "))")
if expectsCompressed {
return try CryptoPrimitives.rawInflate(decrypted)
}

View File

@@ -79,15 +79,21 @@ enum CryptoPrimitives {
keyLength: Int,
prf: CCPseudoRandomAlgorithm
) -> Data {
// Desktop parity: CryptoJS PBKDF2 encodes the full UTF-8 string
// including embedded null bytes (U+0000). Swift's withCString +
// strlen() truncates at the first null byte, producing a different
// PBKDF2 key. Use explicit UTF-8 byte arrays to match JavaScript.
let passwordBytes = Array(password.utf8)
let saltBytes = Array(salt.utf8)
var derivedKey = Data(repeating: 0, count: keyLength)
derivedKey.withUnsafeMutableBytes { keyPtr in
guard let keyBase = keyPtr.bindMemory(to: UInt8.self).baseAddress else { return }
password.withCString { passPtr in
salt.withCString { saltPtr in
passwordBytes.withUnsafeBufferPointer { passPtr in
saltBytes.withUnsafeBufferPointer { saltPtr in
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passPtr, strlen(passPtr),
saltPtr, strlen(saltPtr),
passPtr.baseAddress, passPtr.count,
saltPtr.baseAddress, saltPtr.count,
prf,
UInt32(iterations),
keyBase,

View File

@@ -12,7 +12,7 @@ enum XChaCha20Engine {
/// Decrypts ciphertext+tag using XChaCha20-Poly1305.
static func decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count > poly1305TagSize else {
guard ciphertextWithTag.count >= poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
}
guard key.count == 32, nonce.count == 24 else {

View File

@@ -0,0 +1,68 @@
import UIKit
import os
// MARK: - AttachmentCache
/// Local disk cache for downloaded/decrypted attachment images and files.
///
/// Desktop parity: `readFile("m/...")` and `writeFile(...)` in `DialogProvider.tsx`.
/// Attachments are cached after download+decrypt so subsequent opens are instant.
///
/// Cache directory: `Documents/AttachmentCache/`
/// Key format: attachment ID (8-char random string).
final class AttachmentCache: @unchecked Sendable {
static let shared = AttachmentCache()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AttachmentCache")
private let cacheDir: URL
private init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
cacheDir = docs.appendingPathComponent("AttachmentCache", isDirectory: true)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
// MARK: - Images
/// Saves a decoded image to cache.
func saveImage(_ image: UIImage, forAttachmentId id: String) {
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
let url = cacheDir.appendingPathComponent("img_\(id).jpg")
try? data.write(to: url, options: .atomic)
}
/// Loads a cached image for an attachment ID, or `nil` if not cached.
func loadImage(forAttachmentId id: String) -> UIImage? {
let url = cacheDir.appendingPathComponent("img_\(id).jpg")
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return UIImage(contentsOfFile: url.path)
}
// MARK: - Files
/// Saves raw file data to cache, returns the file URL.
@discardableResult
func saveFile(_ data: Data, forAttachmentId id: String, fileName: String) -> URL {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)")
try? data.write(to: url, options: .atomic)
return url
}
/// Returns cached file URL, or `nil` if not cached.
func fileURL(forAttachmentId id: String, fileName: String) -> URL? {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName)")
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
// MARK: - Cleanup
/// Removes all cached attachments.
func clearAll() {
try? FileManager.default.removeItem(at: cacheDir)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
}

View File

@@ -11,6 +11,12 @@ struct ChatMessage: Identifiable, Codable, Sendable {
var isRead: Bool
var attachments: [MessageAttachment]
/// Desktop parity: stores the decrypted plainKeyAndNonce (latin1) for on-demand
/// attachment download & decryption. Computed during message decryption in
/// `handleIncomingMessage()` and during outgoing send in `sendMessageWithAttachments()`.
/// `nil` for messages without attachments or legacy persisted messages.
var attachmentPassword: String?
func isFromMe(myPublicKey: String) -> Bool {
fromPublicKey == myPublicKey
}

View File

@@ -21,7 +21,13 @@ final class AvatarRepository {
private(set) var avatarVersion: UInt = 0
/// In-memory cache for decoded UIImages keyed by normalized public key.
private let cache = NSCache<NSString, UIImage>()
/// Bounded: 50MB / 200 images max to prevent memory pressure on low-end devices.
private let cache: NSCache<NSString, UIImage> = {
let c = NSCache<NSString, UIImage>()
c.totalCostLimit = 50 * 1024 * 1024 // 50MB
c.countLimit = 200
return c
}()
/// JPEG compression quality (0.8 = reasonable size for avatars).
private let compressionQuality: CGFloat = 0.8
@@ -36,14 +42,22 @@ final class AvatarRepository {
let url = avatarURL(for: key)
ensureDirectoryExists()
try? data.write(to: url, options: .atomic)
cache.setObject(image, forKey: key as NSString)
cache.setObject(image, forKey: key as NSString, cost: data.count)
avatarVersion += 1
}
/// Saves an avatar from a base64-encoded image string (used when receiving from network).
/// Desktop parity: re-encodes with `AVATAR_PASSWORD_TO_ENCODE` iOS stores as plain JPEG.
/// Desktop parity: desktop sends data URI format (`data:image/png;base64,...`).
/// Handles both raw base64 and data URI formats for cross-platform compatibility.
func saveAvatarFromBase64(_ base64: String, publicKey: String) {
guard let data = Data(base64Encoded: base64),
let rawBase64: String
if base64.hasPrefix("data:") {
// Desktop format: "data:image/png;base64,iVBOR..." strip prefix
rawBase64 = String(base64.drop(while: { $0 != "," }).dropFirst())
} else {
rawBase64 = base64
}
guard let data = Data(base64Encoded: rawBase64),
let image = UIImage(data: data) else { return }
saveAvatar(publicKey: publicKey, image: image)
}
@@ -65,15 +79,19 @@ final class AvatarRepository {
let image = UIImage(data: data) else {
return nil
}
cache.setObject(image, forKey: key as NSString)
cache.setObject(image, forKey: key as NSString, cost: data.count)
return image
}
/// Returns bundled avatar for system accounts, nil for regular accounts.
/// Desktop parity: `useSystemAccounts.ts` imports `updates.png` and `safe.png`.
private func systemAccountAvatar(for publicKey: String) -> UIImage? {
if publicKey == SystemAccounts.safePublicKey {
return UIImage(named: "safe-avatar")
}
if publicKey == SystemAccounts.updatesPublicKey {
return UIImage(named: "updates-avatar")
}
return nil
}
@@ -96,6 +114,11 @@ final class AvatarRepository {
avatarVersion += 1
}
/// Clears in-memory cache only (used on memory warning). Disk avatars preserved.
func clearCache() {
cache.removeAllObjects()
}
/// Clears entire avatar cache (used on full data reset).
func clearAll() {
cache.removeAllObjects()

View File

@@ -1,5 +1,6 @@
import Foundation
import Observation
import UserNotifications
/// Account-scoped dialog store with disk persistence.
@Observable
@@ -79,6 +80,7 @@ final class DialogRepository {
.map { ($0.opponentKey, $0) }
)
_sortedKeysCache = nil
updateAppBadge()
}
func reset(clearPersisted: Bool = false) {
@@ -87,6 +89,7 @@ final class DialogRepository {
dialogs.removeAll()
_sortedKeysCache = nil
storagePassword = ""
UNUserNotificationCenter.current().setBadgeCount(0)
guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount
@@ -141,7 +144,19 @@ final class DialogRepository {
lastMessageDelivered: .waiting
)
dialog.lastMessage = decryptedText
// Desktop parity: constructLastMessageTextByAttachments() returns
// "Photo"/"Avatar"/"File" for attachment-only messages.
if decryptedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let firstAttachment = packet.attachments.first {
switch firstAttachment.type {
case .image: dialog.lastMessage = "Photo"
case .file: dialog.lastMessage = "File"
case .avatar: dialog.lastMessage = "Avatar"
default: dialog.lastMessage = decryptedText
}
} else {
dialog.lastMessage = decryptedText
}
dialog.lastMessageTimestamp = normalizeTimestamp(packet.timestamp)
dialog.lastMessageFromMe = fromMe
dialog.lastMessageDelivered = fromMe ? (fromSync ? .delivered : .waiting) : .delivered
@@ -165,7 +180,12 @@ final class DialogRepository {
schedulePersist()
// Desktop parity: re-evaluate request status based on last N messages.
updateRequestStatus(opponentKey: opponentKey)
// Skip for outgoing messages iHaveSent is already set to true above,
// and the message hasn't been added to MessageRepository yet (race condition:
// updateRequestStatus reads MessageRepository BEFORE upsertFromMessagePacket).
if !fromMe {
updateRequestStatus(opponentKey: opponentKey)
}
}
func ensureDialog(
@@ -314,7 +334,17 @@ final class DialogRepository {
return
}
dialog.lastMessage = lastMsg.text
if lastMsg.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let firstAttachment = lastMsg.attachments.first {
switch firstAttachment.type {
case .image: dialog.lastMessage = "Photo"
case .file: dialog.lastMessage = "File"
case .avatar: dialog.lastMessage = "Avatar"
default: dialog.lastMessage = lastMsg.text
}
} else {
dialog.lastMessage = lastMsg.text
}
dialog.lastMessageTimestamp = lastMsg.timestamp
dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount
dialog.lastMessageDelivered = lastMsg.deliveryStatus
@@ -436,6 +466,8 @@ final class DialogRepository {
private func schedulePersist() {
guard !currentAccount.isEmpty else { return }
updateAppBadge()
let snapshot = Array(dialogs.values)
let fileName = Self.dialogsFileName(for: currentAccount)
let storagePassword = self.storagePassword
@@ -451,6 +483,12 @@ final class DialogRepository {
}
}
/// Update app icon badge with total unread message count.
private func updateAppBadge() {
let total = dialogs.values.reduce(0) { $0 + $1.unreadCount }
UNUserNotificationCenter.current().setBadgeCount(total)
}
private static func dialogsFileName(for accountPublicKey: String) -> String {
ChatPersistenceStore.accountScopedFileName(prefix: "dialogs", accountPublicKey: accountPublicKey)
}

View File

@@ -120,7 +120,13 @@ final class MessageRepository: ObservableObject {
/// - Parameter fromSync: When `true`, outgoing messages are created as `.delivered`
/// because the server already processed them during sync ACKs will never arrive again.
func upsertFromMessagePacket(_ packet: PacketMessage, myPublicKey: String, decryptedText: String, fromSync: Bool = false) {
func upsertFromMessagePacket(
_ packet: PacketMessage,
myPublicKey: String,
decryptedText: String,
attachmentPassword: String? = nil,
fromSync: Bool = false
) {
let fromMe = packet.fromPublicKey == myPublicKey
let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
@@ -138,6 +144,10 @@ final class MessageRepository: ObservableObject {
messages[existingIndex].text = decryptedText
messages[existingIndex].timestamp = timestamp
messages[existingIndex].attachments = packet.attachments
// Preserve or update attachment password for on-demand download
if let attachmentPassword, !attachmentPassword.isEmpty {
messages[existingIndex].attachmentPassword = attachmentPassword
}
if fromMe, messages[existingIndex].deliveryStatus == .error {
messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting
}
@@ -156,7 +166,8 @@ final class MessageRepository: ObservableObject {
timestamp: timestamp,
deliveryStatus: outgoingStatus,
isRead: incomingRead || fromMe,
attachments: packet.attachments
attachments: packet.attachments,
attachmentPassword: attachmentPassword
)
)
}
@@ -211,7 +222,11 @@ final class MessageRepository: ObservableObject {
// MARK: - Typing
func markTyping(from opponentKey: String) {
typingDialogs.insert(opponentKey)
// Guard: only publish if not already in set prevents duplicate re-renders
// when multiple typing packets arrive within the 3s window.
if !typingDialogs.contains(opponentKey) {
typingDialogs.insert(opponentKey)
}
typingResetTasks[opponentKey]?.cancel()
typingResetTasks[opponentKey] = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
@@ -330,6 +345,15 @@ final class MessageRepository: ObservableObject {
}
return $0.id < $1.id
}
// Desktop parity: cap to MESSAGE_MAX_LOADED (40) newest messages per dialog.
// Keeps memory bounded evicted IDs stay in allKnownMessageIds for dedup.
if messages.count > ProtocolConstants.messageMaxCached {
let evicted = messages.prefix(messages.count - ProtocolConstants.messageMaxCached)
for message in evicted {
messageToDialog.removeValue(forKey: message.id)
}
messages = Array(messages.suffix(ProtocolConstants.messageMaxCached))
}
}
messagesByDialog[dialogKey] = messages
schedulePersist()
@@ -349,9 +373,12 @@ final class MessageRepository: ObservableObject {
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
let storagePassword = self.storagePassword
let password = storagePassword.isEmpty ? nil : storagePassword
// During sync bursts, increase debounce to reduce disk I/O (full dict serialization).
let isSyncing = SessionManager.shared.syncBatchInProgress
let debounceMs = isSyncing ? 2000 : 400
persistTask?.cancel()
persistTask = Task(priority: .utility) {
try? await Task.sleep(for: .milliseconds(400))
try? await Task.sleep(for: .milliseconds(debounceMs))
guard !Task.isCancelled else { return }
await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password)
await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password)

View File

@@ -104,10 +104,50 @@ final class ProtocolManager: @unchecked Sendable {
}
}
/// Immediately reconnect after returning from background, bypassing backoff.
/// Verify connection health after returning from background.
/// If connection appears alive, sends a WebSocket ping to confirm.
/// If ping fails or times out (2s), forces immediate reconnection.
func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
if connectionState == .authenticated || connectionState == .handshaking { return }
// Don't interrupt active handshake
if connectionState == .handshaking { return }
if connectionState == .authenticated && client.isConnected {
// Connection looks alive verify with ping (2s timeout)
Self.logger.info("Verifying connection with ping...")
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
client.forceReconnect()

View File

@@ -60,9 +60,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
reconnectTask?.cancel()
reconnectTask = nil
guard !isConnected else { return }
// Always tear down and reconnect connection may be zombie after background
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
disconnectHandledForCurrentSocket = false
Self.logger.info("Force reconnect triggered")
connect()
@@ -87,13 +88,27 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
func sendText(_ text: String) {
guard let task = webSocketTask else { return }
task.send(.string(text)) { error in
task.send(.string(text)) { [weak self] error in
if let error {
Self.logger.error("Send text error: \(error.localizedDescription)")
self?.handleDisconnect(error: error)
}
}
}
/// Sends a WebSocket-level ping to verify the connection is alive.
/// Completion receives nil on success (pong received) or an Error on failure.
func sendPing(completion: @escaping (Error?) -> Void) {
guard let task = webSocketTask else {
completion(NSError(domain: "WebSocket", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No active WebSocket task"]))
return
}
task.sendPing { error in
completion(error)
}
}
// MARK: - URLSessionWebSocketDelegate
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {

View File

@@ -99,6 +99,10 @@ final class SessionManager {
)
RecentSearchesRepository.shared.setAccount(account.publicKey)
// Desktop parity: send release notes as a system message from "Rosetta Updates"
// account if the app version changed since the last notice.
sendReleaseNotesIfNeeded(publicKey: account.publicKey)
// Generate private key hash for handshake
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
privateKeyHash = hash
@@ -215,12 +219,13 @@ final class SessionManager {
// Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string
// (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts)
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed
}
// Must use UTF-8 decoding with replacement characters (U+FFFD) to match Node.js behavior.
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Encrypt avatar blob with the plainKeyAndNonce password (desktop: encodeWithPassword)
let avatarData = Data(avatarBase64.utf8)
// Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...")
// not just raw base64. Desktop's AvatarProvider stores and sends data URIs.
let dataURI = "data:image/jpeg;base64,\(avatarBase64)"
let avatarData = Data(dataURI.utf8)
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
avatarData,
password: latin1String
@@ -232,8 +237,10 @@ final class SessionManager {
content: Data(encryptedBlob.utf8)
)
// Desktop parity: preview = "tag::blurhash" (blurhash optional, skip for now)
let preview = "\(tag)::"
// Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments)
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 4)) ?? ""
let preview = "\(tag)::\(blurhash)"
// Build aesChachaKey (same as regular messages)
let aesChachaPayload = Data(latin1String.utf8)
@@ -279,7 +286,8 @@ final class SessionManager {
packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend
)
MessageRepository.shared.upsertFromMessagePacket(
packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend
packet, myPublicKey: currentPublicKey, decryptedText: " ",
attachmentPassword: latin1String, fromSync: offlineAsSend
)
if offlineAsSend {
@@ -299,6 +307,187 @@ final class SessionManager {
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
}
/// Sends a message with image/file attachments.
///
/// Desktop parity: `prepareAttachmentsToSend()` in `DialogProvider.tsx`
/// for each attachment: encrypt blob upload to transport set preview = tag clear blob.
///
/// Flow per attachment:
/// 1. Build data URI (desktop: `FileReader.readAsDataURL()`)
/// 2. Encrypt with `encryptWithPasswordDesktopCompat(dataURI, password: latin1OfPlainKeyAndNonce)`
/// 3. Upload encrypted blob to transport get server tag
/// 4. Set preview: IMAGE `"tag::"`, FILE `"tag::size::filename"`
/// 5. Clear blob (not sent over WebSocket)
func sendMessageWithAttachments(
text: String,
attachments: [PendingAttachment],
toPublicKey: String,
opponentTitle: String = "",
opponentUsername: String = ""
) async throws {
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
Self.logger.error("📤 Cannot send — 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
)
// Derive attachment password from plainKeyAndNonce (desktop: key.toString('utf-8'))
// Must use UTF-8 decoding with replacement characters (U+FFFD for invalid sequences)
// to match Node.js Buffer.toString('utf-8') behavior exactly.
// Previously used .isoLatin1 which produced different PBKDF2 keys for bytes > 0x7F.
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Process each attachment: encrypt upload build metadata
var messageAttachments: [MessageAttachment] = []
for attachment in attachments {
// Build data URI (desktop: FileReader.readAsDataURL)
let dataURI = buildDataURI(attachment)
// Encrypt blob with desktop-compatible encryption
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8),
password: latin1String
)
// Upload to transport server
let tag = try await TransportManager.shared.uploadFile(
id: attachment.id,
content: Data(encryptedBlob.utf8)
)
// Build preview string (format depends on type)
// Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files
let preview: String
switch attachment.type {
case .image:
// Generate blurhash from thumbnail (desktop: encode(imageData, width, height, 4, 4))
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 4)) ?? ""
preview = "\(tag)::\(blurhash)"
case .file:
// Desktop: preview = "tag::size::filename"
preview = "\(tag)::\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
default:
preview = "\(tag)::"
}
messageAttachments.append(MessageAttachment(
id: attachment.id,
preview: preview,
blob: "", // Desktop parity: blob cleared after upload
type: attachment.type
))
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)")
}
// Build aesChachaKey (for sync/backup same as regular messages)
let aesChachaPayload = Data(latin1String.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload,
password: privKey
)
// Build PacketMessage with attachments
var packet = PacketMessage()
packet.fromPublicKey = currentPublicKey
packet.toPublicKey = toPublicKey
packet.content = encrypted.content
packet.chachaKey = encrypted.chachaKey
packet.timestamp = timestamp
packet.privateKey = hash
packet.messageId = messageId
packet.aesChachaKey = aesChachaKey
packet.attachments = messageAttachments
// 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
let isConnected = ProtocolManager.shared.connectionState == .authenticated
let offlineAsSend = !isConnected
// For outgoing messages, store attachment password so we can view our own sent images
let displayText = messageText == " " ? " " : messageText
DialogRepository.shared.updateFromMessage(
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
)
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: currentPublicKey,
decryptedText: displayText,
attachmentPassword: latin1String,
fromSync: offlineAsSend
)
if offlineAsSend {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, opponentKey: toPublicKey, status: .error
)
}
// Saved Messages: local-only, no server send
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, opponentKey: toPublicKey, status: .delivered
)
return
}
ProtocolManager.shared.sendPacket(packet)
registerOutgoingRetry(for: packet)
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))")
}
/// Builds a data URI from attachment data (desktop: `FileReader.readAsDataURL()`).
private func buildDataURI(_ attachment: PendingAttachment) -> String {
let base64 = attachment.data.base64EncodedString()
switch attachment.type {
case .image:
return "data:image/jpeg;base64,\(base64)"
case .file:
let mimeType = mimeTypeForFileName(attachment.fileName ?? "file")
return "data:\(mimeType);base64,\(base64)"
default:
return "data:application/octet-stream;base64,\(base64)"
}
}
/// Returns MIME type for a file name based on extension.
private func mimeTypeForFileName(_ name: String) -> String {
let ext = (name as NSString).pathExtension.lowercased()
switch ext {
case "jpg", "jpeg": return "image/jpeg"
case "png": return "image/png"
case "gif": return "image/gif"
case "pdf": return "application/pdf"
case "zip": return "application/zip"
case "mp4": return "video/mp4"
case "mp3": return "audio/mpeg"
case "doc", "docx": return "application/msword"
case "txt": return "text/plain"
default: return "application/octet-stream"
}
}
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
func sendTypingIndicator(toPublicKey: String) {
guard toPublicKey != currentPublicKey,
@@ -657,7 +846,13 @@ final class SessionManager {
let fromMe = packet.fromPublicKey == myKey
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { return }
// 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 {
print("🔍 [processIncoming] ❌ SKIPPED by isSupportedDirectMessagePacket — myKey=\(myKey.prefix(12))")
return
}
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
@@ -668,13 +863,33 @@ final class SessionManager {
privateKeyHex: currentPrivateKeyHex
)
guard let result = decryptResult else { return }
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)")
return
}
let text = result.text
// Desktop parity: decrypt MESSAGES-type attachment blobs inline.
var processedPacket = packet
// Store attachment password for on-demand image/file download in UI.
var resolvedAttachmentPassword: String?
// Debug: log attachment info for incoming messages
if !processedPacket.attachments.isEmpty {
Self.logger.info("📎 Message \(packet.messageId.prefix(8))… has \(processedPacket.attachments.count) attachment(s), rawKeyData=\(result.rawKeyData != nil ? "\(result.rawKeyData!.count) bytes" : "nil")")
for (i, att) in processedPacket.attachments.enumerated() {
Self.logger.info(" 📎[\(i)] type=\(att.type.rawValue) id=\(att.id) preview=\(att.preview.prefix(60))")
}
}
if let keyData = result.rawKeyData {
// Desktop parity: Buffer.toString('utf-8') replaces invalid UTF-8 bytes with U+FFFD.
// Must match sending side encoding for correct PBKDF2 key derivation.
let attachmentPassword = String(decoding: keyData, as: UTF8.self)
resolvedAttachmentPassword = attachmentPassword
print("🔑 [attachPwd] rawKeyData(\(keyData.count)bytes)=\(keyData.map { String(format: "%02x", $0) }.joined(separator: " "))")
print("🔑 [attachPwd] passwordUTF8(\(Array(attachmentPassword.utf8).count)bytes)=\(Array(attachmentPassword.utf8).map { String(format: "%02x", $0) }.joined(separator: " "))")
print("🔑 [attachPwd] passwordChars=\(attachmentPassword.count)")
for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
let blob = processedPacket.attachments[i].blob
if !blob.isEmpty,
@@ -726,6 +941,7 @@ final class SessionManager {
processedPacket,
myPublicKey: myKey,
decryptedText: text,
attachmentPassword: resolvedAttachmentPassword,
fromSync: syncBatchInProgress
)
@@ -879,10 +1095,14 @@ final class SessionManager {
) -> (text: String, rawKeyData: Data?)? {
let isOwnMessage = packet.fromPublicKey == myPublicKey
guard let privateKeyHex, !packet.content.isEmpty else { return nil }
guard let privateKeyHex, !packet.content.isEmpty else {
print("🔍 [decrypt] ❌ Early return: privateKeyHex=\(privateKeyHex != nil ? "present" : "NIL") content.isEmpty=\(packet.content.isEmpty)")
return nil
}
// Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
if isOwnMessage, !packet.aesChachaKey.isEmpty {
print("🔍 [decrypt] Trying AES-CHACHA path (own sync) for msgId=\(packet.messageId.prefix(8))")
do {
let decryptedPayload = try CryptoManager.shared.decryptWithPassword(
packet.aesChachaKey,
@@ -893,15 +1113,20 @@ final class SessionManager {
ciphertext: packet.content,
plainKeyAndNonce: keyAndNonce
)
return (text, keyAndNonce)
print("🔍 [decrypt] ✅ AES-CHACHA path succeeded, text=\(text.prefix(30))… rawKeyData=\(decryptedPayload.count) bytes")
return (text, decryptedPayload)
} catch {
// Fall through to ECDH path
print("🔍 [decrypt] ⚠️ AES-CHACHA path failed: \(error). Falling through to ECDH…")
}
}
// ECDH path (works for opponent messages, may work for own if chachaKey targets us)
guard !packet.chachaKey.isEmpty else { return nil }
guard !packet.chachaKey.isEmpty else {
print("🔍 [decrypt] ❌ chachaKey is empty, no ECDH path available. isOwnMessage=\(isOwnMessage)")
return nil
}
print("🔍 [decrypt] Trying ECDH path for msgId=\(packet.messageId.prefix(8))… chachaKey=\(packet.chachaKey.prefix(30))")
do {
let text = try MessageCrypto.decryptIncoming(
ciphertext: packet.content,
@@ -912,8 +1137,10 @@ final class SessionManager {
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 {
print("🔍 [decrypt] ❌ ECDH path failed: \(error)")
return nil
}
}
@@ -1295,6 +1522,59 @@ final class SessionManager {
Self.logger.info("Push token sent to server")
}
// MARK: - Release Notes (Desktop Parity)
/// Desktop parity: sends release notes as a local system message from
/// the "Rosetta Updates" account when the app version changes.
/// Desktop equivalent: `useUpdateMessage.ts` `useSendSystemMessage("updates")`.
private func sendReleaseNotesIfNeeded(publicKey: String) {
let key = "lastReleaseNoticeVersion_\(publicKey)"
let lastVersion = UserDefaults.standard.string(forKey: key) ?? ""
let currentVersion = ReleaseNotes.appVersion
guard lastVersion != currentVersion else { return }
let noticeText = ReleaseNotes.releaseNoticeText
guard !noticeText.isEmpty else { return }
let now = Int64(Date().timeIntervalSince1970 * 1000)
let messageId = "release_notes_\(currentVersion)"
// Create synthetic PacketMessage local-only, never sent to server.
var packet = PacketMessage()
packet.fromPublicKey = Account.updatesPublicKey
packet.toPublicKey = publicKey
packet.timestamp = now
packet.messageId = messageId
// Insert message into MessageRepository (delivered immediately).
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: publicKey,
decryptedText: noticeText
)
// Create/update dialog in DialogRepository.
DialogRepository.shared.updateFromMessage(
packet,
myPublicKey: publicKey,
decryptedText: noticeText,
isNewMessage: true
)
// Set system account display info (title, username, verified badge).
// Desktop parity: both system accounts use verified=1 (blue rosette badge).
DialogRepository.shared.updateUserInfo(
publicKey: Account.updatesPublicKey,
title: SystemAccounts.updatesTitle,
username: "updates",
verified: 1
)
UserDefaults.standard.set(currentVersion, forKey: key)
Self.logger.info("Release notes v\(currentVersion) sent to Updates chat")
}
// MARK: - Idle Detection Setup
private func setupIdleDetection() {
@@ -1306,10 +1586,9 @@ final class SessionManager {
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.lastUserInteractionTime = Date()
// Immediate reconnect when returning from background
if ProtocolManager.shared.connectionState != .authenticated {
ProtocolManager.shared.reconnectIfNeeded()
}
// Always verify connection on foreground don't trust cached state.
// reconnectIfNeeded() pings to check if "authenticated" connection is alive.
ProtocolManager.shared.reconnectIfNeeded()
}
}
}

View File

@@ -0,0 +1,189 @@
import UIKit
// MARK: - BlurHash Encoder
/// Pure Swift BlurHash encoder matching the reference implementation at https://blurha.sh/
/// Desktop parity: uses `encode(imageData.data, width, height, 4, 4)` from the `blurhash` npm package.
///
/// BlurHash encodes a compact representation of a placeholder for an image (typically 20-30 chars).
/// Used in attachment `preview` field: `"tag::blurhash"`.
enum BlurHashEncoder {
/// Encodes a UIImage into a BlurHash string.
///
/// - Parameters:
/// - image: Source image (will be downscaled internally for performance).
/// - numberOfComponents: AC components (x, y). Desktop uses (4, 4).
/// - Returns: BlurHash string, or `nil` if encoding fails.
static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 4)) -> String? {
let (componentX, componentY) = components
guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil }
// Downscale for performance (BlurHash doesn't need high resolution)
let pixelWidth = 32
let pixelHeight = 32
guard let cgImage = image.cgImage else { return nil }
let width = pixelWidth
let height = pixelHeight
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
// Calculate DC and AC components
var factors = [(Float, Float, Float)]()
for j in 0..<componentY {
for i in 0..<componentX {
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height,
bytesPerRow: bytesPerRow,
componentX: i, componentY: j)
factors.append(factor)
}
}
// Encode DC value
let dc = factors.first!
let dcValue = encodeDC(dc)
// Quantise maximum AC component value
var maximumValue: Float = 0
if factors.count > 1 {
let actualMaximum = factors.dropFirst().reduce(Float(0)) { current, factor in
max(current, abs(factor.0), abs(factor.1), abs(factor.2))
}
let quantisedMaximum = max(0, min(82, Int(floor(actualMaximum * 166 - 0.5))))
maximumValue = Float(quantisedMaximum + 1) / 166
// Size flag encodes number of components + max AC
let sizeFlag = (componentX - 1) + (componentY - 1) * 9
var result = ""
result += encode83(value: sizeFlag, length: 1)
result += encode83(value: quantisedMaximum, length: 1)
result += encode83(value: dcValue, length: 4)
for factor in factors.dropFirst() {
result += encode83(value: encodeAC(factor, maximumValue: maximumValue), length: 2)
}
return result
} else {
let sizeFlag = (componentX - 1) + (componentY - 1) * 9
var result = ""
result += encode83(value: sizeFlag, length: 1)
result += encode83(value: 0, length: 1)
result += encode83(value: dcValue, length: 4)
return result
}
}
// MARK: - Basis Function
private static func multiplyBasisFunction(
pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int,
componentX: Int, componentY: Int
) -> (Float, Float, Float) {
var r: Float = 0
var g: Float = 0
var b: Float = 0
let normalization: Float = (componentX == 0 && componentY == 0) ? 1 : 2
for y in 0..<height {
for x in 0..<width {
let basis = normalization
* cos((Float.pi * Float(componentX) * Float(x)) / Float(width))
* cos((Float.pi * Float(componentY) * Float(y)) / Float(height))
let offset = y * bytesPerRow + x * 4
r += basis * sRGBToLinear(pixels[offset])
g += basis * sRGBToLinear(pixels[offset + 1])
b += basis * sRGBToLinear(pixels[offset + 2])
}
}
let scale = 1.0 / Float(width * height)
return (r * scale, g * scale, b * scale)
}
// MARK: - sRGB <-> Linear
private static func sRGBToLinear(_ value: UInt8) -> Float {
let v = Float(value) / 255
if v <= 0.04045 {
return v / 12.92
} else {
return pow((v + 0.055) / 1.055, 2.4)
}
}
private static func linearToSRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 {
return Int(v * 12.92 * 255 + 0.5)
} else {
return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
}
}
// MARK: - DC / AC Encoding
private static func encodeDC(_ value: (Float, Float, Float)) -> Int {
let r = linearToSRGB(value.0)
let g = linearToSRGB(value.1)
let b = linearToSRGB(value.2)
return (r << 16) + (g << 8) + b
}
private static func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
let r = max(0, min(18, Int(floor(signPow(value.0 / maximumValue) * 9 + 9.5))))
let g = max(0, min(18, Int(floor(signPow(value.1 / maximumValue) * 9 + 9.5))))
let b = max(0, min(18, Int(floor(signPow(value.2 / maximumValue) * 9 + 9.5))))
return r * 19 * 19 + g * 19 + b
}
private static func signPow(_ value: Float) -> Float {
return copysign(pow(abs(value), 0.5), value)
}
// MARK: - Base83 Encoding
private static let base83Characters: [Character] = Array(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
)
private static func encode83(value: Int, length: Int) -> String {
var result = ""
for i in 1...length {
let digit = (value / pow83(length - i)) % 83
result.append(base83Characters[digit])
}
return result
}
private static func pow83(_ exponent: Int) -> Int {
var result = 1
for _ in 0..<exponent {
result *= 83
}
return result
}
}
// MARK: - UIImage Extension
extension UIImage {
/// Generates a BlurHash string from this image.
/// Desktop parity: `encode(imageData.data, width, height, 4, 4)`.
func blurHash(numberOfComponents components: (Int, Int) = (4, 4)) -> String? {
return BlurHashEncoder.encode(image: self, numberOfComponents: components)
}
}

View File

@@ -0,0 +1,70 @@
import Foundation
/// Cross-platform emoji shortcode parser.
///
/// Desktop and Android use `:emoji_CODE:` format where CODE is the hex-encoded
/// unified Unicode codepoint (e.g., `:emoji_1f631:` 😱).
/// Multi-codepoint emoji use `-` separator (e.g., `:emoji_1f468-200d-1f4bb:` 👨💻).
///
/// Desktop reference: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
/// Android reference: `AppleEmojiPicker.kt` `unifiedToEmoji()`
enum EmojiParser {
/// Regex matching `:emoji_CODE:` shortcodes.
/// Matches hex codes with optional `-` separators for multi-codepoint sequences.
private static let shortcodePattern = try! NSRegularExpression(
pattern: ":emoji_([a-fA-F0-9]+(?:-[a-fA-F0-9]+)*):",
options: []
)
/// Replaces all `:emoji_CODE:` shortcodes in the text with native Unicode emoji.
/// Returns the original text unchanged if no shortcodes are found.
static func replaceShortcodes(in text: String) -> String {
let range = NSRange(text.startIndex..., in: text)
let matches = shortcodePattern.matches(in: text, options: [], range: range)
guard !matches.isEmpty else { return text }
var result = text
// Process matches in reverse order so ranges remain valid after replacements.
for match in matches.reversed() {
guard let fullRange = Range(match.range, in: result),
let codeRange = Range(match.range(at: 1), in: result) else { continue }
let unified = String(result[codeRange])
let emoji = unifiedToEmoji(unified)
if !emoji.isEmpty {
result.replaceSubrange(fullRange, with: emoji)
}
}
return result
}
/// Converts a unified hex code to a native emoji string.
/// Supports multi-codepoint sequences separated by `-`.
///
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`.
/// Examples:
/// - `"1f631"` "😱"
/// - `"1f468-200d-1f4bb"` "👨💻"
/// - `"1f1fa-1f1f8"` "🇺🇸"
static func unifiedToEmoji(_ unified: String) -> String {
unified
.split(separator: "-")
.compactMap { part -> String? in
guard let codePoint = UInt32(part, radix: 16),
let scalar = Unicode.Scalar(codePoint) else { return nil }
return String(scalar)
}
.joined()
}
/// Converts a native emoji character to its `:emoji_CODE:` shortcode.
/// Used when inserting emoji from a picker into the message text.
static func emojiToShortcode(_ emoji: String) -> String {
let codes = emoji.unicodeScalars.map { String(format: "%x", $0.value) }
return ":emoji_\(codes.joined(separator: "-")):"
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
/// Desktop parity: version constants and release notes.
/// Desktop equivalent: `app/version.ts` `APP_VERSION`, `RELEASE_NOTICE`.
enum ReleaseNotes {
/// Current release notes entries, newest first.
/// Each entry contains a version string and a list of changes.
static let entries: [Entry] = [
Entry(
version: appVersion,
changes: [
"Performance optimizations for chat list rendering",
"Improved empty chat placeholder centering",
"Added release notes in Updates settings",
"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",
"End-to-end encrypted messaging with XChaCha20-Poly1305"
]
)
]
/// Current app version from bundle (matches desktop `APP_VERSION`).
static var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
}
/// Current build number from bundle.
static var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
/// Formatted release notice text for the current version (desktop parity: `RELEASE_NOTICE`).
/// Sent as a system message from the "Rosetta Updates" account.
static var releaseNoticeText: String {
guard let latest = entries.first else { return "" }
var lines = ["**Update v\(latest.version)**"]
for change in latest.changes {
lines.append("- \(change)")
}
return lines.joined(separator: "\n")
}
// MARK: - Entry
struct Entry: Identifiable {
let version: String
let changes: [String]
var id: String { version }
}
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
// MARK: - Single Checkmark (Delivered)
/// Single checkmark shape for delivery status.
/// Exact SVG path from design renders at any size as a vector.
/// Original viewBox: 0 0 19 14, fill-rule: evenodd.
struct SingleCheckmarkShape: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 19.0
let sy = rect.height / 14.0
var path = Path()
path.move(to: CGPoint(x: 17.9693 * sx, y: 1.30564 * sy))
path.addCurve(
to: CGPoint(x: 17.9283 * sx, y: 0.20733 * sy),
control1: CGPoint(x: 18.261 * sx, y: 0.991184 * sy),
control2: CGPoint(x: 18.2427 * sx, y: 0.498997 * sy)
)
path.addCurve(
to: CGPoint(x: 16.83 * sx, y: 0.248345 * sy),
control1: CGPoint(x: 17.6138 * sx, y: -0.0843367 * sy),
control2: CGPoint(x: 17.1217 * sx, y: -0.0661078 * sy)
)
path.addLine(to: CGPoint(x: 6.04743 * sx, y: 11.8603 * sy))
path.addLine(to: CGPoint(x: 1.36254 * sx, y: 6.39613 * sy))
path.addCurve(
to: CGPoint(x: 0.268786 * sx, y: 6.3141 * sy),
control1: CGPoint(x: 1.08454 * sx, y: 6.07256 * sy),
control2: CGPoint(x: 0.596911 * sx, y: 6.03611 * sy)
)
path.addCurve(
to: CGPoint(x: 0.186756 * sx, y: 7.40785 * sy),
control1: CGPoint(x: -0.0547816 * sx, y: 6.5921 * sy),
control2: CGPoint(x: -0.0912399 * sx, y: 7.07973 * sy)
)
path.addLine(to: CGPoint(x: 5.43676 * sx, y: 13.5329 * sy))
path.addCurve(
to: CGPoint(x: 6.01097 * sx, y: 13.8017 * sy),
control1: CGPoint(x: 5.57803 * sx, y: 13.7015 * sy),
control2: CGPoint(x: 5.78767 * sx, y: 13.7972 * sy)
)
path.addCurve(
to: CGPoint(x: 6.59431 * sx, y: 13.5556 * sy),
control1: CGPoint(x: 6.22972 * sx, y: 13.8063 * sy),
control2: CGPoint(x: 6.44392 * sx, y: 13.7151 * sy)
)
path.addLine(to: CGPoint(x: 17.9693 * sx, y: 1.30564 * sy))
path.closeSubpath()
return path
}
}
// MARK: - Double Checkmarks (Read)
/// Double checkmark shape for read status.
/// Exact SVG path from design two overlapping checkmarks.
/// Original viewBox: 0 0 22 12, fill-rule: evenodd.
struct DoubleCheckmarkShape: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 22.0
let sy = rect.height / 12.0
var path = Path()
// Left checkmark
path.move(to: CGPoint(x: 16.0745 * sx, y: 1.13501 * sy))
path.addCurve(
to: CGPoint(x: 16.0378 * sx, y: 0.180235 * sy),
control1: CGPoint(x: 16.3354 * sx, y: 0.86165 * sy),
control2: CGPoint(x: 16.3191 * sx, y: 0.433785 * sy)
)
path.addCurve(
to: CGPoint(x: 15.0553 * sx, y: 0.21589 * sy),
control1: CGPoint(x: 15.7565 * sx, y: -0.0733151 * sy),
control2: CGPoint(x: 15.3162 * sx, y: -0.0574685 * sy)
)
path.addLine(to: CGPoint(x: 5.40975 * sx, y: 10.3103 * sy))
path.addLine(to: CGPoint(x: 1.21886 * sx, y: 5.56025 * sy))
path.addCurve(
to: CGPoint(x: 0.240444 * sx, y: 5.48894 * sy),
control1: CGPoint(x: 0.97018 * sx, y: 5.27897 * sy),
control2: CGPoint(x: 0.533969 * sx, y: 5.24727 * sy)
)
path.addCurve(
to: CGPoint(x: 0.167063 * sx, y: 6.43975 * sy),
control1: CGPoint(x: -0.049005 * sx, y: 5.7306 * sy),
control2: CGPoint(x: -0.0816189 * sx, y: 6.15451 * sy)
)
path.addLine(to: CGPoint(x: 4.86346 * sx, y: 11.7643 * sy))
path.addCurve(
to: CGPoint(x: 5.37713 * sx, y: 11.998 * sy),
control1: CGPoint(x: 4.98984 * sx, y: 11.9109 * sy),
control2: CGPoint(x: 5.17737 * sx, y: 11.9941 * sy)
)
path.addCurve(
to: CGPoint(x: 5.89895 * sx, y: 11.7841 * sy),
control1: CGPoint(x: 5.57281 * sx, y: 12.002 * sy),
control2: CGPoint(x: 5.76442 * sx, y: 11.9228 * sy)
)
path.addLine(to: CGPoint(x: 16.0745 * sx, y: 1.13501 * sy))
path.closeSubpath()
// Right checkmark
path.move(to: CGPoint(x: 21.8145 * sx, y: 1.13501 * sy))
path.addCurve(
to: CGPoint(x: 21.7778 * sx, y: 0.180235 * sy),
control1: CGPoint(x: 22.0754 * sx, y: 0.86165 * sy),
control2: CGPoint(x: 22.0591 * sx, y: 0.433785 * sy)
)
path.addCurve(
to: CGPoint(x: 20.7953 * sx, y: 0.21589 * sy),
control1: CGPoint(x: 21.4965 * sx, y: -0.0733151 * sy),
control2: CGPoint(x: 21.0563 * sx, y: -0.0574685 * sy)
)
path.addLine(to: CGPoint(x: 10.6198 * sx, y: 10.865 * sy))
path.addCurve(
to: CGPoint(x: 10.6565 * sx, y: 11.8198 * sy),
control1: CGPoint(x: 10.3589 * sx, y: 11.1383 * sy),
control2: CGPoint(x: 10.3752 * sx, y: 11.5662 * sy)
)
path.addCurve(
to: CGPoint(x: 11.639 * sx, y: 11.7841 * sy),
control1: CGPoint(x: 10.9378 * sx, y: 12.0733 * sy),
control2: CGPoint(x: 11.3781 * sx, y: 12.0575 * sy)
)
path.addLine(to: CGPoint(x: 21.8145 * sx, y: 1.13501 * sy))
path.closeSubpath()
return path
}
}

View File

@@ -2,13 +2,12 @@ import SwiftUI
// MARK: - VerifiedBadge
/// Displays a verified rosette badge for accounts with server-assigned verification.
/// Displays a verified badge for accounts with server-assigned verification.
///
/// Verification levels (from server `verified` field):
/// - `0` not verified (badge hidden)
/// - `1` public figure, brand, or organization
/// - `2` official Rosetta administration account
/// - `3+` group administrator
/// Uses Tabler Icons SVG paths for desktop parity:
/// - Level 1: rosette with checkmark (blue) `IconRosetteDiscountCheckFilled`
/// - Level 2: shield with checkmark (green) `IconShieldCheckFilled`
/// - Level 3+: shield with checkmark (blue) group admin
///
/// Tapping the badge presents a dialog explaining the verification level.
struct VerifiedBadge: View {
@@ -21,9 +20,9 @@ struct VerifiedBadge: View {
var body: some View {
if verified > 0 {
Image(systemName: iconName)
.font(.system(size: size))
.foregroundStyle(resolvedColor)
SVGPathShape(pathData: iconPath, viewBox: CGSize(width: 24, height: 24))
.fill(resolvedColor)
.frame(width: size, height: size)
.onTapGesture { showExplanation = true }
.accessibilityLabel("Verified account")
.alert("Verified Account", isPresented: $showExplanation) {
@@ -38,15 +37,15 @@ struct VerifiedBadge: View {
/// Desktop parity: different icon per verification level.
/// Level 1 = rosette (public figure), Level 2 = shield (Rosetta admin),
/// Level 3+ = arrow badge (group admin).
private var iconName: String {
/// Level 3+ = shield (group admin).
private var iconPath: String {
switch verified {
case 2:
return "checkmark.shield.fill"
return TablerIconPath.shieldCheckFilled
case 3...:
return "arrow.down.app.fill"
return TablerIconPath.shieldCheckFilled
default:
return "checkmark.seal.fill"
return TablerIconPath.rosetteDiscountCheckFilled
}
}
@@ -54,13 +53,11 @@ struct VerifiedBadge: View {
private var resolvedColor: Color {
if let badgeTint { return badgeTint }
if verified == 2 {
return colorScheme == .dark
? RosettaColors.success // green
: RosettaColors.success
return RosettaColors.success // green
}
return colorScheme == .dark
? RosettaColors.primaryBlue // #248AE6
: Color(hex: 0xACD2F9) // soft blue (light theme)
? RosettaColors.primaryBlue // #248AE6
: Color(hex: 0xACD2F9) // soft blue (light theme)
}
private var annotationText: String {
@@ -75,22 +72,40 @@ struct VerifiedBadge: View {
}
}
// MARK: - Tabler Icon Paths
/// SVG path data from Tabler Icons exact match with desktop's verified badge icons.
/// Desktop reference: `@tabler/icons-react` `IconRosetteDiscountCheckFilled`, `IconShieldCheckFilled`.
/// ViewBox: 24×24 for both icons.
private enum TablerIconPath {
/// Rosette with checkmark verification level 1 (public figure/brand/organization).
/// Desktop: `IconRosetteDiscountCheckFilled` from `@tabler/icons-react`.
static let rosetteDiscountCheckFilled = "M12.01 2.011a3.2 3.2 0 0 1 2.113 .797l.154 .145l.698 .698a1.2 1.2 0 0 0 .71 .341l.135 .008h1a3.2 3.2 0 0 1 3.195 3.018l.005 .182v1c0 .27 .092 .533 .258 .743l.09 .1l.697 .698a3.2 3.2 0 0 1 .147 4.382l-.145 .154l-.698 .698a1.2 1.2 0 0 0 -.341 .71l-.008 .135v1a3.2 3.2 0 0 1 -3.018 3.195l-.182 .005h-1a1.2 1.2 0 0 0 -.743 .258l-.1 .09l-.698 .697a3.2 3.2 0 0 1 -4.382 .147l-.154 -.145l-.698 -.698a1.2 1.2 0 0 0 -.71 -.341l-.135 -.008h-1a3.2 3.2 0 0 1 -3.195 -3.018l-.005 -.182v-1a1.2 1.2 0 0 0 -.258 -.743l-.09 -.1l-.697 -.698a3.2 3.2 0 0 1 -.147 -4.382l.145 -.154l.698 -.698a1.2 1.2 0 0 0 .341 -.71l.008 -.135v-1l.005 -.182a3.2 3.2 0 0 1 3.013 -3.013l.182 -.005h1a1.2 1.2 0 0 0 .743 -.258l.1 -.09l.698 -.697a3.2 3.2 0 0 1 2.269 -.944zm3.697 7.282a1 1 0 0 0 -1.414 0l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.32 1.497l2 2l.094 .083a1 1 0 0 0 1.32 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
/// Shield with checkmark verification level 2 (Rosetta administration).
/// Desktop: `IconShieldCheckFilled` from `@tabler/icons-react`.
static let shieldCheckFilled = "M11.998 2l.118 .007l.059 .008l.061 .013l.111 .034a.993 .993 0 0 1 .217 .112l.104 .082l.255 .218a11 11 0 0 0 7.189 2.537l.342 -.01a1 1 0 0 1 1.005 .717a13 13 0 0 1 -9.208 16.25a1 1 0 0 1 -.502 0a13 13 0 0 1 -9.209 -16.25a1 1 0 0 1 1.005 -.717a11 11 0 0 0 7.531 -2.527l.263 -.225l.096 -.075a.993 .993 0 0 1 .217 -.112l.112 -.034a.97 .97 0 0 1 .119 -.021l.115 -.007zm3.71 7.293a1 1 0 0 0 -1.415 0l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.32 1.497l2 2l.094 .083a1 1 0 0 0 1.32 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
}
// MARK: - Preview
#Preview {
VStack(spacing: 16) {
HStack {
Text("Level 1")
VerifiedBadge(verified: 1, size: 16)
Text("Level 1 (rosette)")
VerifiedBadge(verified: 1, size: 20)
}
HStack {
Text("Level 2")
Text("Level 2 (shield)")
VerifiedBadge(verified: 2, size: 20)
}
HStack {
Text("Not verified")
VerifiedBadge(verified: 0, size: 16)
VerifiedBadge(verified: 0, size: 20)
}
}
.padding()
.background(Color.black)
.foregroundStyle(.white)
}

View File

@@ -0,0 +1,376 @@
import SwiftUI
// MARK: - SVGPathShape
/// A SwiftUI Shape that renders an SVG path string within the given rect.
/// Scales the path from the original viewBox to fit the rect.
struct SVGPathShape: Shape {
let pathData: String
let viewBox: CGSize
func path(in rect: CGRect) -> Path {
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
var parser = SVGPathParser(pathData: pathData)
var output = Path(parser.parse())
output = output.applying(.init(
scaleX: rect.width / viewBox.width,
y: rect.height / viewBox.height
))
return output
}
}
// MARK: - SVGPathToken
enum SVGPathToken {
case command(Character)
case number(CGFloat)
}
// MARK: - SVGPathTokenizer
/// Converts an SVG path data string into a sequence of command and number tokens.
/// Handles decimal points, negative signs, scientific notation, and implicit separators
/// (consecutive decimals like `.5.3` are split into `0.5` and `0.3`).
struct SVGPathTokenizer {
static func tokenize(_ source: String) -> [SVGPathToken] {
var tokens: [SVGPathToken] = []
let chars = Array(source)
var index = 0
while index < chars.count {
let ch = chars[index]
if ch.isWhitespace || ch == "," { index += 1; continue }
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
let start = index
var hasDot = (ch == ".")
index += 1
while index < chars.count {
let c = chars[index]
let prev = chars[index - 1]
if c.isNumber { index += 1; continue }
// Second decimal point starts a new number (e.g., `.5.3` `0.5`, `0.3`)
if c == "." {
if hasDot { break }
hasDot = true
index += 1
continue
}
if c == "e" || c == "E" { index += 1; continue }
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
break
}
let fragment = String(chars[start..<index])
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
continue
}
index += 1
}
return tokens
}
}
// MARK: - SVGPathParser
/// Parses SVG path data tokens into a CGPath.
///
/// Supports commands: M, L, H, V, C, A, Z (and lowercase relative variants).
/// Arc (A/a) is converted to cubic Bézier curves using the W3C SVG spec
/// endpoint-to-center parameterization algorithm.
struct SVGPathParser {
private let tokens: [SVGPathToken]
private var index: Int = 0
private var lastCommand: Character = "M"
private var current = CGPoint.zero
private var subpathStart = CGPoint.zero
private var cgPath = CGMutablePath()
init(pathData: String) {
self.tokens = SVGPathTokenizer.tokenize(pathData)
}
mutating func parse() -> CGPath {
while index < tokens.count {
let command = readCommandOrReuse()
switch command {
case "M", "m": parseMove(command)
case "L", "l": parseLine(command)
case "H", "h": parseHorizontal(command)
case "V", "v": parseVertical(command)
case "C", "c": parseCubic(command)
case "A", "a": parseArc(command)
case "Z", "z":
cgPath.closeSubpath()
current = subpathStart
default:
skipToNextCommand()
}
}
return cgPath.copy() ?? CGMutablePath()
}
// MARK: - Token Reading
private var isAtCommand: Bool {
guard index < tokens.count else { return false }
if case .command = tokens[index] { return true }
return false
}
private mutating func readCommandOrReuse() -> Character {
guard index < tokens.count else { return lastCommand }
if case let .command(command) = tokens[index] {
index += 1
lastCommand = command
return command
}
return lastCommand
}
private mutating func readNumber() -> CGFloat? {
guard index < tokens.count else { return nil }
if case let .number(value) = tokens[index] {
index += 1
return value
}
return nil
}
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
}
private mutating func readPoint(relative: Bool) -> CGPoint? {
guard let x = readNumber(), let y = readNumber() else { return nil }
return resolvedPoint(x: x, y: y, relative: relative)
}
// MARK: - Command Parsers
private mutating func parseMove(_ command: Character) {
let relative = command.isLowercase
guard let first = readPoint(relative: relative) else { return }
cgPath.move(to: first)
current = first
subpathStart = first
// Subsequent coordinate pairs after M/m are treated as L/l
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
lastCommand = relative ? "l" : "L"
}
private mutating func parseLine(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
}
private mutating func parseHorizontal(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
cgPath.addLine(to: current)
}
}
private mutating func parseVertical(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
cgPath.addLine(to: current)
}
}
private mutating func parseCubic(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand {
guard let x1 = readNumber(),
let y1 = readNumber(),
let x2 = readNumber(),
let y2 = readNumber(),
let x = readNumber(),
let y = readNumber()
else { return }
let c1 = resolvedPoint(x: x1, y: y1, relative: relative)
let c2 = resolvedPoint(x: x2, y: y2, relative: relative)
let end = resolvedPoint(x: x, y: y, relative: relative)
cgPath.addCurve(to: end, control1: c1, control2: c2)
current = end
}
}
// MARK: - Arc
/// Parses SVG elliptical arc commands (A/a).
/// Parameters: rx ry x-rotation large-arc-flag sweep-flag x y
private mutating func parseArc(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand {
guard let rxRaw = readNumber(),
let ryRaw = readNumber(),
let xRotationDeg = readNumber(),
let largeArcFlag = readNumber(),
let sweepFlag = readNumber(),
let x = readNumber(),
let y = readNumber()
else { return }
let end = resolvedPoint(x: x, y: y, relative: relative)
arcToBezier(
from: current,
to: end,
rx: abs(rxRaw),
ry: abs(ryRaw),
xRotation: xRotationDeg * .pi / 180,
largeArc: largeArcFlag != 0,
sweep: sweepFlag != 0
)
current = end
}
}
/// Converts an SVG elliptical arc to cubic Bézier curves.
///
/// Uses the endpoint-to-center parameterization from the W3C SVG spec:
/// https://www.w3.org/TR/SVG11/implnote.html#ArcConversionEndpointToCenter
///
/// Splits arcs into 90° segments and approximates each with a cubic Bézier.
private mutating func arcToBezier(
from p1: CGPoint,
to p2: CGPoint,
rx rxIn: CGFloat,
ry ryIn: CGFloat,
xRotation phi: CGFloat,
largeArc: Bool,
sweep: Bool
) {
// Degenerate: identical endpoints
if p1.x == p2.x && p1.y == p2.y { return }
// Degenerate: zero radius straight line
if rxIn == 0 || ryIn == 0 {
cgPath.addLine(to: p2)
return
}
var rx = rxIn
var ry = ryIn
let cosPhi = cos(phi)
let sinPhi = sin(phi)
// Step 1: Compute (x1, y1)
let dx = (p1.x - p2.x) / 2
let dy = (p1.y - p2.y) / 2
let x1p = cosPhi * dx + sinPhi * dy
let y1p = -sinPhi * dx + cosPhi * dy
// Ensure radii are large enough
let x1p2 = x1p * x1p
let y1p2 = y1p * y1p
var rx2 = rx * rx
var ry2 = ry * ry
let lambda = x1p2 / rx2 + y1p2 / ry2
if lambda > 1 {
let s = sqrt(lambda)
rx *= s; ry *= s
rx2 = rx * rx; ry2 = ry * ry
}
// Step 2: Compute (cx, cy)
let num = max(0, rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2)
let den = rx2 * y1p2 + ry2 * x1p2
var sq: CGFloat = den > 0 ? sqrt(num / den) : 0
if largeArc == sweep { sq = -sq }
let cxp = sq * rx * y1p / ry
let cyp = -sq * ry * x1p / rx
// Step 3: Compute center (cx, cy) from (cx, cy)
let cx = cosPhi * cxp - sinPhi * cyp + (p1.x + p2.x) / 2
let cy = sinPhi * cxp + cosPhi * cyp + (p1.y + p2.y) / 2
// Step 4: Compute θ and Δθ
let theta1 = vectorAngle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
var dtheta = vectorAngle(
(x1p - cxp) / rx, (y1p - cyp) / ry,
(-x1p - cxp) / rx, (-y1p - cyp) / ry
)
if !sweep && dtheta > 0 { dtheta -= 2 * .pi }
if sweep && dtheta < 0 { dtheta += 2 * .pi }
// Split into 90° segments and approximate each with cubic Bézier
let segments = max(1, Int(ceil(abs(dtheta) / (.pi / 2))))
let segAngle = dtheta / CGFloat(segments)
for i in 0..<segments {
let a1 = theta1 + CGFloat(i) * segAngle
let a2 = a1 + segAngle
let alpha = sin(segAngle)
* (sqrt(4 + 3 * pow(tan(segAngle / 2), 2)) - 1) / 3
let cosA1 = cos(a1), sinA1 = sin(a1)
let cosA2 = cos(a2), sinA2 = sin(a2)
// Tangent vectors at start/end of segment
let dx1 = -rx * sinA1, dy1 = ry * cosA1
let dx2 = -rx * sinA2, dy2 = ry * cosA2
// Endpoint of this segment
let ep2 = CGPoint(
x: cosPhi * rx * cosA2 - sinPhi * ry * sinA2 + cx,
y: sinPhi * rx * cosA2 + cosPhi * ry * sinA2 + cy
)
// Control points
let cp1 = CGPoint(
x: cosPhi * rx * cosA1 - sinPhi * ry * sinA1 + cx
+ alpha * (cosPhi * dx1 - sinPhi * dy1),
y: sinPhi * rx * cosA1 + cosPhi * ry * sinA1 + cy
+ alpha * (sinPhi * dx1 + cosPhi * dy1)
)
let cp2 = CGPoint(
x: ep2.x - alpha * (cosPhi * dx2 - sinPhi * dy2),
y: ep2.y - alpha * (sinPhi * dx2 + cosPhi * dy2)
)
cgPath.addCurve(to: ep2, control1: cp1, control2: cp2)
}
}
/// Signed angle between two 2D vectors.
private func vectorAngle(
_ ux: CGFloat, _ uy: CGFloat,
_ vx: CGFloat, _ vy: CGFloat
) -> CGFloat {
let dot = ux * vx + uy * vy
let len = sqrt(ux * ux + uy * uy) * sqrt(vx * vx + vy * vy)
guard len > 0 else { return 0 }
var a = acos(max(-1, min(1, dot / len)))
if ux * vy - uy * vx < 0 { a = -a }
return a
}
private mutating func skipToNextCommand() {
while index < tokens.count {
if case .command = tokens[index] { return }
index += 1
}
}
}

View File

@@ -298,6 +298,7 @@ private struct SecureToggleField: UIViewRepresentable {
tf.spellCheckingType = .no
tf.textContentType = .init(rawValue: "")
tf.inputAccessoryView = UIView(frame: .zero)
tf.returnKeyType = .done
tf.backgroundColor = .clear
tf.setContentHuggingPriority(.required, for: .vertical)
tf.setContentCompressionResistancePriority(.required, for: .vertical)
@@ -390,6 +391,11 @@ private struct SecureToggleField: UIViewRepresentable {
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.focusedField = parent.field
}

View File

@@ -0,0 +1,716 @@
import SwiftUI
import Photos
import PhotosUI
// MARK: - AttachmentPanelView
/// Figma-style bottom sheet for selecting photos and files to attach to a message.
///
/// Figma nodes: 3994:39103 (panel), 4758:50706 (tab bar).
///
/// Structure:
/// - Toolbar: X button (left) + "Recents" title (centered)
/// - Content: Photo grid (Gallery) or File picker (File)
/// - Tab bar: [Gallery] [File] [Avatar] glass capsule background
/// - Send button: appears when items selected, shows count
///
/// Desktop parity: `DialogInput.tsx` attachment menu (paperclip) + file dialog.
struct AttachmentPanelView: View {
let onSend: ([PendingAttachment], String) -> Void
let onSendAvatar: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedTab: AttachmentTab = .gallery
@State private var selectedAssets: [PHAsset] = []
@State private var showCamera = false
@State private var showFilePicker = false
@State private var capturedImage: UIImage?
@State private var captionText: String = ""
@State private var previewAsset: IdentifiableAsset?
private var hasSelection: Bool { !selectedAssets.isEmpty }
var body: some View {
ZStack(alignment: .bottom) {
// Dark surface background (#1C1C1E NOT pure black, so sheet rounded
// corners are visible against the app's black background behind)
Color(hex: 0x1C1C1E).ignoresSafeArea()
VStack(spacing: 0) {
// Grabber + Toolbar
toolbar
// Content
switch selectedTab {
case .gallery:
PhotoGridView(
selectedAssets: $selectedAssets,
maxSelection: PendingAttachment.maxAttachmentsPerMessage,
onCameraTap: { showCamera = true },
onPhotoPreview: { asset in
previewAsset = IdentifiableAsset(asset: asset)
}
)
case .file:
fileTabContent
case .avatar:
// Avatar is an action tab handled in tabButton tap
Spacer()
}
Spacer(minLength: 0)
}
// Bottom: Tab bar + Send button
bottomBar
}
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
capturedImage = image
handleCapturedImage(image)
}
.ignoresSafeArea()
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { urls in
handlePickedFiles(urls)
}
}
.fullScreenCover(item: $previewAsset) { item in
PhotoPreviewView(
asset: item.asset,
isSelected: selectedAssets.contains(where: { $0.localIdentifier == item.id }),
selectionNumber: selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }).map { $0 + 1 },
captionText: $captionText,
onSend: { image in
let caption = captionText
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], caption)
dismiss()
},
onToggleSelect: {
if let idx = selectedAssets.firstIndex(where: { $0.localIdentifier == item.id }) {
selectedAssets.remove(at: idx)
} else if selectedAssets.count < PendingAttachment.maxAttachmentsPerMessage {
selectedAssets.append(item.asset)
}
}
)
.background(TransparentFullScreenBackground())
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
.preferredColorScheme(.dark)
}
// MARK: - Toolbar (Telegram-style: dark surface header)
/// Dark surface header matching Telegram attachment panel.
/// Custom grabber + 44pt close button (Figma: node 4764-50752) + centered title.
private var toolbar: some View {
VStack(spacing: 0) {
// Custom drag indicator (replaces system .presentationDragIndicator)
Capsule()
.fill(Color.white.opacity(0.3))
.frame(width: 36, height: 5)
.padding(.top, 5)
.padding(.bottom, 14)
// Close button + centered title
HStack {
// Close button (same glass style as GlassBackButton, but with X icon)
Button {
dismiss()
} label: {
CloseIconShape()
.fill(.white)
.frame(width: 14, height: 14)
.frame(width: 44, height: 44)
}
.background { closeButtonGlass }
.clipShape(Circle())
Spacer()
// Title with dropdown chevron (Telegram-style, ~20pt semibold)
HStack(spacing: 5) {
Text(tabTitle)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45))
}
Spacer()
// Right side: selection badge (deselects all) or invisible spacer
if hasSelection {
Button {
withAnimation(.easeInOut(duration: 0.25)) {
selectedAssets.removeAll()
}
} label: {
selectionBadge
}
.transition(.scale.combined(with: .opacity))
} else {
// Invisible spacer to balance close button width (keeps title centered)
Color.clear
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 10)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: hasSelection)
}
}
/// Selection count badge blue capsule with + count. Tapping deselects all.
private var selectionBadge: some View {
HStack(spacing: 2) {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("\(selectedAssets.count)")
.font(.system(size: 12, weight: .bold))
}
.foregroundStyle(.white)
.frame(width: 44, height: 28)
.background(Color(hex: 0x008BFF), in: Capsule())
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@ViewBuilder
private var closeButtonGlass: some View {
if #available(iOS 26, *) {
Circle()
.fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle)
} else {
Circle()
.fill(.thinMaterial)
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
/// Title text changes based on selected tab.
private var tabTitle: String {
switch selectedTab {
case .gallery: return "Recents"
case .file: return "File"
case .avatar: return "Avatar"
}
}
// MARK: - File Tab Content
private var fileTabContent: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "doc.fill")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Select a file to send")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.5))
Button {
showFilePicker = true
} label: {
Text("Browse Files")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
}
Spacer()
}
.frame(maxWidth: .infinity)
.task {
// Auto-open file picker when File tab is selected
try? await Task.sleep(for: .milliseconds(300))
showFilePicker = true
}
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 0) {
if hasSelection {
// Caption input bar (replaces tab bar when photos selected)
captionInputBar
.padding(.horizontal, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
} else {
// Tab bar (Figma: node 4758:50706 glass capsule)
tabBar
.padding(.horizontal, 25)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: hasSelection)
.background(
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(edges: .bottom)
)
}
// MARK: - Caption Input Bar (matches ChatDetail composer style)
/// Caption input bar shown when photos are selected.
/// Matches ChatDetailView's composer capsule: `.thinMaterial` glass rounded rect.
/// Layout: [text field] [emoji] [send button]
private var captionInputBar: some View {
HStack(spacing: 0) {
// Caption text field
TextField("Add a caption...", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 6)
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
.padding(.trailing, 8)
// Send button (exact ChatDetail match: 38×36 capsule with sendPlane icon)
Button {
sendSelectedPhotos()
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.frame(width: 22, height: 19)
.frame(width: 38, height: 36)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background { captionBarBackground }
}
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
/// Glass capsule tab bar matching RosettaTabBar pattern.
/// Tabs: Gallery | File | Avatar.
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
private var tabBar: some View {
HStack(spacing: 0) {
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
tabButton(.file, icon: "doc.fill", label: "File")
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
}
.padding(4)
.background { tabBarBackground }
.clipShape(Capsule())
.tabBarShadow()
}
/// Glass background matching RosettaTabBar (lines 136149).
@ViewBuilder
private var tabBarBackground: some View {
if #available(iOS 26, *) {
// iOS 26+ native liquid glass
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// iOS < 26 frosted glass material (matches RosettaTabBar)
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
/// Icon: 22pt regular (frame height 28), Label: 10pt, VStack spacing: 2, padding: 6pt.
private func tabButton(_ tab: AttachmentTab, icon: String, label: String) -> some View {
let isSelected = selectedTab == tab
return Button {
if tab == .avatar {
// Avatar is an action tab immediately sends avatar + dismisses
onSendAvatar()
dismiss()
} else {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
}
} label: {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 22, weight: .regular))
.frame(height: 28)
Text(label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
}
// RosettaTabBar colors: selected=#008BFF, unselected=white
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
.background(
// Selected tab: thin material pill (matches RosettaTabBar selection style)
isSelected
? AnyShapeStyle(.thinMaterial)
: AnyShapeStyle(.clear),
in: Capsule()
)
}
.buttonStyle(.plain)
}
// MARK: - Actions
private func sendSelectedPhotos() {
let assets = selectedAssets
let caption = captionText
let manager = PHImageManager.default()
Task {
var attachments: [PendingAttachment] = []
for asset in assets {
if let image = await loadFullImage(asset: asset, manager: manager) {
attachments.append(PendingAttachment.fromImage(image))
}
}
await MainActor.run {
onSend(attachments, caption)
dismiss()
}
}
}
private func handleCapturedImage(_ image: UIImage) {
let attachment = PendingAttachment.fromImage(image)
onSend([attachment], "")
dismiss()
}
private func handlePickedFiles(_ urls: [URL]) {
var attachments: [PendingAttachment] = []
for url in urls.prefix(PendingAttachment.maxAttachmentsPerMessage) {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: url) {
attachments.append(PendingAttachment.fromFile(
data: data,
fileName: url.lastPathComponent
))
}
}
if !attachments.isEmpty {
onSend(attachments, "")
dismiss()
}
}
private func loadFullImage(asset: PHAsset, manager: PHImageManager) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
options.isSynchronous = false
manager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: options
) { image, info in
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: image)
}
}
}
}
}
// MARK: - AttachmentTab
private enum AttachmentTab: Hashable {
case gallery
case file
case avatar
}
// MARK: - IdentifiableAsset
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
struct IdentifiableAsset: Identifiable {
let id: String
let asset: PHAsset
init(asset: PHAsset) {
self.id = asset.localIdentifier
self.asset = asset
}
}
// MARK: - Close Icon (Figma SVG: node 4764-50752)
/// Custom X icon matching Figma close button design.
/// SVG source: 20×20 viewport, fill path. Rendered at ~14pt inside 44pt circle.
private struct CloseIconShape: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 19.6289
let sy = rect.height / 19.6289
func pt(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: x * sx, y: y * sy)
}
var p = Path()
p.move(to: pt(17.7734, 0.3174))
p.addCurve(to: pt(18.1396, 0.0732), control1: pt(17.8711, 0.2035), control2: pt(17.9932, 0.1221))
p.addCurve(to: pt(18.5547, 0), control1: pt(18.2699, 0.0244), control2: pt(18.4082, 0))
p.addCurve(to: pt(18.9453, 0.0732), control1: pt(18.6849, 0), control2: pt(18.8151, 0.0244))
p.addCurve(to: pt(19.3115, 0.3174), control1: pt(19.0918, 0.1221), control2: pt(19.2139, 0.2035))
p.addCurve(to: pt(19.5557, 0.6836), control1: pt(19.4255, 0.415), control2: pt(19.5068, 0.5371))
p.addCurve(to: pt(19.6289, 1.0742), control1: pt(19.6045, 0.8138), control2: pt(19.6289, 0.944))
p.addCurve(to: pt(19.5557, 1.4893), control1: pt(19.6289, 1.2207), control2: pt(19.6045, 1.3591))
p.addCurve(to: pt(19.3115, 1.8555), control1: pt(19.5068, 1.6357), control2: pt(19.4255, 1.7578))
p.addLine(to: pt(11.3525, 9.8145))
p.addLine(to: pt(19.3115, 17.7734))
p.addCurve(to: pt(19.5557, 18.1396), control1: pt(19.4255, 17.8711), control2: pt(19.5068, 17.9932))
p.addCurve(to: pt(19.6289, 18.5547), control1: pt(19.6045, 18.2699), control2: pt(19.6289, 18.4082))
p.addCurve(to: pt(19.5557, 18.9453), control1: pt(19.6289, 18.6849), control2: pt(19.6045, 18.8151))
p.addCurve(to: pt(19.3115, 19.3115), control1: pt(19.5068, 19.0918), control2: pt(19.4255, 19.2139))
p.addCurve(to: pt(18.9453, 19.5557), control1: pt(19.2139, 19.4255), control2: pt(19.0918, 19.5068))
p.addCurve(to: pt(18.5547, 19.6289), control1: pt(18.8151, 19.6045), control2: pt(18.6849, 19.6289))
p.addCurve(to: pt(18.1396, 19.5557), control1: pt(18.4082, 19.6289), control2: pt(18.2699, 19.6045))
p.addCurve(to: pt(17.7734, 19.3115), control1: pt(17.9932, 19.5068), control2: pt(17.8711, 19.4255))
p.addLine(to: pt(9.8145, 11.3525))
p.addLine(to: pt(1.8555, 19.3115))
p.addCurve(to: pt(1.4893, 19.5557), control1: pt(1.7578, 19.4255), control2: pt(1.6357, 19.5068))
p.addCurve(to: pt(1.0742, 19.6289), control1: pt(1.3591, 19.6045), control2: pt(1.2207, 19.6289))
p.addCurve(to: pt(0.6836, 19.5557), control1: pt(0.944, 19.6289), control2: pt(0.8138, 19.6045))
p.addCurve(to: pt(0.3174, 19.3115), control1: pt(0.5371, 19.5068), control2: pt(0.415, 19.4255))
p.addCurve(to: pt(0.0732, 18.9453), control1: pt(0.2035, 19.2139), control2: pt(0.1221, 19.0918))
p.addCurve(to: pt(0, 18.5547), control1: pt(0.0244, 18.8151), control2: pt(0, 18.6849))
p.addCurve(to: pt(0.0732, 18.1396), control1: pt(0, 18.4082), control2: pt(0.0244, 18.2699))
p.addCurve(to: pt(0.3174, 17.7734), control1: pt(0.1221, 17.9932), control2: pt(0.2035, 17.8711))
p.addLine(to: pt(8.2764, 9.8145))
p.addLine(to: pt(0.3174, 1.8555))
p.addCurve(to: pt(0.0732, 1.4893), control1: pt(0.2035, 1.7578), control2: pt(0.1221, 1.6357))
p.addCurve(to: pt(0, 1.0742), control1: pt(0.0244, 1.3591), control2: pt(0, 1.2207))
p.addCurve(to: pt(0.0732, 0.6836), control1: pt(0, 0.944), control2: pt(0.0244, 0.8138))
p.addCurve(to: pt(0.3174, 0.3174), control1: pt(0.1221, 0.5371), control2: pt(0.2035, 0.415))
p.addCurve(to: pt(0.6836, 0.0732), control1: pt(0.415, 0.2035), control2: pt(0.5371, 0.1221))
p.addCurve(to: pt(1.0742, 0), control1: pt(0.8138, 0.0244), control2: pt(0.944, 0))
p.addCurve(to: pt(1.4893, 0.0732), control1: pt(1.2207, 0), control2: pt(1.3591, 0.0244))
p.addCurve(to: pt(1.8555, 0.3174), control1: pt(1.6357, 0.1221), control2: pt(1.7578, 0.2035))
p.addLine(to: pt(9.8145, 8.2764))
p.addLine(to: pt(17.7734, 0.3174))
p.closeSubpath()
return p
}
}
// MARK: - Presentation Corner Radius (iOS 16.4+)
/// Sets a custom corner radius on the sheet presentation.
/// `.presentationCornerRadius` is iOS 16.4+, so this modifier guards availability.
private struct AttachmentCornerRadiusModifier: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationCornerRadius(radius)
} else {
content
}
}
}
private extension View {
func attachmentCornerRadius(_ radius: CGFloat) -> some View {
modifier(AttachmentCornerRadiusModifier(radius: radius))
}
}
// MARK: - Tab Bar Shadow Modifier
/// Shadow for iOS < 26 tab bar (matches RosettaTabBar's TabBarShadowModifier).
private struct AttachmentTabBarShadowModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
}
private extension View {
func tabBarShadow() -> some View {
modifier(AttachmentTabBarShadowModifier())
}
}
// MARK: - Preview Background Modifier (iOS 16.4+)
/// Sets a custom background on the sheet presentation.
/// `.presentationBackground` is iOS 16.4+, so this modifier guards availability.
private struct PreviewBackgroundModifier<S: ShapeStyle>: ViewModifier {
let style: S
func body(content: Content) -> some View {
if #available(iOS 16.4, *) {
content.presentationBackground(style)
} else {
content
}
}
}
private extension View {
func previewBackground<S: ShapeStyle>(_ style: S) -> some View {
modifier(PreviewBackgroundModifier(style: style))
}
}
// MARK: - CameraPickerView
/// UIKit camera wrapper for taking photos.
struct CameraPickerView: UIViewControllerRepresentable {
let onCapture: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onCapture: onCapture)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onCapture: (UIImage) -> Void
init(onCapture: @escaping (UIImage) -> Void) {
self.onCapture = onCapture
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
onCapture(image)
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}
// MARK: - DocumentPickerView
/// UIKit document picker wrapper for selecting files.
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: ([URL]) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.allowsMultipleSelection = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: ([URL]) -> Void
init(onPick: @escaping ([URL]) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
onPick(urls)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
// No-op user cancelled
}
}
}
// MARK: - Transparent FullScreen Background
/// UIKit hack to make `.fullScreenCover` background transparent.
/// Walks the view hierarchy to find the presentation container and sets
/// its background to clear, allowing the presenting view to show through.
struct TransparentFullScreenBackground: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
// MARK: - AttachmentPreviewStrip
/// Horizontal strip above the compositor showing selected attachments before send.
///
/// Desktop parity: `DialogInput.tsx` renders `DialogAttachment` components in a
/// flex row above the input field when `attachments.length > 0`.
///
/// Each item shows a thumbnail (images) or file card (files) with a red X remove button.
struct AttachmentPreviewStrip: View {
@Binding var pendingAttachments: [PendingAttachment]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(pendingAttachments) { attachment in
attachmentPreview(attachment)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
}
@ViewBuilder
private func attachmentPreview(_ attachment: PendingAttachment) -> some View {
ZStack(alignment: .topTrailing) {
switch attachment.type {
case .image:
imagePreview(attachment)
case .file:
filePreview(attachment)
default:
EmptyView()
}
// Remove button (Figma: red circle with X)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
pendingAttachments.removeAll { $0.id == attachment.id }
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Color.red, in: Circle())
}
.offset(x: 4, y: -4)
}
}
@ViewBuilder
private func imagePreview(_ attachment: PendingAttachment) -> some View {
if let thumbnail = attachment.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 70, height: 70)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.1))
.frame(width: 70, height: 70)
.overlay {
Image(systemName: "photo")
.foregroundStyle(.white.opacity(0.4))
}
}
}
@ViewBuilder
private func filePreview(_ attachment: PendingAttachment) -> some View {
VStack(spacing: 4) {
Image(systemName: fileIcon(for: attachment.fileName ?? "file"))
.font(.system(size: 22))
.foregroundStyle(Color(hex: 0x008BFF))
Text(attachment.fileName ?? "file")
.font(.system(size: 9))
.foregroundStyle(.white.opacity(0.8))
.lineLimit(2)
.multilineTextAlignment(.center)
Text(formatFileSize(attachment.fileSize ?? 0))
.font(.system(size: 8))
.foregroundStyle(.white.opacity(0.4))
}
.frame(width: 70, height: 70)
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
// MARK: - Helpers
/// Returns an appropriate SF Symbol name for the file extension.
private func fileIcon(for fileName: String) -> String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.fill"
case "zip", "rar", "7z": return "doc.zipper"
case "jpg", "jpeg", "png", "gif", "webp": return "photo.fill"
case "mp4", "mov", "avi": return "film.fill"
case "mp3", "wav", "aac": return "waveform"
case "doc", "docx": return "doc.text.fill"
case "xls", "xlsx": return "tablecells.fill"
case "txt": return "doc.plaintext.fill"
default: return "doc.fill"
}
}
/// Formats byte count to human-readable string.
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 {
return "\(bytes) B"
} else if bytes < 1024 * 1024 {
return String(format: "%.1f KB", Double(bytes) / 1024)
} else {
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
}

View File

@@ -0,0 +1,195 @@
import AVFoundation
import SwiftUI
// MARK: - CameraPreviewView
/// Live camera preview using AVCaptureSession, wrapped for SwiftUI.
///
/// Figma: Camera tile in attachment panel shows a real-time rear camera feed.
/// Uses `.medium` preset for minimal memory/battery usage (it's just a preview).
///
/// Graceful fallback: on Simulator or when camera unavailable, shows a dark
/// placeholder with a camera icon.
struct CameraPreviewView: UIViewRepresentable {
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView()
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
static func dismantleUIView(_ uiView: CameraPreviewUIView, coordinator: ()) {
uiView.stopSession()
}
}
// MARK: - CameraPreviewUIView
/// UIKit view hosting AVCaptureVideoPreviewLayer for live camera feed.
///
/// Permission request is deferred to `didMoveToWindow()` the system dialog
/// needs an active window to present on. Requesting in `init()` is too early.
final class CameraPreviewUIView: UIView {
private let captureSession = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isSessionRunning = false
private var isSetUp = false
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(white: 0.08, alpha: 1)
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil {
// First time: request permission + set up session
// Subsequent times: just resume
if !isSetUp {
isSetUp = true
requestAccessAndSetup()
} else {
startSession()
}
} else {
stopSession()
}
}
// MARK: - Permission + Session Setup
private func requestAccessAndSetup() {
#if targetEnvironment(simulator)
addPlaceholder()
return
#else
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configureSession()
case .notDetermined:
// System dialog will present over the current window
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
if granted {
self?.configureSession()
} else {
self?.addDeniedPlaceholder()
}
}
}
default:
addDeniedPlaceholder()
}
#endif
}
/// Configures AVCaptureSession with rear camera input + preview layer.
/// Called only after camera authorization is confirmed.
private func configureSession() {
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back
) else {
addPlaceholder()
return
}
do {
let input = try AVCaptureDeviceInput(device: device)
captureSession.beginConfiguration()
captureSession.sessionPreset = .medium
if captureSession.canAddInput(input) {
captureSession.addInput(input)
}
captureSession.commitConfiguration()
let preview = AVCaptureVideoPreviewLayer(session: captureSession)
preview.videoGravity = .resizeAspectFill
preview.frame = bounds
layer.addSublayer(preview)
previewLayer = preview
startSession()
} catch {
addPlaceholder()
}
}
func startSession() {
guard !isSessionRunning, !captureSession.inputs.isEmpty else { return }
isSessionRunning = true
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.startRunning()
}
}
func stopSession() {
guard isSessionRunning else { return }
isSessionRunning = false
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.stopRunning()
}
}
// MARK: - Placeholders
/// Generic placeholder (simulator / no camera hardware).
private func addPlaceholder() {
let iconConfig = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular)
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
icon.tintColor = UIColor.white.withAlphaComponent(0.5)
icon.translatesAutoresizingMaskIntoConstraints = false
addSubview(icon)
NSLayoutConstraint.activate([
icon.centerXAnchor.constraint(equalTo: centerXAnchor),
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
/// Placeholder shown when camera access is denied includes "Tap to enable" hint.
private func addDeniedPlaceholder() {
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
let iconConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
icon.tintColor = UIColor.white.withAlphaComponent(0.4)
let label = UILabel()
label.text = "Enable Camera"
label.font = .systemFont(ofSize: 10, weight: .medium)
label.textColor = UIColor.white.withAlphaComponent(0.4)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(label)
addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor),
])
// Tap opens Settings
let tap = UITapGestureRecognizer(target: self, action: #selector(openSettings))
addGestureRecognizer(tap)
}
@objc private func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}

View File

@@ -38,6 +38,23 @@ private struct KeyboardPaddedView<Content: View>: View {
}
}
/// Shifts empty state content up by half the keyboard height to keep it
/// centered in the visible area above keyboard. Uses `.offset` (visual-only)
/// instead of frame height changes that would leak layout to the compositor overlay.
/// Observation-isolated: keyboard changes re-render only this wrapper.
private struct EmptyStateKeyboardOffset<Content: View>: View {
@ObservedObject private var keyboard = KeyboardTracker.shared
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content.offset(y: -keyboard.keyboardPadding / 2)
}
}
struct ChatDetailView: View {
let route: ChatRoute
var onPresentedChange: ((Bool) -> Void)? = nil
@@ -62,6 +79,9 @@ struct ChatDetailView: View {
/// Captured on chat open ID of the first unread incoming message (for separator).
@State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false
@State private var showAttachmentPanel = false
@State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false
private var currentPublicKey: String {
SessionManager.shared.currentPublicKey
@@ -108,11 +128,11 @@ struct ChatDetailView: View {
}
private var canSend: Bool {
!trimmedMessage.isEmpty
!trimmedMessage.isEmpty || !pendingAttachments.isEmpty
}
private var shouldShowSendButton: Bool {
!messageText.isEmpty
!messageText.isEmpty || !pendingAttachments.isEmpty
}
private var sendButtonProgress: CGFloat {
@@ -239,6 +259,117 @@ struct ChatDetailView: View {
var body: some View {
content
.navigationDestination(isPresented: $showOpponentProfile) {
OpponentProfileView(route: route)
}
}
}
// MARK: - Toolbar Content (observation-isolated)
/// Reads `DialogRepository` in its own observation scope for title/subtitle/verified.
/// Dialog mutations (from ANY chat) no longer cascade to ChatDetailView body,
/// preventing all visible message cells from re-evaluating.
private struct ChatDetailPrincipal: View {
let route: ChatRoute
@ObservedObject var viewModel: ChatDetailViewModel
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var badgeSpacing: CGFloat {
if #available(iOS 26, *) { return 3 } else { return 4 }
}
private var badgeSize: CGFloat {
if #available(iOS 26, *) { return 12 } else { return 14 }
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var subtitleText: String {
if route.isSavedMessages { return "" }
if route.isSystemAccount { return "official account" }
if viewModel.isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
private var subtitleColor: Color {
if viewModel.isTyping { return RosettaColors.primaryBlue }
if dialog?.isOnline == true { return RosettaColors.online }
return RosettaColors.Adaptive.textSecondary
}
var body: some View {
VStack(spacing: 1) {
HStack(spacing: badgeSpacing) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: badgeSize)
}
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
}
}
/// Reads `DialogRepository` and `AvatarRepository` in its own observation scope
/// for avatar initials/color/image. Isolated from ChatDetailView body.
private struct ChatDetailToolbarAvatar: View {
let route: ChatRoute
let size: CGFloat
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
var body: some View {
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
AvatarView(
initials: initials,
colorIndex: colorIndex,
size: size,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: avatar
)
}
}
@@ -265,52 +396,25 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 3) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 12)
}
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 35,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
} else {
// iOS < 26 capsule back button, larger avatar, .thinMaterial
@@ -321,53 +425,25 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 14)
}
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 38,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
}
}
@@ -478,38 +554,52 @@ private extension ChatDetailView {
}
private var emptyStateView: some View {
VStack(spacing: 16) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
// EmptyStateKeyboardOffset applies offset(y: -keyboardPadding/2) which is
// visual-only does NOT affect layout. Keeps content centered in the visible
// area above keyboard without leaking layout changes to the compositor overlay.
EmptyStateKeyboardOffset {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 4) {
Text(titleText)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
VStack(spacing: 16) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
if !route.isSavedMessages {
Text(subtitleText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
VStack(spacing: 4) {
Text(titleText)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
if !route.isSavedMessages {
Text(subtitleText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
Text(route.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
}
}
Text(route.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
Spacer()
// Reserve space for compositor so content centers above it.
Color.clear.frame(height: composerHeight)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { isInputFocused = false }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { isInputFocused = false }
}
@ViewBuilder
@@ -628,9 +718,25 @@ private extension ChatDetailView {
@ViewBuilder
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let messageText = message.text.isEmpty ? " " : message.text
let hasTail = position == .single || position == .bottom
// 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 }
if visibleAttachments.isEmpty {
// Text-only message (original path)
textOnlyBubble(message: message, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
} else {
// Attachment message: images/files + optional caption
attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
}
}
/// Text-only message bubble (original design).
@ViewBuilder
private func textOnlyBubble(message: ChatMessage, outgoing: Bool, hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let messageText = message.text.isEmpty ? " " : message.text
// Telegram-style compact bubble: inline time+status at bottom-trailing.
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
Text(parsedMarkdown(messageText))
@@ -645,25 +751,7 @@ private extension ChatDetailView {
.padding(.vertical, 5)
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
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)
@@ -678,18 +766,139 @@ private extension ChatDetailView {
.padding(.bottom, 0)
}
// MARK: - Markdown Parsing
/// Attachment message bubble: images/files with optional text caption.
@ViewBuilder
private func attachmentBubble(
message: ChatMessage,
attachments: [MessageAttachment],
outgoing: Bool,
hasTail: Bool,
maxBubbleWidth: CGFloat,
position: BubblePosition
) -> some View {
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
/// Parses inline markdown (`**bold**`) from runtime strings.
/// Falls back to plain `AttributedString` if parsing fails.
VStack(alignment: .leading, spacing: 0) {
// Attachment views
ForEach(attachments, id: \.id) { attachment in
switch attachment.type {
case .image:
MessageImageView(
attachment: attachment,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .file:
MessageFileView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 6)
.padding(.top, 4)
default:
EmptyView()
}
}
// Caption text (if any)
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, 4)
.padding(.bottom, 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) }
.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.
@ViewBuilder
private func timestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
}
// MARK: - Text Parsing (Markdown + Emoji)
/// Static cache for parsed markdown + emoji. Message text is immutable,
/// so results never need invalidation. Bounded at 200 entries (~5 chats).
/// Without cache, regex + markdown parser runs on EVERY body evaluation
/// for EVERY visible cell expensive at 120Hz scroll.
@MainActor private static var markdownCache: [String: AttributedString] = [:]
/// Parses inline markdown (`**bold**`) and emoji shortcodes (`:emoji_CODE:`)
/// from runtime strings. Emoji shortcodes are replaced BEFORE markdown parsing
/// so that emoji characters render inline with formatted text.
///
/// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/`
/// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt`
private func parsedMarkdown(_ text: String) -> AttributedString {
if let cached = Self.markdownCache[text] { return cached }
// Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji.
let withEmoji = EmojiParser.replaceShortcodes(in: text)
let result: AttributedString
if let parsed = try? AttributedString(
markdown: text,
markdown: withEmoji,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return parsed
result = parsed
} else {
result = AttributedString(withEmoji)
}
return AttributedString(text)
if Self.markdownCache.count > 200 {
Self.markdownCache.removeAll(keepingCapacity: true)
}
Self.markdownCache[text] = result
return result
}
// MARK: - Unread Separator
@@ -710,6 +919,11 @@ private extension ChatDetailView {
var composer: some View {
VStack(spacing: 6) {
// Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
}
if let sendError {
Text(sendError)
.font(.system(size: 12))
@@ -719,15 +933,9 @@ private extension ChatDetailView {
}
HStack(alignment: .bottom, spacing: 0) {
// Desktop parity: paperclip opens attachment menu with camera option.
// Camera sends current user's avatar to this chat.
Menu {
Button {
sendAvatarToChat()
} label: {
Label("Send Avatar", systemImage: "camera.fill")
}
.disabled(isSendingAvatar)
// Desktop parity: paperclip opens attachment panel (photo gallery + file picker).
Button {
showAttachmentPanel = true
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.paperclip,
@@ -740,6 +948,21 @@ private extension ChatDetailView {
}
.accessibilityLabel("Attach")
.buttonStyle(ChatDetailGlassPressButtonStyle())
.sheet(isPresented: $showAttachmentPanel) {
AttachmentPanelView(
onSend: { attachments, caption in
// Pre-fill caption as message text (sent alongside attachments)
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
if !trimmedCaption.isEmpty {
messageText = trimmedCaption
}
handleAttachmentsSend(attachments)
},
onSendAvatar: {
sendAvatarToChat()
}
)
}
HStack(alignment: .bottom, spacing: 0) {
ChatTextInput(
@@ -923,6 +1146,13 @@ private extension ChatDetailView {
// MARK: - Actions / utils
/// Opens the opponent profile sheet.
/// For Saved Messages and system accounts no profile to show.
func openProfile() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
showOpponentProfile = true
}
func trailingAction() {
if canSend { sendCurrentMessage() }
else { isInputFocused = true }
@@ -942,34 +1172,29 @@ private extension ChatDetailView {
func deliveryTint(_ status: DeliveryStatus) -> Color {
switch status {
case .read: return Color(hex: 0xA4E2FF)
case .delivered: return Color.white.opacity(0.94)
case .delivered: return Color.white.opacity(0.5)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
}
}
func deliveryIcon(_ status: DeliveryStatus) -> String {
switch status {
case .waiting: return "clock"
case .delivered: return "checkmark"
case .read: return "checkmark"
case .error: return "exclamationmark.circle.fill"
}
}
@ViewBuilder
func deliveryIndicator(_ status: DeliveryStatus) -> some View {
switch status {
case .read:
ZStack {
Image(systemName: "checkmark").offset(x: 3)
Image(systemName: "checkmark")
}
.font(.system(size: 9.5, weight: .semibold))
.foregroundStyle(deliveryTint(status))
.frame(width: 12, alignment: .trailing)
default:
Image(systemName: deliveryIcon(status))
DoubleCheckmarkShape()
.fill(deliveryTint(status))
.frame(width: 16, height: 8.7)
case .delivered:
SingleCheckmarkShape()
.fill(deliveryTint(status))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status))
}
@@ -1071,23 +1296,40 @@ private extension ChatDetailView {
func sendCurrentMessage() {
let message = trimmedMessage
guard !message.isEmpty else { return }
let attachments = pendingAttachments
// Must have either text or attachments
guard !message.isEmpty || !attachments.isEmpty else { return }
// User is sending a message reset idle timer.
SessionManager.shared.recordUserInteraction()
shouldScrollOnNextMessage = true
messageText = ""
pendingAttachments = []
sendError = nil
// Desktop parity: delete draft after sending.
DraftManager.shared.deleteDraft(for: route.publicKey)
Task { @MainActor in
do {
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
if !attachments.isEmpty {
// Send message with attachments
try await SessionManager.shared.sendMessageWithAttachments(
text: message,
attachments: attachments,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else {
// Text-only message (existing path)
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
}
} catch {
sendError = "Failed to send message"
if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -1097,6 +1339,15 @@ private extension ChatDetailView {
}
}
/// Handles attachments selected from the attachment panel.
/// Always sends immediately no preview step.
func handleAttachmentsSend(_ attachments: [PendingAttachment]) {
let remaining = PendingAttachment.maxAttachmentsPerMessage - pendingAttachments.count
let toAdd = Array(attachments.prefix(remaining))
pendingAttachments.append(contentsOf: toAdd)
sendCurrentMessage()
}
/// Desktop parity: onClickCamera() sends current user's avatar to this chat.
func sendAvatarToChat() {
guard !isSendingAvatar else { return }
@@ -1191,7 +1442,7 @@ private struct ChatDetailGlassCirclePressStyle: ButtonStyle {
// MARK: - SVG
private struct TelegramVectorIcon: View {
struct TelegramVectorIcon: View {
let pathData: String
let viewBox: CGSize
let color: Color
@@ -1202,193 +1453,8 @@ private struct TelegramVectorIcon: View {
}
}
private struct SVGPathShape: Shape {
let pathData: String
let viewBox: CGSize
func path(in rect: CGRect) -> Path {
guard viewBox.width > 0, viewBox.height > 0 else { return Path() }
var parser = SVGPathParser(pathData: pathData)
var output = Path(parser.parse())
output = output.applying(.init(scaleX: rect.width / viewBox.width, y: rect.height / viewBox.height))
return output
}
}
private enum SVGPathToken {
case command(Character)
case number(CGFloat)
}
private struct SVGPathTokenizer {
static func tokenize(_ source: String) -> [SVGPathToken] {
var tokens: [SVGPathToken] = []
let chars = Array(source)
var index = 0
while index < chars.count {
let ch = chars[index]
if ch.isWhitespace || ch == "," { index += 1; continue }
if ch.isLetter { tokens.append(.command(ch)); index += 1; continue }
if ch.isNumber || ch == "-" || ch == "+" || ch == "." {
let start = index
index += 1
while index < chars.count {
let c = chars[index]
let prev = chars[index - 1]
if c.isNumber || c == "." || c == "e" || c == "E" { index += 1; continue }
if (c == "-" || c == "+"), (prev == "e" || prev == "E") { index += 1; continue }
break
}
let fragment = String(chars[start..<index])
if let value = Double(fragment) { tokens.append(.number(CGFloat(value))) }
continue
}
index += 1
}
return tokens
}
}
private struct SVGPathParser {
private let tokens: [SVGPathToken]
private var index: Int = 0
private var lastCommand: Character = "M"
private var current = CGPoint.zero
private var subpathStart = CGPoint.zero
private var cgPath = CGMutablePath()
init(pathData: String) {
self.tokens = SVGPathTokenizer.tokenize(pathData)
}
mutating func parse() -> CGPath {
while index < tokens.count {
let command = readCommandOrReuse()
switch command {
case "M", "m": parseMove(command)
case "L", "l": parseLine(command)
case "H", "h": parseHorizontal(command)
case "V", "v": parseVertical(command)
case "C", "c": parseCubic(command)
case "Z", "z":
cgPath.closeSubpath()
current = subpathStart
default:
skipToNextCommand()
}
}
return cgPath.copy() ?? CGMutablePath()
}
private var isAtCommand: Bool {
guard index < tokens.count else { return false }
if case .command = tokens[index] { return true }
return false
}
private mutating func readCommandOrReuse() -> Character {
guard index < tokens.count else { return lastCommand }
if case let .command(command) = tokens[index] {
index += 1
lastCommand = command
return command
}
return lastCommand
}
private mutating func readNumber() -> CGFloat? {
guard index < tokens.count else { return nil }
if case let .number(value) = tokens[index] {
index += 1
return value
}
return nil
}
private func resolvedPoint(x: CGFloat, y: CGFloat, relative: Bool) -> CGPoint {
relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y)
}
private mutating func readPoint(relative: Bool) -> CGPoint? {
guard let x = readNumber(), let y = readNumber() else { return nil }
return resolvedPoint(x: x, y: y, relative: relative)
}
private mutating func parseMove(_ command: Character) {
let relative = command.isLowercase
guard let first = readPoint(relative: relative) else { return }
cgPath.move(to: first)
current = first
subpathStart = first
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
lastCommand = relative ? "l" : "L"
}
private mutating func parseLine(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let point = readPoint(relative: relative) {
cgPath.addLine(to: point)
current = point
}
}
private mutating func parseHorizontal(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: relative ? current.x + value : value, y: current.y)
cgPath.addLine(to: current)
}
}
private mutating func parseVertical(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand, let value = readNumber() {
current = CGPoint(x: current.x, y: relative ? current.y + value : value)
cgPath.addLine(to: current)
}
}
private mutating func parseCubic(_ command: Character) {
let relative = command.isLowercase
while !isAtCommand {
guard let x1 = readNumber(),
let y1 = readNumber(),
let x2 = readNumber(),
let y2 = readNumber(),
let x = readNumber(),
let y = readNumber()
else { return }
let c1 = resolvedPoint(x: x1, y: y1, relative: relative)
let c2 = resolvedPoint(x: x2, y: y2, relative: relative)
let end = resolvedPoint(x: x, y: y, relative: relative)
cgPath.addCurve(to: end, control1: c1, control2: c2)
current = end
}
}
private mutating func skipToNextCommand() {
while index < tokens.count {
if case .command = tokens[index] { return }
index += 1
}
}
}
private enum TelegramIconPath {
enum TelegramIconPath {
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
static let paperclip = #"M11.0156 17.9297L9.84375 16.7871L17.4316 9.11133C17.8418 8.70117 18.1543 8.22266 18.3691 7.67578C18.584 7.14844 18.6914 6.5918 18.6914 6.00586C18.6914 5.43945 18.584 4.88281 18.3691 4.33594C18.1348 3.80859 17.8125 3.33984 17.4023 2.92969C16.9922 2.51953 16.5137 2.20703 15.9668 1.99219C15.4395 1.75781 14.8828 1.65039 14.2969 1.66992C13.7109 1.66992 13.1543 1.77734 12.627 1.99219C12.0801 2.22656 11.6016 2.54883 11.1914 2.95898L3.60352 10.6055C2.97852 11.2305 2.5 11.9531 2.16797 12.7734C1.83594 13.5742 1.66992 14.4141 1.66992 15.293C1.66992 16.1719 1.8457 17.0215 2.19727 17.8418C2.5293 18.6426 3.00781 19.3555 3.63281 19.9805C4.25781 20.6055 4.98047 21.084 5.80078 21.416C6.62109 21.748 7.4707 21.9141 8.34961 21.9141C9.22852 21.8945 10.0684 21.7188 10.8691 21.3867C11.6895 21.0547 12.4121 20.5762 13.0371 19.9512L18.5449 14.3848C18.7012 14.2285 18.8965 14.1504 19.1309 14.1504C19.3652 14.1504 19.5605 14.2285 19.7168 14.3848C19.873 14.541 19.9512 14.7363 19.9512 14.9707C19.9707 15.2051 19.8926 15.4004 19.7168 15.5566L14.1211 21.1816C13.3594 21.9434 12.4805 22.5293 11.4844 22.9395C10.4688 23.3496 9.42383 23.5547 8.34961 23.5547C8.33008 23.5547 8.04688 23.5547 7.5 23.5547C6.95312 23.5547 6.17188 23.3496 5.15625 22.9395C4.14062 22.5293 3.24219 21.9336 2.46094 21.1523C1.67969 20.3906 1.07422 19.502 0.644531 18.4863C0.234375 17.4707 0.0195312 16.4062 0 15.293V15.2637C0 14.1699 0.214844 13.125 0.644531 12.1289C1.05469 11.1133 1.64062 10.2148 2.40234 9.43359L10.0195 1.78711C10.5859 1.2207 11.2402 0.78125 11.9824 0.46875C12.7246 0.15625 13.4961 0 14.2969 0H14.3262C15.1074 0 15.8691 0.146484 16.6113 0.439453C17.3535 0.751953 18.0078 1.18164 18.5742 1.72852C19.1406 2.29492 19.5801 2.94922 19.8926 3.69141C20.2051 4.43359 20.3613 5.20508 20.3613 6.00586V6.03516C20.3613 6.83594 20.2148 7.59766 19.9219 8.32031C19.6094 9.0625 19.1699 9.7168 18.6035 10.2832L11.0156 17.9297ZM10.957 6.88477C11.0352 6.80664 11.1328 6.74805 11.25 6.70898C11.3477 6.66992 11.4453 6.65039 11.543 6.65039C11.6602 6.65039 11.7676 6.66992 11.8652 6.70898C11.9629 6.74805 12.0508 6.80664 12.1289 6.88477C12.207 6.96289 12.2754 7.05078 12.334 7.14844C12.373 7.24609 12.3926 7.35352 12.3926 7.4707C12.3926 7.56836 12.373 7.67578 12.334 7.79297C12.2754 7.89063 12.207 7.97852 12.1289 8.05664L6.62109 13.623C6.40625 13.8184 6.25 14.0527 6.15234 14.3262C6.03516 14.6191 5.97656 14.9121 5.97656 15.2051C5.97656 15.498 6.03516 15.7812 6.15234 16.0547C6.26953 16.3281 6.43555 16.5723 6.65039 16.7871C6.86523 17.002 7.10938 17.168 7.38281 17.2852C7.65625 17.3828 7.93945 17.4316 8.23242 17.4316C8.54492 17.4316 8.83789 17.373 9.11133 17.2559C9.38477 17.1387 9.62891 16.9824 9.84375 16.7871L11.0156 17.9297C10.6445 18.3008 10.2246 18.584 9.75586 18.7793C9.26758 18.9941 8.75977 19.1016 8.23242 19.1016C7.70508 19.1016 7.20703 19.0039 6.73828 18.8086C6.26953 18.6133 5.84961 18.3301 5.47852 17.959C5.10742 17.5879 4.82422 17.168 4.62891 16.6992C4.41406 16.2305 4.30664 15.7324 4.30664 15.2051V15.1758C4.30664 14.6875 4.4043 14.209 4.59961 13.7402C4.77539 13.291 5.0293 12.8809 5.36133 12.5098L10.957 6.88477Z"#

View File

@@ -0,0 +1,198 @@
import SwiftUI
// MARK: - MessageAvatarView
/// Displays an avatar attachment inside a message bubble.
///
/// Desktop parity: `MessageAvatar.tsx` shows a bordered card with circular avatar
/// preview, "Avatar" title with lock icon, and descriptive text.
///
/// States:
/// 1. **Cached** avatar already in AttachmentCache, display immediately
/// 2. **Downloading** show placeholder + spinner
/// 3. **Downloaded** display avatar, auto-saved to AvatarRepository
/// 4. **Error** "Avatar expired" or download error
struct MessageAvatarView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
@State private var avatarImage: UIImage?
@State private var isDownloading = false
@State private var downloadError = false
/// Avatar circle diameter (desktop parity: 60px).
private let avatarSize: CGFloat = 56
var body: some View {
HStack(spacing: 10) {
// Avatar circle (left side)
avatarCircle
// Metadata (right side)
VStack(alignment: .leading, spacing: 3) {
// Title row: "Avatar" + lock icon
HStack(spacing: 4) {
Text("Avatar")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundStyle(Color.green.opacity(0.8))
}
// Description
Text("An avatar image shared in the message.")
.font(.system(size: 12))
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.lineLimit(2)
// Download state indicator
if isDownloading {
HStack(spacing: 4) {
ProgressView()
.tint(outgoing ? .white.opacity(0.6) : Color(hex: 0x008BFF))
.scaleEffect(0.7)
Text("Downloading...")
.font(.system(size: 11))
.foregroundStyle(outgoing ? .white.opacity(0.4) : RosettaColors.Adaptive.textSecondary)
}
.padding(.top, 2)
} else if downloadError {
Text("Avatar expired")
.font(.system(size: 11))
.foregroundStyle(RosettaColors.error)
.padding(.top, 2)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.1), lineWidth: 1)
)
.task {
loadFromCache()
if avatarImage == nil {
downloadAvatar()
}
}
}
// MARK: - Avatar Circle
@ViewBuilder
private var avatarCircle: some View {
ZStack {
if let avatarImage {
Image(uiImage: avatarImage)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
Circle()
.fill(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.08))
.frame(width: avatarSize, height: avatarSize)
.overlay {
if isDownloading {
ProgressView()
.tint(.white.opacity(0.5))
} else {
Image(systemName: "person.fill")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.3))
}
}
}
}
}
// MARK: - Download
private func loadFromCache() {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
avatarImage = cached
}
}
private func downloadAvatar() {
guard !isDownloading, avatarImage == nil else { return }
let tag = extractTag(from: attachment.preview)
guard !tag.isEmpty else {
downloadError = true
return
}
guard let password = message.attachmentPassword, !password.isEmpty else {
print("🎭 [AvatarView] NO password for attachment \(attachment.id)")
downloadError = true
return
}
print("🎭 [AvatarView] Downloading avatar \(attachment.id), tag=\(tag.prefix(20))")
isDownloading = true
downloadError = false
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let decryptedData = try CryptoManager.shared.decryptWithPassword(
encryptedString, password: password
)
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 {
if let downloadedImage {
avatarImage = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
} else {
downloadError = true
}
isDownloading = false
}
} catch {
await MainActor.run {
downloadError = true
isDownloading = false
}
}
}
}
/// Extracts the server tag from preview string.
/// Format: "tag::blurhash" returns "tag".
private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
}
}

View File

@@ -0,0 +1,199 @@
import SwiftUI
// MARK: - MessageFileView
/// Displays a file attachment inside a message bubble.
///
/// Desktop parity: `MessageFile.tsx` shows file icon + filename + size card.
/// Tap to download (if not cached), then share via UIActivityViewController.
///
/// Preview format: "tag::filesize::filename" (desktop parity).
struct MessageFileView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
@State private var isDownloading = false
@State private var downloadProgress: String = ""
@State private var isDownloaded = false
@State private var downloadError = false
@State private var cachedFileURL: URL?
var body: some View {
HStack(spacing: 10) {
// File icon circle
ZStack {
Circle()
.fill(outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF).opacity(0.2))
.frame(width: 40, height: 40)
if isDownloading {
ProgressView()
.tint(outgoing ? .white : Color(hex: 0x008BFF))
.scaleEffect(0.8)
} else if isDownloaded {
Image(systemName: fileIcon)
.font(.system(size: 18))
.foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF))
} else {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 18))
.foregroundStyle(outgoing ? .white.opacity(0.7) : Color(hex: 0x008BFF).opacity(0.7))
}
}
// File metadata
VStack(alignment: .leading, spacing: 2) {
Text(fileName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
.lineLimit(1)
if isDownloading {
Text("Downloading...")
.font(.system(size: 12))
.foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
} else if downloadError {
Text("File expired")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.error)
} else {
Text(formattedFileSize)
.font(.system(size: 12))
.foregroundStyle(
outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary
)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 220)
.contentShape(Rectangle())
.onTapGesture {
if isDownloaded, let url = cachedFileURL {
shareFile(url)
} else if !isDownloading {
downloadFile()
}
}
.task {
checkCache()
}
}
// MARK: - Metadata Parsing
/// Parses "tag::filesize::filename" preview format.
private var fileMetadata: (tag: String, size: Int, name: String) {
let parts = attachment.preview.components(separatedBy: "::")
let tag = parts.first ?? ""
let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
let name = parts.count > 2 ? parts[2] : "file"
return (tag, size, name)
}
private var fileName: String { fileMetadata.name }
private var fileSize: Int { fileMetadata.size }
private var fileTag: String { fileMetadata.tag }
private var formattedFileSize: String {
let bytes = fileSize
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
private var fileIcon: String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.fill"
case "zip", "rar", "7z": return "doc.zipper"
case "jpg", "jpeg", "png", "gif": return "photo.fill"
case "mp4", "mov", "avi": return "film.fill"
case "mp3", "wav", "aac": return "waveform"
default: return "doc.fill"
}
}
// MARK: - Download
private func checkCache() {
if let url = AttachmentCache.shared.fileURL(forAttachmentId: attachment.id, fileName: fileName) {
cachedFileURL = url
isDownloaded = true
}
}
private func downloadFile() {
guard !isDownloading, !fileTag.isEmpty else { return }
guard let password = message.attachmentPassword, !password.isEmpty else {
downloadError = true
return
}
isDownloading = true
downloadError = false
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: fileTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let decryptedData = try CryptoManager.shared.decryptWithPassword(
encryptedString, password: password
)
// Parse data URI if present, otherwise use raw data
let fileData: Data
if let decryptedString = String(data: decryptedData, encoding: .utf8),
decryptedString.hasPrefix("data:"),
let commaIndex = decryptedString.firstIndex(of: ",") {
let base64Part = String(decryptedString[decryptedString.index(after: commaIndex)...])
fileData = Data(base64Encoded: base64Part) ?? decryptedData
} else {
fileData = decryptedData
}
let url = AttachmentCache.shared.saveFile(
fileData, forAttachmentId: attachment.id, fileName: fileName
)
await MainActor.run {
cachedFileURL = url
isDownloaded = true
isDownloading = false
}
} catch {
await MainActor.run {
downloadError = true
isDownloading = false
}
}
}
}
// MARK: - Share
private func shareFile(_ url: URL) {
let activityVC = UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first,
let rootVC = window.rootViewController {
var topVC = rootVC
while let presented = topVC.presentedViewController {
topVC = presented
}
if let popover = activityVC.popoverPresentationController {
popover.sourceView = topVC.view
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0)
}
topVC.present(activityVC, animated: true)
}
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
// MARK: - MessageImageView
/// Displays an image attachment inside a message bubble.
///
/// Desktop parity: `MessageImage.tsx` shows blur placeholder while downloading,
/// full image after download, "Image expired" on error.
///
/// States:
/// 1. **Cached** image already in AttachmentCache, display immediately
/// 2. **Downloading** show placeholder + spinner
/// 3. **Downloaded** display image, tap for full-screen (future)
/// 4. **Error** "Image expired" or download error
struct MessageImageView: View {
let attachment: MessageAttachment
let message: ChatMessage
let outgoing: Bool
let maxWidth: CGFloat
@State private var image: UIImage?
@State private var isDownloading = false
@State private var downloadError = false
/// Desktop parity: image bubble max dimensions.
private let maxImageWidth: CGFloat = 240
private let maxImageHeight: CGFloat = 280
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: min(maxImageWidth, maxWidth - 20))
.frame(maxHeight: maxImageHeight)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else if isDownloading {
placeholder
.overlay { ProgressView().tint(.white) }
} else if downloadError {
placeholder
.overlay {
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 {
placeholder
.overlay {
Image(systemName: "arrow.down.circle")
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.6))
}
.onTapGesture { downloadImage() }
}
}
.task {
loadFromCache()
if image == nil {
downloadImage()
}
}
}
// MARK: - Placeholder
private var placeholder: some View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.08))
.frame(width: 200, height: 150)
}
// MARK: - Download
private func loadFromCache() {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
image = cached
}
}
private func downloadImage() {
guard !isDownloading, image == nil else { return }
// Extract tag from preview ("tag::blurhash" tag)
let tag = extractTag(from: attachment.preview)
guard !tag.isEmpty else {
print("🖼️ [ImageView] tag is empty for attachment \(attachment.id)")
downloadError = true
return
}
guard let password = message.attachmentPassword, !password.isEmpty else {
print("🖼️ [ImageView] NO password for attachment \(attachment.id), preview=\(attachment.preview.prefix(40))")
downloadError = true
return
}
print("🖼️ [ImageView] Downloading attachment \(attachment.id), tag=\(tag.prefix(20))…, passwordLen=\(password.count)")
isDownloading = true
downloadError = false
Task {
do {
// Download encrypted blob from transport server
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag)
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
let decryptedData = try CryptoManager.shared.decryptWithPassword(
encryptedString, password: password
)
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 {
if let downloadedImage {
print("🖼️ [ImageView] ✅ Image decoded successfully for \(attachment.id)")
image = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
} else {
print("🖼️ [ImageView] ❌ Failed to decode image data for \(attachment.id)")
downloadError = true
}
isDownloading = false
}
} catch {
print("🖼️ [ImageView] ❌ Error for \(attachment.id): \(error.localizedDescription)")
await MainActor.run {
downloadError = true
isDownloading = false
}
}
}
}
/// Extracts the server tag from preview string.
/// Format: "tag::blurhash" or "tag::" returns "tag".
private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
}
}

View File

@@ -0,0 +1,251 @@
import SwiftUI
/// Profile screen for viewing opponent (other user) information.
/// Pushed from ChatDetailView when tapping the toolbar capsule or avatar.
///
/// Desktop parity: ProfileCard (avatar + name + subtitle)
/// Username section (copyable) Public Key section (copyable).
struct OpponentProfileView: View {
let route: ChatRoute
@Environment(\.dismiss) private var dismiss
@State private var copiedField: String?
// MARK: - Computed properties
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var displayName: String {
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var username: String {
if let dialog, !dialog.opponentUsername.isEmpty { return dialog.opponentUsername }
return route.username
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var avatarInitials: String {
RosettaColors.initials(name: displayName, publicKey: route.publicKey)
}
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey)
}
private var opponentAvatar: UIImage? {
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
}
/// Desktop parity: @username shortKey
private var subtitleText: String {
let shortKey = route.publicKey.prefix(4) + "..." + route.publicKey.suffix(4)
if !username.isEmpty {
return "@\(username) · \(shortKey)"
}
return String(shortKey)
}
// MARK: - Body
var body: some View {
ScrollView {
VStack(spacing: 0) {
profileCard
.padding(.top, 32)
infoSections
.padding(.top, 32)
.padding(.horizontal, 16)
}
}
.scrollIndicators(.hidden)
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backButtonLabel }
.buttonStyle(.plain)
}
}
.toolbarBackground(.hidden, for: .navigationBar)
}
// MARK: - Back Button
private var backButtonLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.background { glassCapsule() }
}
// MARK: - Profile Card (Desktop: ProfileCard component)
private var profileCard: some View {
VStack(spacing: 0) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 100,
isOnline: false,
image: opponentAvatar
)
HStack(spacing: 5) {
Text(displayName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(2)
.multilineTextAlignment(.center)
if effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 18, badgeTint: .white)
}
}
.padding(.top, 12)
.padding(.horizontal, 32)
Text(subtitleText)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.padding(.top, 4)
}
}
// MARK: - Info Sections (Desktop: SettingsInput.Copy rows)
private var infoSections: some View {
VStack(spacing: 16) {
if !username.isEmpty {
copyRow(
label: "Username",
value: "@\(username)",
rawValue: username,
fieldId: "username",
helper: "Username for search user or send message."
)
}
copyRow(
label: "Public Key",
value: route.publicKey,
rawValue: route.publicKey,
fieldId: "publicKey",
helper: "This is user public key. If user haven't set a @username yet, you can send message using public key."
)
}
}
// MARK: - Copy Row (Desktop: SettingsInput.Copy)
private func copyRow(
label: String,
value: String,
rawValue: String,
fieldId: String,
helper: String
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Button {
UIPasteboard.general.string = rawValue
withAnimation(.easeInOut(duration: 0.2)) { copiedField = fieldId }
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.5))
withAnimation(.easeInOut(duration: 0.2)) {
if copiedField == fieldId { copiedField = nil }
}
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(copiedField == fieldId ? "Copied" : value)
.font(.system(size: 16))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.text
)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: copiedField == fieldId ? "checkmark" : "doc.on.doc")
.font(.system(size: 13))
.foregroundStyle(
copiedField == fieldId
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background { glassCard() }
}
.buttonStyle(.plain)
Text(helper)
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.horizontal, 8)
}
}
// MARK: - Glass helpers
@ViewBuilder
private func glassCapsule() -> some View {
if #available(iOS 26.0, *) {
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
} else {
Capsule().fill(.thinMaterial)
.overlay { Capsule().strokeBorder(Color.white.opacity(0.22), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
@ViewBuilder
private func glassCard() -> some View {
if #available(iOS 26.0, *) {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.clear)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
)
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
}
}
}
}

View File

@@ -0,0 +1,91 @@
import UIKit
// MARK: - PendingAttachment
/// Represents an attachment selected by the user but not yet sent.
/// Used in the attachment preview strip above the compositor.
///
/// Desktop parity: the `attachments` state array in `DialogInput.tsx` before
/// `prepareAttachmentsToSend()` processes and uploads them.
struct PendingAttachment: Identifiable, Sendable {
/// Random 8-character alphanumeric ID (matches desktop's `generateRandomKey(8)`).
let id: String
/// Attachment type `.image` or `.file` for user-initiated sends.
let type: AttachmentType
/// Raw image/file data (pre-compression for images).
let data: Data
/// Thumbnail for preview (images only). `nil` for files.
let thumbnail: UIImage?
/// Original file name (files only). `nil` for images.
let fileName: String?
/// File size in bytes (files only). `nil` for images.
let fileSize: Int?
// MARK: - Factory
/// Creates a PendingAttachment from a UIImage (compressed to JPEG).
static func fromImage(_ image: UIImage) -> PendingAttachment {
let id = generateRandomId()
// Resize to max 1280px on longest side for mobile optimization
let resized = resizeImage(image, maxDimension: 1280)
let data = resized.jpegData(compressionQuality: 0.8) ?? Data()
let thumbnail = resizeImage(image, maxDimension: 200)
return PendingAttachment(
id: id,
type: .image,
data: data,
thumbnail: thumbnail,
fileName: nil,
fileSize: nil
)
}
/// Creates a PendingAttachment from file data + metadata.
static func fromFile(data: Data, fileName: String) -> PendingAttachment {
return PendingAttachment(
id: generateRandomId(),
type: .file,
data: data,
thumbnail: nil,
fileName: fileName,
fileSize: data.count
)
}
// MARK: - Helpers
/// Generates a random 8-character ID (desktop: `generateRandomKey(8)`).
private static func generateRandomId() -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return String((0..<8).map { _ in chars.randomElement()! })
}
/// Resizes image so longest side is at most `maxDimension`.
private static func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
// MARK: - Constants
extension PendingAttachment {
/// Desktop parity: `MAX_ATTACHMENTS_IN_MESSAGE = 5`.
static let maxAttachmentsPerMessage = 5
}

View File

@@ -0,0 +1,356 @@
import SwiftUI
import Photos
// MARK: - PhotoGridView
/// Custom photo library grid matching Figma attachment panel design (1:1).
///
/// Figma layout (node 3994:39103):
/// ```
///
/// Photo[0] Photo[1] row 1
/// Camera
/// (live) Photo[2] Photo[3] row 2
///
///
/// Photo[4] Photo[5] Photo[6] row 3 (LazyVGrid)
///
/// ```
///
/// Camera tile: col 1, rows 12, shows live rear camera feed (AVCaptureSession).
/// Photos: square tiles with selection circle (22pt, white border 1.5pt).
/// 1px spacing between all tiles.
struct PhotoGridView: View {
@Binding var selectedAssets: [PHAsset]
let maxSelection: Int
let onCameraTap: () -> Void
var onPhotoPreview: ((PHAsset) -> Void)? = nil
@State private var assets: [PHAsset] = []
@State private var authorizationStatus: PHAuthorizationStatus = .notDetermined
private let imageManager = PHCachingImageManager()
private let columns = 3
private let spacing: CGFloat = 1
var body: some View {
Group {
switch authorizationStatus {
case .authorized, .limited:
photoGrid
case .denied, .restricted:
permissionDeniedView
default:
requestingView
}
}
.task {
await requestPhotoAccess()
}
}
// MARK: - Photo Grid
private var photoGrid: some View {
GeometryReader { geometry in
let tileSize = (geometry.size.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
let cameraHeight = tileSize * 2 + spacing
ScrollView {
VStack(spacing: spacing) {
// Header: Camera (2 rows) + 4 photos (2×2 grid)
headerSection(tileSize: tileSize, cameraHeight: cameraHeight)
// Remaining photos: standard 3-column grid
remainingPhotosGrid(tileSize: tileSize, startIndex: 4)
}
.padding(.bottom, 100) // Space for tab bar + send button
}
}
}
/// Header section: Camera tile (col 1, rows 1-2) + 4 photos (cols 2-3, rows 1-2).
@ViewBuilder
private func headerSection(tileSize: CGFloat, cameraHeight: CGFloat) -> some View {
HStack(alignment: .top, spacing: spacing) {
// Camera tile spans 2 rows (double height)
CameraPreviewTile(
width: tileSize,
height: cameraHeight,
onTap: onCameraTap
)
// Right side: 2 rows × 2 columns of photos
VStack(spacing: spacing) {
// Row 1: photos[0], photos[1]
HStack(spacing: spacing) {
headerPhotoTile(index: 0, size: tileSize)
headerPhotoTile(index: 1, size: tileSize)
}
// Row 2: photos[2], photos[3]
HStack(spacing: spacing) {
headerPhotoTile(index: 2, size: tileSize)
headerPhotoTile(index: 3, size: tileSize)
}
}
}
}
/// Single photo tile in the header section.
@ViewBuilder
private func headerPhotoTile(index: Int, size: CGFloat) -> some View {
if index < assets.count {
let asset = assets[index]
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
PhotoTile(
asset: asset,
imageManager: imageManager,
size: size,
isSelected: isSelected,
selectionNumber: selectionIndex.map { $0 + 1 },
onTap: { handlePhotoTap(asset) },
onCircleTap: { toggleSelection(asset) }
)
.frame(width: size, height: size)
} else {
Color(white: 0.15)
.frame(width: size, height: size)
}
}
/// Remaining photos after the header, displayed as a standard 3-column LazyVGrid.
@ViewBuilder
private func remainingPhotosGrid(tileSize: CGFloat, startIndex: Int) -> some View {
let remaining = assets.count > startIndex ? Array(assets[startIndex...]) : []
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns),
spacing: spacing
) {
ForEach(0..<remaining.count, id: \.self) { index in
let asset = remaining[index]
let isSelected = selectedAssets.contains(where: { $0.localIdentifier == asset.localIdentifier })
let selectionIndex = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier })
PhotoTile(
asset: asset,
imageManager: imageManager,
size: tileSize,
isSelected: isSelected,
selectionNumber: selectionIndex.map { $0 + 1 },
onTap: { handlePhotoTap(asset) },
onCircleTap: { toggleSelection(asset) }
)
.frame(height: tileSize)
}
}
}
// MARK: - Permission Views
private var requestingView: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.white)
Text("Requesting photo access...")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.6))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var permissionDeniedView: some View {
VStack(spacing: 16) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 40))
.foregroundStyle(.white.opacity(0.4))
Text("Photo Access Required")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
Text("Enable photo access in Settings to send images.")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Logic
private func requestPhotoAccess() async {
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
await MainActor.run {
authorizationStatus = status
if status == .authorized || status == .limited {
loadAssets()
}
}
}
private func loadAssets() {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.fetchLimit = 500
let result = PHAsset.fetchAssets(with: .image, options: options)
var fetched: [PHAsset] = []
result.enumerateObjects { asset, _, _ in
fetched.append(asset)
}
assets = fetched
}
/// Handles tap on the photo body (not the selection circle).
/// Always opens preview selection is handled by the circle tap only.
private func handlePhotoTap(_ asset: PHAsset) {
onPhotoPreview?(asset)
}
private func toggleSelection(_ asset: PHAsset) {
if let index = selectedAssets.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) {
selectedAssets.remove(at: index)
} else if selectedAssets.count < maxSelection {
selectedAssets.append(asset)
}
}
}
// MARK: - CameraPreviewTile
/// Camera tile with live AVCaptureSession preview.
///
/// Figma: col 1, rows 12 in attachment grid. Shows live rear camera feed
/// with a camera icon overlay in the top-right corner.
/// Tapping opens full-screen UIImagePickerController for capture.
private struct CameraPreviewTile: View {
let width: CGFloat
let height: CGFloat
let onTap: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
// Live camera preview (or placeholder on simulator)
CameraPreviewView()
.frame(width: width, height: height)
.clipped()
// Camera icon overlay (Figma: top-right)
Image(systemName: "camera.fill")
.font(.system(size: 18))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
.padding(8)
}
.frame(width: width, height: height)
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
}
}
// MARK: - PhotoTile
/// Single photo tile in the grid with async thumbnail loading and selection circle.
///
/// Figma: square aspect ratio, selection circle (22pt, white border 1.5pt) in
/// top-right corner with 6pt padding. Selected: blue #008BFF filled circle
/// with white number.
///
/// Two tap targets:
/// - Photo body (`onTap`): selects if unselected, opens preview if selected
/// - Selection circle (`onCircleTap`): always toggles selection (deselects if selected)
private struct PhotoTile: View {
let asset: PHAsset
let imageManager: PHCachingImageManager
let size: CGFloat
let isSelected: Bool
let selectionNumber: Int?
let onTap: () -> Void
let onCircleTap: () -> Void
@State private var thumbnail: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
// Photo thumbnail body tap
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: size, height: size)
.clipped()
} else {
Color(white: 0.15)
.frame(width: size, height: size)
}
}
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
// Selection circle (Figma: 22pt, top-right, 6pt inset) circle tap
selectionCircle
.padding(6)
.contentShape(Circle())
.onTapGesture(perform: onCircleTap)
}
.task(id: asset.localIdentifier) {
loadThumbnail()
}
}
@ViewBuilder
private var selectionCircle: some View {
if isSelected {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
.frame(width: 22, height: 22)
if let number = selectionNumber {
Text("\(number)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
}
} else {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
.frame(width: 22, height: 22)
}
}
private func loadThumbnail() {
let scale = UIScreen.main.scale
let targetSize = CGSize(width: size * scale, height: size * scale)
let options = PHImageRequestOptions()
options.deliveryMode = .opportunistic
options.isNetworkAccessAllowed = true
options.resizeMode = .fast
imageManager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { image, _ in
if let image {
DispatchQueue.main.async {
self.thumbnail = image
}
}
}
}
}

View File

@@ -0,0 +1,361 @@
import Photos
import SwiftUI
// MARK: - PhotoPreviewView
/// Full-screen photo preview with editing toolbar and caption input.
///
/// Presented via `.fullScreenCover` with transparent background.
/// Dismissible by swipe-down drag gesture or back button.
/// On swipe-down, the presenting view (attachment panel) is visible behind.
///
/// Layout (Telegram-style):
/// ```
/// +----------------------------------+
/// | [radio button] |
/// | |
/// | [Full photo] |
/// | |
/// |-----------------------------------|
/// | Add a caption... [emoji] [done]| <- checkmark OUTSIDE bar
/// |-----------------------------------|
/// | [<] [crop] [Aa] [adj] [SD] [>] | <- toolbar row
/// +-----------------------------------+
/// ```
struct PhotoPreviewView: View {
let asset: PHAsset
let isSelected: Bool
let selectionNumber: Int?
@Binding var captionText: String
let onSend: (UIImage) -> Void
let onToggleSelect: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var fullImage: UIImage?
@State private var isLoading = true
@State private var dragOffset: CGFloat = 0
@FocusState private var isKeyboardFocused: Bool
private var isKeyboardVisible: Bool { isKeyboardFocused }
var body: some View {
ZStack {
// Semi-transparent background fades on swipe to reveal content behind
Color.black
.opacity(max(0, 1.0 - dragOffset / 400))
.ignoresSafeArea(.container)
VStack(spacing: 0) {
topBar
Spacer()
photoContent
Spacer()
bottomSection
.padding(.bottom, 12)
}
}
.offset(y: dragOffset)
.gesture(dismissDragGesture)
.preferredColorScheme(.dark)
.task {
await loadFullResolutionImage()
}
}
// MARK: - Drag to Dismiss
private var dismissDragGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 120 {
dismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
dragOffset = 0
}
}
}
}
// MARK: - Top Bar
private var topBar: some View {
HStack {
Spacer()
Button {
onToggleSelect()
} label: {
if isSelected {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
.frame(width: 28, height: 28)
if let number = selectionNumber {
Text("\(number)")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
} else {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
.frame(width: 28, height: 28)
}
}
.frame(width: 44, height: 44)
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
// MARK: - Photo Content
private var photoContent: some View {
Group {
if let fullImage {
Image(uiImage: fullImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 8)
} else if isLoading {
ProgressView()
.tint(.white)
.scaleEffect(1.2)
}
}
}
// MARK: - Bottom Section
@ViewBuilder
private var bottomSection: some View {
VStack(spacing: 12) {
captionInputBar
.padding(.horizontal, 16)
if !isKeyboardVisible {
toolbarRow
.padding(.horizontal, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
// MARK: - Caption Input Bar
/// Inactive: placeholder centered, no icons, no checkmark.
/// Active (keyboard): placeholder slides left, emoji inside bar, checkmark OUTSIDE bar.
private var captionInputBar: some View {
HStack(spacing: 8) {
// Glass capsule input bar
HStack(spacing: 0) {
ZStack {
// Custom animated placeholder (slides center left)
if captionText.isEmpty {
Text("Add a caption...")
.font(.system(size: 16))
.foregroundStyle(.white.opacity(0.4))
.frame(
maxWidth: .infinity,
alignment: isKeyboardVisible ? .leading : .center
)
.padding(.leading, isKeyboardVisible ? 16 : 0)
.allowsHitTesting(false)
}
// TextField (no built-in placeholder, always left-aligned)
TextField("", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.padding(.leading, 16)
.focused($isKeyboardFocused)
}
// Emoji icon only when keyboard active
if isKeyboardVisible {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.padding(.trailing, 12)
}
}
.padding(.horizontal, 3)
.frame(height: 42)
.background { captionBarBackground }
// Checkmark button OUTSIDE the bar (white circle + dark checkmark)
if isKeyboardVisible {
Button {
isKeyboardFocused = false
} label: {
ZStack {
Circle()
.fill(.white)
SingleCheckmarkShape()
.fill(Color.black.opacity(0.85))
.frame(width: 14, height: 10)
}
.frame(width: 42, height: 42)
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.25), value: isKeyboardVisible)
}
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous)
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
// MARK: - Toolbar Row (Telegram 1:1 match)
private var toolbarRow: some View {
HStack(spacing: 0) {
// Back button white outlined circle with chevron (matches screenshot)
Button {
dismiss()
} label: {
ZStack {
Circle()
.strokeBorder(Color.white, lineWidth: 1.5)
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
Spacer()
// Center editing tools
HStack(spacing: 20) {
toolbarIconButton(systemName: "crop")
toolbarTextButton
toolbarIconButton(systemName: "slider.horizontal.3")
qualityBadge
}
Spacer()
// Send button blue circle with arrow up (matches screenshot)
Button {
guard let image = fullImage else { return }
onSend(image)
} label: {
ZStack {
Circle()
.fill(Color(hex: 0x008BFF))
Image(systemName: "arrow.up")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 33, height: 33)
}
.buttonStyle(.plain)
}
}
private func toolbarIconButton(systemName: String) -> some View {
Button {
// Phase 1: non-functional
} label: {
Image(systemName: systemName)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// "Aa" text editing button.
private var toolbarTextButton: some View {
Button {
// Phase 1: non-functional
} label: {
Text("Aa")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
/// SD quality badge dark filled background with border (matches Telegram).
private var qualityBadge: some View {
Button {
// Phase 1: non-functional
} label: {
Text("SD")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(white: 0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.white.opacity(0.7), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
// MARK: - Image Loading
private func loadFullResolutionImage() async {
let manager = PHImageManager.default()
let image = await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
options.isSynchronous = false
manager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: options
) { image, info in
let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
if !isDegraded {
continuation.resume(returning: image)
}
}
}
await MainActor.run {
fullImage = image
isLoading = false
}
}
}

View File

@@ -20,7 +20,7 @@ final class ChatListNavigationState: ObservableObject {
/// times per frame" app freeze.
///
/// All `@Observable` access is isolated in dedicated child views:
/// - `DeviceVerificationBannersContainer` `ProtocolManager`
/// - `DeviceVerificationContentRouter` `ProtocolManager`
/// - `ToolbarStoriesAvatar` `AccountManager` / `SessionManager`
/// - `ChatListDialogContent` `DialogRepository` (via ViewModel)
struct ChatListView: View {
@@ -30,6 +30,7 @@ struct ChatListView: View {
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = ""
@State private var hasPinnedChats = false
@State private var showRequestChats = false
@FocusState private var isSearchFocused: Bool
var body: some View {
@@ -84,11 +85,20 @@ struct ChatListView: View {
}
)
}
.navigationDestination(isPresented: $showRequestChats) {
RequestChatsView(
viewModel: viewModel,
navigationState: navigationState
)
}
.onAppear {
isDetailPresented = !navigationState.path.isEmpty
isDetailPresented = !navigationState.path.isEmpty || showRequestChats
}
.onChange(of: navigationState.path) { _, newPath in
isDetailPresented = !newPath.isEmpty
isDetailPresented = !newPath.isEmpty || showRequestChats
}
.onChange(of: showRequestChats) { _, showing in
isDetailPresented = !navigationState.path.isEmpty || showing
}
}
.tint(RosettaColors.figmaBlue)
@@ -223,25 +233,21 @@ private extension ChatListView {
private extension ChatListView {
@ViewBuilder
var normalContent: some View {
VStack(spacing: 0) {
// Isolated view reads ProtocolManager (@Observable) without
// polluting ChatListView's observation scope.
DeviceVerificationBannersContainer()
// Isolated view reads DialogRepository (@Observable) via viewModel
// without polluting ChatListView's observation scope.
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState,
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
// Observation-isolated router reads ProtocolManager in its own scope.
// Shows full-screen DeviceConfirmView when awaiting approval,
// or normal chat list with optional device approval banner otherwise.
DeviceVerificationContentRouter(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: { showRequestChats = true },
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
}
)
}
}
)
}
}
@@ -340,7 +346,7 @@ private extension ChatListView {
// MARK: - Toolbar Background Modifier
private struct ChatListToolbarBackgroundModifier: ViewModifier {
struct ChatListToolbarBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
@@ -364,49 +370,38 @@ private struct ToolbarTitleView: View {
let isSyncing = SessionManager.shared.syncBatchInProgress
if state == .authenticated && isSyncing {
UpdatingDotsView()
} else {
let title: String = switch state {
case .authenticated: "Chats"
default: "Connecting..."
}
Text(title)
ToolbarStatusLabel(title: "Updating...")
} else if state == .authenticated {
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.25), value: state)
} else {
ToolbarStatusLabel(title: "Connecting...")
}
}
}
/// Desktop parity: "Updating..." with bouncing dots animation during sync.
private struct UpdatingDotsView: View {
@State private var activeDot = 0
private let dotCount = 3
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
/// Desktop parity: circular spinner + status text (Mantine `<Loader size={12}>` equivalent).
private struct ToolbarStatusLabel: View {
let title: String
@State private var isSpinning = false
var body: some View {
HStack(spacing: 1) {
Text("Updating")
HStack(spacing: 5) {
Circle()
.trim(from: 0.05, to: 0.75)
.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)
HStack(spacing: 2) {
ForEach(0..<dotCount, id: \.self) { index in
Text(".")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.offset(y: activeDot == index ? -3 : 0)
.animation(
.easeInOut(duration: 0.3),
value: activeDot
)
}
}
}
.onReceive(timer) { _ in
activeDot = (activeDot + 1) % dotCount
}
.onAppear { isSpinning = true }
}
}
@@ -453,24 +448,91 @@ private struct SyncAwareEmptyState: View {
}
}
// MARK: - Device Verification Banners (observation-isolated)
// MARK: - Request Chats Row (Telegram Archive style)
/// Shown at the top of the chat list when there are incoming message requests.
/// Matches ChatRowView sizing: height 78, pl-10, pr-16, avatar 62px.
private struct RequestChatsRow: View {
let count: Int
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 0) {
// Avatar: solid blue circle with white icon (62px)
ZStack {
Circle()
.fill(RosettaColors.primaryBlue)
.frame(width: 62, height: 62)
Image(systemName: "tray.and.arrow.down")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white)
}
.padding(.trailing, 10)
// Content section matches ChatRowView.contentSection layout
VStack(alignment: .leading, spacing: 0) {
// Title row
Text("Request Chats")
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
// Subtitle row (count)
Text(count == 1 ? "1 request" : "\(count) requests")
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63, alignment: .top)
.padding(.top, 8)
}
.padding(.leading, 10)
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Device Verification Content Router (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// During handshake, `connectionState` changes 4+ times rapidly this view
/// absorbs those re-renders instead of cascading them to the NavigationStack.
private struct DeviceVerificationBannersContainer: View {
///
/// Device confirmation (THIS device waiting) is handled by full-screen overlay
/// in MainTabView (DeviceConfirmOverlay). This router only handles the
/// approval banner (ANOTHER device requesting access on primary device).
private struct DeviceVerificationContentRouter: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var body: some View {
let proto = ProtocolManager.shared
if proto.connectionState == .deviceVerificationRequired {
DeviceWaitingApprovalBanner()
}
VStack(spacing: 0) {
// Banner for approving ANOTHER device (primary device side)
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
)
}
if let pendingDevice = proto.pendingDeviceVerification {
DeviceApprovalBanner(
device: pendingDevice,
onAccept: { proto.acceptDevice(pendingDevice.deviceId) },
onDecline: { proto.declineDevice(pendingDevice.deviceId) }
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange
)
}
}
@@ -483,27 +545,39 @@ private struct DeviceVerificationBannersContainer: View {
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
var body: some View {
let hasPinned = !viewModel.pinnedDialogs.isEmpty
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
} else {
dialogList
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
// Compute once avoids 3× filter (allModeDialogs allModePinned allModeUnpinned).
let allDialogs = viewModel.allModeDialogs
let pinned = allDialogs.filter(\.isPinned)
let unpinned = allDialogs.filter { !$0.isPinned }
let requestsCount = viewModel.requestsCount
Group {
if allDialogs.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
} else {
dialogList(
pinned: pinned,
unpinned: unpinned,
requestsCount: requestsCount
)
}
}
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
.onAppear {
onPinnedStateChange(!pinned.isEmpty)
}
}
private var dialogList: some View {
// MARK: - Dialog List
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -513,15 +587,25 @@ private struct ChatListDialogContent: View {
.listRowSeparator(.hidden)
}
} else {
if !viewModel.pinnedDialogs.isEmpty {
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
chatRow(dialog, isFirst: index == 0)
// Telegram-style "Request Chats" row at top (like Archived Chats)
if requestsCount > 0 {
RequestChatsRow(count: requestsCount, onTap: onShowRequests)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
}
if !pinned.isEmpty {
ForEach(pinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
.environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground)
.listRowBackground(RosettaColors.Dark.pinnedSectionBackground)
}
}
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
chatRow(dialog, isFirst: index == 0 && viewModel.pinnedDialogs.isEmpty)
ForEach(unpinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
}
}
@@ -533,19 +617,20 @@ private struct ChatListDialogContent: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
/// of SessionManager.syncBatchInProgress from this view's observation scope.
/// viewModel + navigationState passed as plain `let` (not @ObservedObject)
/// stable class references don't trigger row re-evaluation on parent re-render.
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isFirst: isFirst,
onTap: { navigationState.path.append(ChatRoute(dialog: dialog)) },
onDelete: { withAnimation { viewModel.deleteDialog(dialog) } },
onToggleMute: { viewModel.toggleMute(dialog) },
onTogglePin: { viewModel.togglePin(dialog) }
viewModel: viewModel,
navigationState: navigationState
)
}
}
@@ -555,18 +640,28 @@ private struct ChatListDialogContent: View {
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
private struct SyncAwareChatRow: View {
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
///
/// **Performance:** `viewModel` and `navigationState` are stored as plain `let`
/// (not @ObservedObject). Class references compare by pointer in SwiftUI's
/// memcmp-based view diffing stable pointers mean unchanged rows are NOT
/// re-evaluated when the parent body rebuilds. Closures are defined inline
/// (not passed from parent) to avoid non-diffable closure props that force
/// every row dirty on every parent re-render.
struct SyncAwareChatRow: View {
let dialog: Dialog
let isTyping: Bool
let isFirst: Bool
let onTap: () -> Void
let onDelete: () -> Void
let onToggleMute: () -> Void
let onTogglePin: () -> Void
let viewModel: ChatListViewModel
let navigationState: ChatListNavigationState
var body: some View {
let isSyncing = SessionManager.shared.syncBatchInProgress
Button(action: onTap) {
Button {
navigationState.path.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(
dialog: dialog,
isSyncing: isSyncing,
@@ -575,62 +670,41 @@ private struct SyncAwareChatRow: View {
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive, action: onDelete) {
Label("Delete", systemImage: "trash")
}
if !dialog.isSavedMessages {
Button(action: onToggleMute) {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(dialog.isMuted ? .green : .indigo)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button(action: onTogglePin) {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
}
}
// MARK: - Device Waiting Approval Banner
/// Shown when THIS device needs approval from another Rosetta device.
private struct DeviceWaitingApprovalBanner: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: "lock.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Waiting for device approval")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Open Rosetta on your other device and approve this login.")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation { viewModel.deleteDialog(dialog) }
} label: {
Label("Delete", systemImage: "trash")
}
Spacer(minLength: 0)
if !dialog.isSavedMessages {
Button {
viewModel.toggleMute(dialog)
} label: {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(dialog.isMuted ? .green : .indigo)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
viewModel.togglePin(dialog)
} label: {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(RosettaColors.warning.opacity(0.12))
}
}
// MARK: - Device Approval Banner
/// Shown on primary device when another device is requesting access.

View File

@@ -2,6 +2,11 @@ import Combine
import Foundation
import os
// MARK: - Dialogs Mode (All vs Requests)
/// Desktop parity: dialogs are split into "All" (iHaveSent) and "Requests" (only incoming).
enum DialogsMode: Hashable { case all, requests }
// MARK: - ChatListViewModel
@MainActor
@@ -12,6 +17,7 @@ final class ChatListViewModel: ObservableObject {
// MARK: - State
@Published var isLoading = false
@Published var dialogsMode: DialogsMode = .all
/// NOT @Published avoids 2× body re-renders per keystroke in ChatListView.
/// Local filtering uses `searchText` param directly in ChatListSearchContent.
var searchQuery = ""
@@ -35,16 +41,35 @@ final class ChatListViewModel: ObservableObject {
// MARK: - Computed (dialog list for ChatListDialogContent)
/// Full dialog list used by ChatListDialogContent which is only visible
/// when search is NOT active. Search filtering is done separately in
/// ChatListSearchContent using `searchText` parameter directly.
/// Filtered dialog list based on `dialogsMode`.
/// - `all`: dialogs where I have sent (+ Saved Messages + system accounts)
/// - `requests`: dialogs where only opponent has messaged me
var filteredDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs
let all = DialogRepository.shared.sortedDialogs
switch dialogsMode {
case .all:
return all.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
case .requests:
return all.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}
}
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
/// Number of request dialogs (incoming-only, not system, not self-chat).
var requestsCount: Int {
DialogRepository.shared.sortedDialogs.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}.count
}
var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int {
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
@@ -53,6 +78,27 @@ final class ChatListViewModel: ObservableObject {
var hasUnread: Bool { totalUnreadCount > 0 }
// MARK: - Per-mode dialogs (for TabView pages)
/// "All" dialogs conversations where I have sent (+ Saved Messages + system accounts).
/// Used by the All page in the swipeable TabView.
var allModeDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
}
var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) }
var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } }
/// "Requests" dialogs conversations where only opponent has messaged me.
/// Used by the Requests page in the swipeable TabView.
var requestsModeDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}
// MARK: - Actions
func setSearchQuery(_ query: String) {

View File

@@ -25,10 +25,6 @@ struct ChatRowView: View {
/// Desktop parity: show "typing..." instead of last message.
var isTyping: Bool = false
/// Desktop parity: recheck delivery timeout every 40s so clock error
/// transitions happen automatically without user scrolling.
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
@@ -48,7 +44,6 @@ struct ChatRowView: View {
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
.onReceive(recheckTimer) { now = $0 }
}
}
@@ -107,7 +102,7 @@ private extension ChatRowView {
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 12
size: 16
)
}
@@ -145,8 +140,9 @@ private extension ChatRowView {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
// Strip inline markdown markers for clean chat list preview
return dialog.lastMessage.replacingOccurrences(of: "**", with: "")
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
return EmojiParser.replaceShortcodes(in: cleaned)
}
}
@@ -198,38 +194,20 @@ private extension ChatRowView {
}
}
/// Desktop parity: clock only within 80s of send, then error.
/// Delivered single check, Read double checks.
private static let maxWaitingSeconds: TimeInterval = 80
@ViewBuilder
var deliveryIcon: some View {
switch dialog.lastMessageDelivered {
case .waiting:
if isWithinWaitingWindow {
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} else {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
// Timer isolated to sub-view only .waiting rows create a timer.
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
case .delivered:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
SingleCheckmarkShape()
.fill(RosettaColors.Adaptive.textSecondary)
.frame(width: 14, height: 10.3)
case .read:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.overlay(alignment: .leading) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.offset(x: -4)
}
.padding(.trailing, 2)
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
@@ -237,12 +215,6 @@ private extension ChatRowView {
}
}
private var isWithinWaitingWindow: Bool {
guard dialog.lastMessageTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
return now.timeIntervalSince(sentDate) < Self.maxWaitingSeconds
}
var unreadBadge: some View {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
@@ -266,6 +238,37 @@ private extension ChatRowView {
}
}
// MARK: - Delivery Waiting Icon (timer-isolated)
/// Desktop parity: clock error after 80s. Timer only exists on rows with
/// `.waiting` delivery status all other rows have zero timer overhead.
private struct DeliveryWaitingIcon: View {
let sentTimestamp: Int64
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
private var isWithinWindow: Bool {
guard sentTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(sentTimestamp) / 1000)
return now.timeIntervalSince(sentDate) < 80
}
var body: some View {
Group {
if isWithinWindow {
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} else {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
.onReceive(recheckTimer) { now = $0 }
}
}
// MARK: - Time Formatting
private extension ChatRowView {

View File

@@ -0,0 +1,101 @@
import Lottie
import SwiftUI
/// Full-screen device confirmation overlay shown when THIS device
/// needs approval from another Rosetta device (desktop parity: DeviceConfirm.tsx).
///
/// Displayed as an overlay in MainTabView, covering nav bar, search, and tab bar.
struct DeviceConfirmView: View {
private let deviceName = UIDevice.current.name
var body: some View {
ZStack {
// Full black background covering everything
RosettaColors.Dark.background
.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Inbox animation (desktop parity: inbox.json)
LottieView(
animationName: "inbox",
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 140, height: 140)
Spacer().frame(height: 24)
// Title (desktop: fw:500, fz:18)
Text("Confirm new device")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Spacer().frame(height: 10)
// Description (desktop: fz:14, dimmed, centered, px:lg)
Text("To confirm this device, please check your first device attached to your account and approve the new device.")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
.padding(.horizontal, 16)
Spacer().frame(height: 24)
// Exit button (desktop: animated red gradient, fullWidth, radius xl)
Button(action: exitAccount) {
Text("Exit")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(DeviceConfirmExitButtonStyle())
.padding(.horizontal, 32)
Spacer()
// Footer with device name (desktop: fz:12, dimmed, bold device name)
Text("Confirm device **\(deviceName)** on your first device to loading your chats.")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.6))
.multilineTextAlignment(.center)
.frame(maxWidth: 300)
.padding(.bottom, 40)
}
}
}
private func exitAccount() {
ProtocolManager.shared.disconnect()
}
}
// MARK: - Exit Button Style (red glass capsule, desktop: animated gradient #e03131 #ff5656)
private struct DeviceConfirmExitButtonStyle: ButtonStyle {
private let fillColor = RosettaColors.error
func makeBody(configuration: Configuration) -> some View {
Group {
if #available(iOS 26, *) {
configuration.label
.background {
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.glassEffect(.regular, in: Capsule())
} else {
configuration.label
.background {
Capsule()
.fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85))
}
.clipShape(Capsule())
}
}
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}

View File

@@ -0,0 +1,124 @@
import Lottie
import SwiftUI
/// Screen showing incoming message requests opened from the "Request Chats"
/// row at the top of the main chat list (Telegram Archive style).
struct RequestChatsView: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@Environment(\.dismiss) private var dismiss
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
var body: some View {
Group {
if viewModel.requestsModeDialogs.isEmpty {
RequestsEmptyStateView()
} else {
List {
ForEach(Array(viewModel.requestsModeDialogs.enumerated()), id: \.element.id) { index, dialog in
requestRow(dialog, isFirst: index == 0)
}
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
}
}
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
backCapsuleLabel
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .principal) {
Text("Request Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
.modifier(ChatListToolbarBackgroundModifier())
.enableSwipeBack()
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
}
// MARK: - Capsule Back Button (matches ChatDetailView)
private var backCapsuleLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.background {
glassCapsule(strokeOpacity: 0.22, strokeColor: .white)
}
}
@ViewBuilder
private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View {
if #available(iOS 26.0, *) {
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
} else {
Capsule().fill(.thinMaterial)
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
}
}
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState
)
}
}
// MARK: - Requests Empty State
/// Shown when there are no incoming requests.
/// Design: folder Lottie + title + subtitle.
private struct RequestsEmptyStateView: View {
var body: some View {
VStack(spacing: 0) {
LottieView(animationName: "folder_empty", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No Requests")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("New message requests will appear here")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}

View File

@@ -31,26 +31,32 @@ struct MainTabView: View {
}
var body: some View {
Group {
if #available(iOS 26.0, *) {
systemTabView
} else {
legacyTabView
ZStack {
Group {
if #available(iOS 26.0, *) {
systemTabView
} else {
legacyTabView
}
}
}
.fullScreenCover(item: $addAccountScreen) { screen in
AuthCoordinator(
onAuthComplete: {
addAccountScreen = nil
// New account created end session and go to unlock for the new account
SessionManager.shared.endSession()
onLogout?()
},
onBackToUnlock: {
addAccountScreen = nil
},
initialScreen: screen
)
.fullScreenCover(item: $addAccountScreen) { screen in
AuthCoordinator(
onAuthComplete: {
addAccountScreen = nil
// New account created end session and go to unlock for the new account
SessionManager.shared.endSession()
onLogout?()
},
onBackToUnlock: {
addAccountScreen = nil
},
initialScreen: screen
)
}
// Full-screen device verification overlay (observation-isolated).
// Covers nav bar, search bar, and tab bar desktop parity.
DeviceConfirmOverlay()
}
}
@@ -276,3 +282,16 @@ struct PlaceholderTabView: View {
}
}
}
// MARK: - Device Confirm Overlay (observation-isolated)
/// Reads `ProtocolManager` in its own observation scope.
/// Shows full-screen `DeviceConfirmView` when this device awaits approval.
private struct DeviceConfirmOverlay: View {
var body: some View {
if ProtocolManager.shared.connectionState == .deviceVerificationRequired {
DeviceConfirmView()
.transition(.opacity)
}
}
}

View File

@@ -1,18 +1,10 @@
import SwiftUI
/// Updates screen Android parity.
/// Shows app version info, up-to-date status, and a "Check for Updates" button.
/// Updates screen desktop + Android parity.
/// Shows app version info, up-to-date status, release notes, and a "Check for Updates" button.
struct UpdatesView: View {
@Environment(\.dismiss) private var dismiss
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
@@ -20,6 +12,7 @@ struct UpdatesView: View {
versionCard
helpText
checkButton
releaseNotesSection
}
.padding(.horizontal, 16)
.padding(.top, 16)
@@ -84,13 +77,13 @@ struct UpdatesView: View {
private var versionCard: some View {
SettingsCard {
VStack(spacing: 0) {
versionRow(title: "Application Version", value: appVersion)
versionRow(title: "Application Version", value: ReleaseNotes.appVersion)
Divider()
.background(RosettaColors.Adaptive.divider)
.padding(.horizontal, 16)
versionRow(title: "Build Number", value: buildNumber)
versionRow(title: "Build Number", value: ReleaseNotes.buildNumber)
}
}
.padding(.top, 16)
@@ -137,4 +130,55 @@ struct UpdatesView: View {
.buttonStyle(RosettaPrimaryButtonStyle())
.padding(.top, 16)
}
// MARK: - Release Notes
/// Desktop parity: shows release notes for each version.
/// Desktop sends these as system messages from the "updates" account;
/// iOS displays them inline on the Updates settings page.
private var releaseNotesSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("What's New")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 4)
ForEach(ReleaseNotes.entries) { entry in
releaseNoteCard(entry)
}
}
.padding(.top, 28)
}
private func releaseNoteCard(_ entry: ReleaseNotes.Entry) -> some View {
SettingsCard {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.primaryBlue)
Text("Version \(entry.version)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.changes, id: \.self) { change in
HStack(alignment: .top, spacing: 8) {
Text("\u{2022}")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(change)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
.padding(16)
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,26 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
// Register notification category with CarPlay support.
let messageCategory = UNNotificationCategory(
identifier: "message",
actions: [],
intentIdentifiers: [],
options: [.allowInCarPlay]
)
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
// Clear caches on memory pressure to prevent system from killing the app.
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
AvatarRepository.shared.clearCache()
}
}
// Request notification permission
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
@@ -53,15 +73,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// MARK: - UNUserNotificationCenterDelegate
/// Handle foreground notifications suppress ALL when app is in foreground.
/// Android parity: `isAppInForeground` check suppresses everything.
/// Messages arrive in real-time via WebSocket, push is only for background.
/// Android parity: messages arrive via WebSocket in real-time, push is background-only.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) ->
Void
) {
// App is in foreground suppress all notifications (Android parity).
completionHandler([])
}