Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android

This commit is contained in:
2026-03-18 20:10:20 +05:00
parent 1f442e1298
commit 422b20702e
42 changed files with 2459 additions and 656 deletions

View File

@@ -7,20 +7,53 @@
objects = {
/* Begin PBXBuildFile section */
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; };
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
proxyType = 1;
remoteGlobalIDString = E47730762E9823BA2D02A197;
remoteInfo = RosettaNotificationService;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
853F29642F4B50410092AD05 /* Rosetta */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Rosetta;
sourceTree = "<group>";
};
@@ -39,14 +72,32 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C595701A2879A2FD49DDEF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
32A246700D4A2618B3F81039 /* iOS */ = {
isa = PBXGroup;
children = (
272B862BE4D99E7DD751CC3E /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
853F29592F4B50410092AD05 = {
isa = PBXGroup;
children = (
853F29642F4B50410092AD05 /* Rosetta */,
853F29632F4B50410092AD05 /* Products */,
95676C1A4D239B1FF9E73782 /* Frameworks */,
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
);
sourceTree = "<group>";
};
@@ -54,10 +105,29 @@
isa = PBXGroup;
children = (
853F29622F4B50410092AD05 /* Rosetta.app */,
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
);
name = Products;
sourceTree = "<group>";
};
95676C1A4D239B1FF9E73782 /* Frameworks */ = {
isa = PBXGroup;
children = (
32A246700D4A2618B3F81039 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */ = {
isa = PBXGroup;
children = (
0F43A41D5496A62870E307FC /* NotificationService.swift */,
93685A4F330DCD1B63EF121F /* Info.plist */,
);
name = RosettaNotificationService;
path = RosettaNotificationService;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -68,10 +138,12 @@
853F295E2F4B50410092AD05 /* Sources */,
853F295F2F4B50410092AD05 /* Frameworks */,
853F29602F4B50410092AD05 /* Resources */,
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
3323872B02212359E2291EE8 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
853F29642F4B50410092AD05 /* Rosetta */,
@@ -88,6 +160,23 @@
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
productType = "com.apple.product-type.application";
};
E47730762E9823BA2D02A197 /* RosettaNotificationService */ = {
isa = PBXNativeTarget;
buildConfigurationList = B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */;
buildPhases = (
A624149985830F8CA8C2E52D /* Sources */,
B2C595701A2879A2FD49DDEF /* Frameworks */,
F9F9B9BDE87DB35631992F35 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = RosettaNotificationService;
productName = RosettaNotificationService;
productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -101,6 +190,9 @@
853F29612F4B50410092AD05 = {
CreatedOnToolsVersion = 26.2;
};
E47730762E9823BA2D02A197 = {
CreatedOnToolsVersion = 26.2;
};
};
};
buildConfigurationList = 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */;
@@ -123,6 +215,7 @@
projectRoot = "";
targets = (
853F29612F4B50410092AD05 /* Rosetta */,
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
);
};
/* End PBXProject section */
@@ -135,6 +228,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F9F9B9BDE87DB35631992F35 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -145,9 +245,54 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A624149985830F8CA8C2E52D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
3323872B02212359E2291EE8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = RosettaNotificationService;
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
0140D6320A9CF4B5E933E0B1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
853F296B2F4B50420092AD05 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -275,7 +420,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -291,7 +436,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.6;
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -314,7 +459,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -330,7 +475,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.6;
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -345,6 +490,35 @@
};
name = Release;
};
93E51266ED50ED634DDBB900 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -366,6 +540,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
93E51266ED50ED634DDBB900 /* Release */,
0140D6320A9CF4B5E933E0B1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>

View File

@@ -10,8 +10,15 @@ final class DialogRepository {
static let shared = DialogRepository()
private(set) var dialogs: [String: Dialog] = [:] {
didSet { _sortedDialogsCache = nil }
didSet {
_sortedDialogsCache = nil
dialogsVersion &+= 1
}
}
/// Monotonic counter incremented on every `dialogs` mutation.
/// Used by ChatListViewModel to avoid redundant partition recomputation.
@ObservationIgnored private(set) var dialogsVersion: Int = 0
private var currentAccount: String = ""
private var storagePassword: String = ""
private var persistTask: Task<Void, Never>?
@@ -81,6 +88,7 @@ final class DialogRepository {
)
_sortedKeysCache = nil
updateAppBadge()
syncMutedKeysToDefaults()
}
func reset(clearPersisted: Bool = false) {
@@ -91,6 +99,7 @@ final class DialogRepository {
storagePassword = ""
UNUserNotificationCenter.current().setBadgeCount(0)
UserDefaults.standard.set(0, forKey: "app_badge_count")
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count")
guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount
@@ -146,14 +155,14 @@ final class DialogRepository {
)
// Desktop parity: constructLastMessageTextByAttachments() returns
// "Photo"/"Avatar"/"File" for attachment-only messages.
// "Photo"/"Avatar"/"File"/"Forwarded message" 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
case .messages: dialog.lastMessage = "Forwarded message"
}
} else {
dialog.lastMessage = decryptedText
@@ -165,13 +174,12 @@ final class DialogRepository {
if fromMe {
dialog.iHaveSent = true
} else {
// Only increment unread count when:
// 1. The message is genuinely new (not a dedup hit from sync re-processing)
// 2. The user is NOT currently viewing this dialog
// Desktop parity: desktop computes unread from `SELECT COUNT(*) WHERE read = 0`,
// so duplicates never inflate the count. iOS uses an incremental counter,
// so we must guard against re-incrementing for known messages.
if isNewMessage && !MessageRepository.shared.isDialogActive(opponentKey) {
// Only increment unread count for REAL-TIME messages (not sync).
// During sync, messages may already be read on another device but arrive
// as "new" to iOS. Incrementing here inflates the badge (e.g., 11 4 0).
// Android parity: Android recalculates unread from DB after every message
// via COUNT(*) WHERE read=0. iOS defers to reconcileUnreadCounts() at sync end.
if isNewMessage && !fromSync && !MessageRepository.shared.isDialogActive(opponentKey) {
dialog.unreadCount += 1
}
}
@@ -341,7 +349,7 @@ final class DialogRepository {
case .image: dialog.lastMessage = "Photo"
case .file: dialog.lastMessage = "File"
case .avatar: dialog.lastMessage = "Avatar"
default: dialog.lastMessage = lastMsg.text
case .messages: dialog.lastMessage = "Forwarded message"
}
} else {
dialog.lastMessage = lastMsg.text
@@ -457,9 +465,19 @@ final class DialogRepository {
guard var dialog = dialogs[opponentKey] else { return }
dialog.isMuted.toggle()
dialogs[opponentKey] = dialog
syncMutedKeysToDefaults()
schedulePersist()
}
/// Sync muted chat keys to shared App Group UserDefaults.
/// Background push handler reads this to skip notifications for muted chats
/// without needing MainActor access to DialogRepository.
private func syncMutedKeysToDefaults() {
let mutedKeys = dialogs.values.filter(\.isMuted).map(\.opponentKey)
UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys")
}
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
raw < 1_000_000_000_000 ? raw * 1000 : raw
}
@@ -485,9 +503,15 @@ final class DialogRepository {
}
/// Update app icon badge with total unread message count.
/// Writes to shared App Group UserDefaults so the Notification Service Extension
/// can read the current count and increment it when the app is terminated.
private func updateAppBadge() {
let total = dialogs.values.reduce(0) { $0 + $1.unreadCount }
let total = dialogs.values.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount }
UNUserNotificationCenter.current().setBadgeCount(total)
// Shared storage NSE reads this to increment badge when app is killed.
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
shared?.set(total, forKey: "app_badge_count")
// Keep standard defaults in sync for backward compat.
UserDefaults.standard.set(total, forKey: "app_badge_count")
}

View File

@@ -96,6 +96,14 @@ final class MessageRepository: ObservableObject {
messagesByDialog[dialogKey]?.last?.id == messageId
}
/// Android parity: returns the timestamp of the latest incoming message in a dialog.
/// Used for read receipt dedup only send if `latestIncomingTs > lastReadReceiptTimestamp`.
func latestIncomingTimestamp(for dialogKey: String, myPublicKey: String) -> Int64? {
messagesByDialog[dialogKey]?
.last { $0.fromPublicKey == dialogKey || $0.fromPublicKey != myPublicKey }?
.timestamp
}
/// Whether the user is currently viewing any chat.
var hasActiveDialog: Bool {
!activeDialogs.isEmpty
@@ -105,6 +113,12 @@ final class MessageRepository: ObservableObject {
activeDialogs.contains(dialogKey)
}
/// All currently active dialog keys (read-only snapshot).
/// Android parity: used to re-mark messages as read on idleactive transition.
var activeDialogKeys: Set<String> {
activeDialogs
}
func setDialogActive(_ dialogKey: String, isActive: Bool) {
if isActive {
activeDialogs.insert(dialogKey)
@@ -184,6 +198,12 @@ final class MessageRepository: ObservableObject {
}
}
/// Returns the current delivery status for a message, or nil if not found.
func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? {
guard let dialogKey = messageToDialog[messageId] else { return nil }
return messagesByDialog[dialogKey]?.first(where: { $0.id == messageId })?.deliveryStatus
}
func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
guard let dialogKey = messageToDialog[messageId] else { return }
updateMessages(for: dialogKey) { messages in

View File

@@ -105,14 +105,39 @@ final class ProtocolManager: @unchecked Sendable {
}
/// Verify connection health after returning from background.
/// Always force reconnect after background, the socket is likely dead
/// and a 2s ping timeout just delays the inevitable.
/// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
/// If pong arrives, connection is alive no reconnect needed.
/// If ping fails or times out (500ms), force full reconnect.
func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// Don't interrupt active handshake
if connectionState == .handshaking { return }
// Fast path: if authenticated, try ping first before tearing down.
if connectionState == .authenticated, client.isConnected {
Self.logger.info("Foreground — ping check")
client.sendPing { [weak self] error in
guard let self else { return }
if error == nil {
// Pong received connection alive, send heartbeat to keep it fresh.
Self.logger.info("Foreground ping OK — connection alive")
self.client.sendText("heartbeat")
return
}
// Ping failed connection dead, force reconnect.
Self.logger.info("Foreground ping failed — force reconnecting")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
}
return
}
// Not authenticated force reconnect immediately.
Self.logger.info("Foreground reconnect — force reconnecting")
handshakeComplete = false
heartbeatTask?.cancel()

View File

@@ -31,7 +31,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
override init() {
super.init()
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
// Don't wait for connectivity fail fast so NWPathMonitor can trigger
// instant reconnect when network becomes available.
config.waitsForConnectivity = false
config.timeoutIntervalForRequest = 10
config.timeoutIntervalForResource = 15
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
startNetworkMonitor()
}
@@ -91,6 +95,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
webSocketTask = nil
isConnected = false
disconnectHandledForCurrentSocket = false
// Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s.
reconnectAttempts = 0
Self.logger.info("Force reconnect triggered")
connect()
}
@@ -196,9 +202,19 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return }
// Android parity: exponential backoff 1s, 2s, 4s, 8s, 16s (cap).
// First attempt: reconnect immediately (0ms delay) for fastest recovery.
// Subsequent attempts: exponential backoff 1s, 2s, 4s, 8s, 16s (cap).
reconnectAttempts += 1
let exponent = min(reconnectAttempts - 1, 4)
if reconnectAttempts == 1 {
// Immediate retry no delay on first attempt.
Self.logger.info("Reconnecting immediately (attempt #1)...")
reconnectTask = Task { [weak self] in
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil
self.connect()
}
} else {
let exponent = min(reconnectAttempts - 2, 4)
let delayMs = min(1000 * (1 << exponent), 16000)
reconnectTask = Task { [weak self] in
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
@@ -208,4 +224,5 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
self.connect()
}
}
}
}

View File

@@ -77,6 +77,9 @@ final class TransportManager: @unchecked Sendable {
/// - id: Unique file identifier (used as filename in multipart).
/// - content: Raw file content to upload.
/// - Returns: Server-assigned tag for later download.
/// Android parity: retry with exponential backoff (1s, 2s, 4s) on upload failure.
private static let maxUploadRetries = 3
func uploadFile(id: String, content: Data) async throws -> String {
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
throw TransportError.noTransportServer
@@ -102,6 +105,9 @@ final class TransportManager: @unchecked Sendable {
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
var lastError: Error = TransportError.invalidResponse
for attempt in 0..<Self.maxUploadRetries {
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -121,6 +127,16 @@ final class TransportManager: @unchecked Sendable {
Self.logger.info("Upload complete: id=\(id), tag=\(tag)")
return tag
} catch {
lastError = error
if attempt < Self.maxUploadRetries - 1 {
let delayMs = 1000 * (1 << attempt) // 1s, 2s, 4s
Self.logger.warning("Upload retry \(attempt + 1)/\(Self.maxUploadRetries) for \(id) in \(delayMs)ms: \(error.localizedDescription)")
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
}
}
}
throw lastError
}
// MARK: - Download

View File

@@ -28,8 +28,9 @@ final class SessionManager {
private var syncRequestInFlight = false
private var pendingIncomingMessages: [PacketMessage] = []
private var isProcessingIncomingMessages = false
private var pendingReadReceiptKeys: Set<String> = []
private var lastReadReceiptSentAt: [String: Int64] = [:]
/// Android parity: tracks the latest incoming message timestamp per dialog
/// for which a read receipt was already sent. Prevents redundant sends.
private var lastReadReceiptTimestamp: [String: Int64] = [:]
private var requestedUserInfoKeys: Set<String> = []
private var onlineSubscribedKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
@@ -38,17 +39,9 @@ final class SessionManager {
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
// MARK: - Idle Detection (Desktop parity)
// MARK: - Foreground Detection (Android parity)
/// Tracks the last user interaction timestamp for idle detection.
/// Desktop: messages marked unread if user idle > 20 seconds.
private var lastUserInteractionTime: Date = Date()
private var idleObserverToken: NSObjectProtocol?
/// Whether the user is considered idle (no interaction for `idleTimeoutForUnreadS`).
private var isUserIdle: Bool {
Date().timeIntervalSince(lastUserInteractionTime) > ProtocolConstants.idleTimeoutForUnreadS
}
private var foregroundObserverToken: NSObjectProtocol?
/// Whether the app is in the foreground.
private var isAppInForeground: Bool {
@@ -60,13 +53,22 @@ final class SessionManager {
private init() {
setupProtocolCallbacks()
setupUserInfoSearchHandler()
setupIdleDetection()
setupForegroundObserver()
}
/// Desktop parity: track user interaction to implement idle detection.
/// Call this from any user-facing interaction (tap, scroll, keyboard).
func recordUserInteraction() {
lastUserInteractionTime = Date()
/// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
/// Called on foreground resume. Android has no idle detection just re-marks on resume.
func markActiveDialogsAsRead() {
let activeKeys = MessageRepository.shared.activeDialogKeys
let myKey = currentPublicKey
for dialogKey in activeKeys {
guard !SystemAccounts.isSystemAccount(dialogKey) else { continue }
DialogRepository.shared.markAsRead(opponentKey: dialogKey)
MessageRepository.shared.markIncomingAsRead(
opponentKey: dialogKey, myPublicKey: myKey
)
sendReadReceipt(toPublicKey: dialogKey)
}
}
// MARK: - Session Lifecycle
@@ -218,10 +220,15 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string
// (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts)
// Must use UTF-8 decoding with replacement characters (U+FFFD) to match Node.js behavior.
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Attachment password: Android-style UTF-8 decoder (1:1 parity with Android).
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// aesChachaKey = Latin-1 encoding (matches desktop sync chain:
// Buffer.from(decryptedString, 'binary') takes low byte of each char).
// NEVER use WHATWG UTF-8 for aesChachaKey U+FFFD round-trips as 0xFD, not original byte.
guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
throw CryptoError.encryptionFailed
}
// 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.
@@ -229,7 +236,7 @@ final class SessionManager {
let avatarData = Data(dataURI.utf8)
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
avatarData,
password: latin1String
password: attachmentPassword
)
// Upload encrypted blob to transport server (desktop: uploadFile)
@@ -243,8 +250,8 @@ final class SessionManager {
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
let preview = "\(tag)::\(blurhash)"
// Build aesChachaKey (same as regular messages)
let aesChachaPayload = Data(latin1String.utf8)
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
let aesChachaPayload = Data(latin1ForSync.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload,
password: privKey
@@ -297,10 +304,15 @@ final class SessionManager {
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
}
// Saved Messages local only
// Saved Messages mark delivered locally but STILL send to server
// for cross-device avatar sync. Other devices receive via sync and
// update their local avatar cache.
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
// Send to server for multi-device sync (unlike text Saved Messages)
ProtocolManager.shared.sendPacket(packet)
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)")
return
}
@@ -342,9 +354,11 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Attachment password: WHATWG UTF-8 of raw key+nonce bytes.
// Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation.
let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
// Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity.
// Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in
// failed multi-byte sequences. Desktop WHATWG is slightly different but both work
// because Desktop tries both variants when decrypting.
let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
#if DEBUG
// Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS.
@@ -361,92 +375,81 @@ final class SessionManager {
Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
#endif
// Process each attachment: encrypt upload build metadata
var messageAttachments: [MessageAttachment] = []
// Phase 1: Encrypt all attachments sequentially (same password, CPU-bound).
struct EncryptedAttachment {
let original: PendingAttachment
let encryptedData: Data
let preview: String // partially built tag placeholder
}
var encryptedAttachments: [EncryptedAttachment] = []
for attachment in attachments {
// Build data URI (desktop: FileReader.readAsDataURL)
let dataURI = buildDataURI(attachment)
#if DEBUG
Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)")
#endif
// Encrypt blob with desktop-compatible encryption
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8),
password: attachmentPassword
)
#if DEBUG
// Log IV and ciphertext prefix for cross-platform verification.
let blobParts = encryptedBlob.components(separatedBy: ":")
if blobParts.count == 2, let ivData = Data(base64Encoded: blobParts[0]) {
Self.logger.debug("📎 blob IV: \(ivData.hexString), ct(\(blobParts[1].count) b64chars)")
}
// Self-test: decrypt with the SAME WHATWG password.
if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
encryptedBlob, password: attachmentPassword, requireCompression: true
), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true {
Self.logger.debug("📎 Blob self-test PASSED")
Self.logger.debug("📎 Blob self-test PASSED for \(attachment.id)")
} else {
Self.logger.error("📎 Blob self-test FAILED — blob may not decrypt on desktop")
Self.logger.error("📎 Blob self-test FAILED for \(attachment.id)")
}
#endif
// Upload to transport server
let uploadData = Data(encryptedBlob.utf8)
let uploadHash = CryptoManager.shared.sha256(uploadData)
let tag = try await TransportManager.shared.uploadFile(
id: attachment.id,
content: uploadData
)
#if DEBUG
Self.logger.debug("📎 Uploaded tag=\(tag), \(uploadData.count) bytes, sha256=\(uploadHash.hexString)")
// Transport round-trip verification: download the blob back and compare SHA256.
// This catches CDN corruption, partial uploads, and encoding issues.
do {
let verifyData = try await TransportManager.shared.downloadFile(tag: tag)
let verifyHash = CryptoManager.shared.sha256(verifyData)
if uploadHash == verifyHash {
Self.logger.debug("📎 Transport verify PASS: tag=\(tag), \(verifyData.count) bytes")
} else {
Self.logger.error("📎 ❌ TRANSPORT MISMATCH tag=\(tag): uploaded \(uploadData.count)b sha=\(uploadHash.hexString), downloaded \(verifyData.count)b sha=\(verifyHash.hexString)")
// Log first 100 bytes of each for comparison
let upPrefix = String(data: uploadData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
let downStr = String(data: verifyData.prefix(100), encoding: .utf8) ?? "<non-utf8>"
Self.logger.error("📎 ❌ Upload prefix: \(upPrefix)")
Self.logger.error("📎 ❌ Download prefix: \(downStr)")
}
} catch {
Self.logger.error("📎 ❌ Transport verify FAILED to download tag=\(tag): \(error)")
}
#endif
// Build preview string (format depends on type)
// Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files
let preview: String
// Pre-compute blurhash/preview prefix (everything except tag)
let previewSuffix: String
switch attachment.type {
case .image:
// Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3))
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
preview = "\(tag)::\(blurhash)"
previewSuffix = blurhash
case .file:
// Desktop: preview = "tag::size::filename"
preview = "\(tag)::\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
default:
preview = "\(tag)::"
previewSuffix = ""
}
messageAttachments.append(MessageAttachment(
id: attachment.id,
preview: preview,
blob: "", // Desktop parity: blob cleared after upload
type: attachment.type
encryptedAttachments.append(EncryptedAttachment(
original: attachment,
encryptedData: Data(encryptedBlob.utf8),
preview: previewSuffix
))
}
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)")
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
of: (Int, String).self
) { group in
for (index, item) in encryptedAttachments.enumerated() {
group.addTask {
let tag = try await TransportManager.shared.uploadFile(
id: item.original.id,
content: item.encryptedData
)
return (index, tag)
}
}
// Collect results, preserving original order.
var tags = [Int: String]()
for try await (index, tag) in group {
tags[index] = tag
}
return encryptedAttachments.enumerated().map { index, item in
let tag = tags[index] ?? ""
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
return MessageAttachment(
id: item.original.id,
preview: preview,
blob: "",
type: item.original.type
)
}
}
// Build aesChachaKey (for sync/backup same encoding as makeOutgoingPacket).
@@ -606,9 +609,9 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey
)
// Desktop parity: reply blob password = WHATWG UTF-8 of raw plainKeyAndNonce bytes.
// Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes.
// Same as attachment password derivation.
let replyPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self)
let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// Build the reply JSON blob
let replyJSON = try JSONEncoder().encode(replyMessages)
@@ -736,9 +739,47 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(packet)
}
/// Sends read receipt for direct dialog.
/// Android parity: sends read receipt for direct dialog.
/// Uses timestamp dedup (not time-based throttle) only sends if latest incoming
/// message timestamp > last sent read receipt timestamp. Retries once after 2s.
func sendReadReceipt(toPublicKey: String) {
sendReadReceipt(toPublicKey: toPublicKey, force: false)
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
let connState = ProtocolManager.shared.connectionState
guard normalized != currentPublicKey,
!normalized.isEmpty,
let hash = privateKeyHash,
connState == .authenticated
else {
return
}
// Android parity: timestamp dedup only send if latest incoming message
// timestamp is newer than what we already sent a receipt for.
let latestTs = MessageRepository.shared.latestIncomingTimestamp(
for: normalized, myPublicKey: currentPublicKey
) ?? 0
let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 0
if latestTs > 0, latestTs <= lastSentTs { return }
var packet = PacketRead()
packet.privateKey = hash
packet.fromPublicKey = currentPublicKey
packet.toPublicKey = normalized
ProtocolManager.shared.sendPacket(packet)
lastReadReceiptTimestamp[normalized] = latestTs
// Android parity: retry once after 2 seconds in case send failed silently.
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self,
ProtocolManager.shared.connectionState == .authenticated,
let hash = self.privateKeyHash else { return }
var retryPacket = PacketRead()
retryPacket.privateKey = hash
retryPacket.fromPublicKey = self.currentPublicKey
retryPacket.toPublicKey = normalized
ProtocolManager.shared.sendPacket(retryPacket)
}
}
/// Updates locally cached display name and username (called from ProfileEditView).
@@ -757,14 +798,12 @@ final class SessionManager {
syncRequestInFlight = false
pendingIncomingMessages.removeAll()
isProcessingIncomingMessages = false
pendingReadReceiptKeys.removeAll()
lastReadReceiptSentAt.removeAll()
lastReadReceiptTimestamp.removeAll()
requestedUserInfoKeys.removeAll()
pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
pendingOutgoingRetryTasks.removeAll()
pendingOutgoingPackets.removeAll()
pendingOutgoingAttempts.removeAll()
lastUserInteractionTime = Date()
isAuthenticated = false
currentPublicKey = ""
displayName = ""
@@ -809,6 +848,10 @@ final class SessionManager {
newTimestamp: deliveryTimestamp
)
self?.resolveOutgoingRetry(messageId: packet.messageId)
// Desktop parity (useDialogFiber.ts): update sync cursor on delivery ACK.
if let self, !self.syncBatchInProgress {
self.saveLastSyncTimestamp(deliveryTimestamp)
}
}
}
@@ -845,6 +888,14 @@ final class SessionManager {
opponentKey: opponentKey,
myPublicKey: ownKey
)
// Resolve pending retry timers for all messages to this opponent
// read receipt proves delivery, no need to retry further.
self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
// Desktop parity (useDialogFiber.ts): update sync cursor on read receipt.
if !self.syncBatchInProgress {
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
self.saveLastSyncTimestamp(nowMs)
}
}
}
@@ -964,12 +1015,16 @@ final class SessionManager {
await self.waitForInboundQueueToDrain()
let serverCursor = packet.timestamp
self.saveLastSyncTimestamp(serverCursor)
// Android parity: reconcile unread counts after each batch.
// Sync messages may have been read on another device PacketRead
// arrives during sync and marks them read, but incremental counters
// can drift. Reconcile from actual isRead state.
DialogRepository.shared.reconcileUnreadCounts()
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
self.requestSynchronize(cursor: serverCursor)
case .notNeeded:
self.syncBatchInProgress = false
self.flushPendingReadReceipts()
DialogRepository.shared.reconcileDeliveryStatuses()
DialogRepository.shared.reconcileUnreadCounts()
Self.logger.debug("SYNC NOT_NEEDED")
@@ -1228,13 +1283,12 @@ final class SessionManager {
// Sending 0x08 for every received message was causing a packet flood
// that triggered server RST disconnects.
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground.
// Desktop also skips system accounts and blocked users.
// Android parity: mark as read if dialog is active AND app is in foreground.
// Android has NO idle detection only isDialogActive flag (ON_RESUME/ON_PAUSE).
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
let idle = isUserIdle
let fg = isAppInForeground
let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem
let shouldMarkRead = dialogIsActive && fg && !isSystem
if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
@@ -1243,13 +1297,11 @@ final class SessionManager {
myPublicKey: myKey
)
if !fromMe && !wasKnownBefore {
if syncBatchInProgress {
pendingReadReceiptKeys.insert(opponentKey)
} else {
// Android/Desktop parity: send read receipt immediately,
// even during sync. 400ms debounce prevents flooding.
sendReadReceipt(toPublicKey: opponentKey)
}
}
}
// Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION.
// Sync cursor is updated once at BATCH_END with the server's timestamp.
@@ -1327,9 +1379,8 @@ final class SessionManager {
guard !syncRequestInFlight else { return }
syncRequestInFlight = true
// Desktop parity: pass server cursor as-is (seconds). NO normalization
// server uses seconds, converting to milliseconds made the server see a
// "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs.
// Server and all platforms use MILLISECONDS for sync cursors.
// Pass cursor as-is no normalization needed.
let lastSync = cursor ?? loadLastSyncTimestamp()
var packet = PacketSync()
@@ -1343,10 +1394,11 @@ final class SessionManager {
private func loadLastSyncTimestamp() -> Int64 {
guard !currentPublicKey.isEmpty else { return 0 }
let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey))
// Migration: old code normalized seconds milliseconds. If the stored value
// is in milliseconds (>= 1 trillion), convert back to seconds for server parity.
if stored >= 1_000_000_000_000 {
let corrected = stored / 1000
// Android parity (normalizeSyncTimestamp): all platforms store milliseconds.
// If stored value is in seconds (< 1 trillion), convert to milliseconds.
// Values >= 1 trillion are already in milliseconds return as-is.
if stored > 0, stored < 1_000_000_000_000 {
let corrected = stored * 1000
UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey)
return corrected
}
@@ -1355,7 +1407,7 @@ final class SessionManager {
private func saveLastSyncTimestamp(_ raw: Int64) {
guard !currentPublicKey.isEmpty else { return }
// Desktop parity: store server cursor as-is (seconds), no normalization.
// Store server cursor as-is (milliseconds). No normalization.
guard raw > 0 else { return }
let existing = loadLastSyncTimestamp()
guard raw > existing else { return }
@@ -1632,11 +1684,18 @@ final class SessionManager {
)
do {
// Use fresh timestamp for the packet so the 80s delivery timeout
// starts from NOW, not from the original send time.
// Without this, messages sent 70s before reconnect would expire
// in ~10s not enough time for the server to respond with 0x08.
// Also fixes server-side messageId dedup: fresh timestamp lets the
// server accept the retried message instead of silently dropping it.
let retryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
let packet = try makeOutgoingPacket(
text: text,
toPublicKey: message.toPublicKey,
messageId: message.id,
timestamp: message.timestamp,
timestamp: retryTimestamp,
privateKeyHex: privateKeyHex,
privateKeyHash: privateKeyHash
)
@@ -1655,42 +1714,6 @@ final class SessionManager {
}
}
private func flushPendingReadReceipts() {
guard !pendingReadReceiptKeys.isEmpty else { return }
let keys = pendingReadReceiptKeys
pendingReadReceiptKeys.removeAll()
for key in keys {
sendReadReceipt(toPublicKey: key, force: true)
}
}
private func sendReadReceipt(toPublicKey: String, force: Bool) {
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
let connState = ProtocolManager.shared.connectionState
guard normalized != currentPublicKey,
!normalized.isEmpty,
let hash = privateKeyHash,
connState == .authenticated
else {
return
}
let now = Int64(Date().timeIntervalSince1970 * 1000)
if !force {
let lastSent = lastReadReceiptSentAt[normalized] ?? 0
if now - lastSent < 400 {
return
}
}
lastReadReceiptSentAt[normalized] = now
var packet = PacketRead()
packet.privateKey = hash
packet.fromPublicKey = currentPublicKey
packet.toPublicKey = normalized
ProtocolManager.shared.sendPacket(packet)
}
private func registerOutgoingRetry(for packet: PacketMessage) {
let messageId = packet.messageId
pendingOutgoingRetryTasks[messageId]?.cancel()
@@ -1708,20 +1731,26 @@ final class SessionManager {
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0
// Check if message exceeded delivery timeout (80s) mark as error.
// Android parity: 80s × max(1, attachmentCount).
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let ageMs = nowMs - packet.timestamp
if ageMs >= self.maxOutgoingWaitingLifetimeMs {
Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error")
self.markOutgoingAsError(messageId: messageId, packet: packet)
let attachCount = max(1, Int64(packet.attachments.count))
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
if ageMs >= timeoutMs {
// Server didn't send 0x08, but we were authenticated and packets
// were sent successfully. Most likely cause: server deduplicates
// by messageId (original was delivered before disconnect, ACK lost).
// Mark as DELIVERED (optimistic) rather than ERROR.
Self.logger.info("Message \(messageId) — no ACK after \(ageMs)ms, marking as delivered (optimistic)")
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
return
}
guard attempts < self.maxOutgoingRetryAttempts else {
// Max retries exhausted for this connection session mark as error.
// The user sees the error icon immediately instead of a stuck clock.
Self.logger.warning("Message \(messageId) exhausted \(attempts) retries marking as error")
self.markOutgoingAsError(messageId: messageId, packet: packet)
// Max retries exhausted while connected same reasoning:
// packets were sent, no error from server, likely delivered.
Self.logger.info("Message \(messageId) — no ACK after \(attempts) retries, marking as delivered (optimistic)")
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
return
}
@@ -1741,10 +1770,48 @@ final class SessionManager {
}
}
/// Optimistically mark an outgoing message as delivered when no ACK was received
/// but packets were successfully sent while authenticated. Most common cause:
/// server deduplicates by messageId (original was delivered before disconnect).
private func markOutgoingAsDelivered(messageId: String, packet: PacketMessage) {
let fromMe = packet.fromPublicKey == currentPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
if currentStatus == .read {
// Already read don't downgrade to delivered.
resolveOutgoingRetry(messageId: messageId)
return
}
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
MessageRepository.shared.updateDeliveryStatus(
messageId: messageId,
status: .delivered,
newTimestamp: deliveryTimestamp
)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId,
opponentKey: opponentKey,
status: .delivered
)
resolveOutgoingRetry(messageId: messageId)
}
/// Mark an outgoing message as error in both repositories and clean up retry state.
/// Guards against downgrading .delivered/.read .error (e.g. if PacketDelivery/PacketRead
/// arrived while the retry timer was still running).
private func markOutgoingAsError(messageId: String, packet: PacketMessage) {
let fromMe = packet.fromPublicKey == currentPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
if currentStatus == .delivered || currentStatus == .read {
Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)")
resolveOutgoingRetry(messageId: messageId)
return
}
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(
messageId: messageId,
@@ -1761,6 +1828,18 @@ final class SessionManager {
pendingOutgoingAttempts.removeValue(forKey: messageId)
}
/// Resolve all pending outgoing retries for messages to a specific opponent.
/// Called when PacketRead (0x07) proves that messages were delivered.
private func resolveAllOutgoingRetries(toPublicKey: String) {
let matchingIds = pendingOutgoingPackets
.filter { $0.value.toPublicKey == toPublicKey }
.map { $0.key }
for messageId in matchingIds {
Self.logger.info("Resolving retry for \(messageId.prefix(8))… — read receipt received")
resolveOutgoingRetry(messageId: messageId)
}
}
// MARK: - Push Notifications
/// Stores the APNs device token received from AppDelegate.
@@ -1795,16 +1874,16 @@ final class SessionManager {
/// 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 lastKey = UserDefaults.standard.string(forKey: key) ?? ""
// Android parity: version + text hash re-sends if text changed within same version.
let noticeText = ReleaseNotes.releaseNoticeText
guard !noticeText.isEmpty else { return }
let currentKey = "\(ReleaseNotes.appVersion)_\(noticeText.hashValue)"
guard lastKey != currentKey else { return }
let now = Int64(Date().timeIntervalSince1970 * 1000)
let messageId = "release_notes_\(currentVersion)"
let messageId = "release_notes_\(ReleaseNotes.appVersion)"
// Create synthetic PacketMessage local-only, never sent to server.
var packet = PacketMessage()
@@ -1837,23 +1916,23 @@ final class SessionManager {
verified: 1
)
UserDefaults.standard.set(currentVersion, forKey: key)
Self.logger.info("Release notes v\(currentVersion) sent to Updates chat")
UserDefaults.standard.set(currentKey, forKey: key)
Self.logger.info("Release notes v\(ReleaseNotes.appVersion) sent to Updates chat")
}
// MARK: - Idle Detection Setup
// MARK: - Foreground Observer (Android parity)
private func setupIdleDetection() {
// Track app going to background/foreground to reset idle state + reconnect.
idleObserverToken = NotificationCenter.default.addObserver(
private func setupForegroundObserver() {
// Android parity: ON_RESUME markVisibleMessagesAsRead() + reconnect.
foregroundObserverToken = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.lastUserInteractionTime = Date()
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
self?.markActiveDialogsAsRead()
// Always verify connection on foreground don't trust cached state.
// reconnectIfNeeded() pings to check if "authenticated" connection is alive.
ProtocolManager.shared.reconnectIfNeeded()
}
}

View File

@@ -9,11 +9,13 @@ enum ProfileValidator {
enum DisplayNameError: LocalizedError {
case empty
case tooLong
case tooManyWords
var errorDescription: String? {
switch self {
case .empty: return "Name cannot be empty"
case .tooLong: return "Name cannot exceed 64 characters"
case .tooManyWords: return "Maximum two words (first and last name)"
}
}
}
@@ -22,9 +24,28 @@ enum ProfileValidator {
let trimmed = name.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return .empty }
if trimmed.count > 64 { return .tooLong }
// Maximum 2 words (first name + last name)
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
if words.count > 2 { return .tooManyWords }
return nil
}
/// Sanitizes display name input: collapses multiple spaces,
/// prevents more than one space (two words max).
static func sanitizeDisplayName(_ name: String) -> String {
// Collapse multiple consecutive spaces into one
let collapsed = name.replacingOccurrences(
of: "\\s{2,}", with: " ", options: .regularExpression
)
// If already has one space and user tries to add another, block it
let spaceCount = collapsed.filter { $0 == " " }.count
if spaceCount > 1 {
let parts = collapsed.split(separator: " ", maxSplits: 1)
return parts.joined(separator: " ")
}
return collapsed
}
// MARK: - Username
enum UsernameError: LocalizedError {

View File

@@ -9,16 +9,15 @@ enum ProtocolConstants {
static let maxMessagesLoad = 20
/// Maximum messages kept in memory per dialog.
static let messageMaxCached = 40
/// Android: unlimited in SQLite, 500 in-memory cache.
/// Desktop: MESSAGE_MAX_LOADED = 40 (but stores all in IndexedDB).
/// iOS: keep 200 in memory + persist to disk. Phase 2: SQLite + pagination.
static let messageMaxCached = 200
/// Outgoing message delivery timeout in seconds.
/// If a WAITING message is older than this, mark it as ERROR.
static let messageDeliveryTimeoutS: Int64 = 80
/// User idle timeout for marking incoming messages as unread (seconds).
/// Desktop: `TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20`.
static let idleTimeoutForUnreadS: TimeInterval = 20
/// Maximum number of file attachments per message.
static let maxAttachmentsInMessage = 5
@@ -41,9 +40,6 @@ enum ProtocolConstants {
/// Maximum number of outgoing message retry attempts.
static let maxOutgoingRetryAttempts = 3
/// Read receipt throttle interval in milliseconds.
static let readReceiptThrottleMs: Int64 = 400
/// Typing indicator throttle interval in milliseconds (desktop parity).
static let typingThrottleMs: Int64 = 3_000

View File

@@ -11,23 +11,17 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Синхронизация**
- Исправлена критическая ошибка, из-за которой синхронизация могла не запускаться после подключения к серверу
- Исправлена ошибка с курсором синхронизации — теперь курсор передаётся без преобразования, как в Desktop
- Исправлены ложные непрочитанные сообщения после синхронизации
**Уведомления**
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
**Мульти-девайс**
- Сообщения с другого устройства того же аккаунта теперь корректно показываются со статусом «доставлено»
**Фото и файлы**
Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
**UI (iOS 26)**
- Исправлен баг с размытием экрана чата при скролле
**Оптимизация производительности**
Улучшен FPS скролла и клавиатуры в длинных переписках.
**Swipe-to-Reply** — свайп влево по сообщению для ответа, как в Telegram
**Reply Quote** — обновлённый дизайн цитаты ответа. Если ответ на фото — миниатюра из BlurHash
**Навигация по цитате** — тап на цитату скроллит к оригиналу с плавной подсветкой
**Коллаж фотографий** — несколько фото в сообщении отображаются в сетке в стиле Telegram
**Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов
**Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия
**Исправления**
Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
"""
)
]

View File

@@ -26,10 +26,7 @@ struct GlassBackButton: View {
.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)
TelegramGlassCircle()
}
}
}

View File

@@ -40,12 +40,9 @@ struct GlassCard<Content: View>: View {
content()
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
} else {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.background {
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
}
}
}

View File

@@ -3,7 +3,7 @@ import SwiftUI
// MARK: - Glass Modifier
//
// iOS 26+: native .glassEffect API
// iOS < 26: .thinMaterial blur + stroke + shadow
// iOS < 26: Telegram-style glass (CABackdropLayer + gaussianBlur)
struct GlassModifier: ViewModifier {
let cornerRadius: CGFloat
@@ -20,9 +20,7 @@ struct GlassModifier: ViewModifier {
} else {
content
.background {
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
}
}
}
@@ -46,9 +44,7 @@ extension View {
}
} else {
background {
Capsule().fill(.thinMaterial)
.overlay { Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
TelegramGlassCapsule()
}
}
}
@@ -63,9 +59,7 @@ extension View {
}
} else {
background {
Circle().fill(.thinMaterial)
.overlay { Circle().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.10), radius: 16, y: 6)
TelegramGlassCircle()
}
}
}

View File

@@ -0,0 +1,274 @@
import SwiftUI
import UIKit
// MARK: - Environment Key: Glass Active
/// When false, TelegramGlassUIView removes its CABackdropLayer from the
/// layer tree, stopping real-time blur (zero GPU cost). When true, the
/// backdrop layer is re-inserted and blur resumes.
///
/// Usage: set `.environment(\.telegramGlassActive, false)` on views whose
/// glass effects should be frozen (e.g. hidden tabs in a ZStack pager).
private struct TelegramGlassActiveKey: EnvironmentKey {
static let defaultValue: Bool = true
}
extension EnvironmentValues {
var telegramGlassActive: Bool {
get { self[TelegramGlassActiveKey.self] }
set { self[TelegramGlassActiveKey.self] = newValue }
}
}
// MARK: - Telegram Glass (CABackdropLayer + CAFilter)
//
// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground.
// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay.
// iOS 26+: native UIGlassEffect(style: .regular).
/// SwiftUI wrapper for Telegram-style glass background.
/// Capsule shape with proper corner radius.
struct TelegramGlassCapsule: UIViewRepresentable {
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.updateGlass()
}
}
/// SwiftUI wrapper for Telegram-style glass circle.
struct TelegramGlassCircle: UIViewRepresentable {
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
view.isCircle = true
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.updateGlass()
}
}
/// SwiftUI wrapper for Telegram-style glass with custom corner radius.
struct TelegramGlassRoundedRect: UIViewRepresentable {
let cornerRadius: CGFloat
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
view.fixedCornerRadius = cornerRadius
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.fixedCornerRadius = cornerRadius
uiView.updateGlass()
}
}
// MARK: - UIKit Implementation
final class TelegramGlassUIView: UIView {
var isCircle = false
/// When set, overrides auto-calculated corner radius (height/2 for capsule, min/2 for circle).
var fixedCornerRadius: CGFloat?
/// When true, the CABackdropLayer is removed from the layer tree,
/// stopping real-time blur capture. Set to true for views inside
/// hidden tabs to eliminate GPU work from invisible glass effects.
var isFrozen: Bool = false {
didSet {
guard isFrozen != oldValue else { return }
if isFrozen {
backdropLayer?.removeFromSuperlayer()
} else if let backdrop = backdropLayer, backdrop.superlayer == nil {
clippingContainer.insertSublayer(backdrop, at: 0)
}
}
}
// Layers
private var backdropLayer: CALayer?
private let clippingContainer = CALayer()
private let foregroundLayer = CALayer()
private let borderLayer = CAShapeLayer()
// iOS 26+ native glass
private var nativeGlassView: UIVisualEffectView?
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = false
if #available(iOS 26.0, *) {
setupNativeGlass()
} else {
setupLegacyGlass()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - iOS 26+ (native UIGlassEffect)
@available(iOS 26.0, *)
private func setupNativeGlass() {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = false
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
let glassView = UIVisualEffectView(effect: effect)
glassView.layer.cornerCurve = .continuous
addSubview(glassView)
nativeGlassView = glassView
}
// MARK: - iOS < 26 (CABackdropLayer Telegram LegacyGlassView)
private func setupLegacyGlass() {
// Clipping container holds backdrop + foreground, clips to pill shape.
// Border is added to main layer OUTSIDE the clip so it's fully visible.
clippingContainer.masksToBounds = true
clippingContainer.cornerCurve = .circular
layer.addSublayer(clippingContainer)
// 1. CABackdropLayer blurs content behind this view
if let backdrop = Self.createBackdropLayer() {
backdrop.rasterizationScale = 1.0
Self.setBackdropScale(backdrop, scale: 1.0)
// gaussianBlur filter with radius 2.0 (Telegram .normal style)
if let blurFilter = Self.makeBlurFilter() {
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
backdrop.filters = [blurFilter]
}
clippingContainer.addSublayer(backdrop)
self.backdropLayer = backdrop
}
// 2. Foreground dark semi-transparent fill
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor
clippingContainer.addSublayer(foregroundLayer)
// 3. Border on main layer, NOT inside clipping container
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
borderLayer.lineWidth = 0.5
layer.addSublayer(borderLayer)
layer.cornerCurve = .circular
}
// MARK: - Layout
func updateGlass() {
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
let bounds = bounds
guard bounds.width > 0, bounds.height > 0 else { return }
let cornerRadius: CGFloat
if let fixed = fixedCornerRadius {
cornerRadius = fixed
} else if isCircle {
cornerRadius = min(bounds.width, bounds.height) / 2
} else {
cornerRadius = bounds.height / 2
}
if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.frame = bounds
glassView.layer.cornerRadius = cornerRadius
return
}
// Legacy layout
clippingContainer.frame = bounds
clippingContainer.cornerRadius = cornerRadius
backdropLayer?.frame = bounds
foregroundLayer.frame = bounds
foregroundLayer.cornerRadius = cornerRadius
let halfBorder = borderLayer.lineWidth / 2
let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder)
let borderRadius = max(0, cornerRadius - halfBorder)
let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius)
borderLayer.path = borderPath.cgPath
borderLayer.frame = bounds
}
// MARK: - Shadow (drawn as separate image Telegram parity)
/// Call from parent to add Telegram-exact shadow.
/// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt.
static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? {
let inset: CGFloat = 32
let innerInset: CGFloat = 0.5
let diameter = cornerRadius * 2
let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter)
let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: totalSize))
context.setFillColor(UIColor.black.cgColor)
context.setShadow(
offset: CGSize(width: 0, height: 1),
blur: 40.0,
color: UIColor(white: 0.0, alpha: 0.04).cgColor
)
let ellipseRect = CGRect(
x: inset + innerInset,
y: inset + innerInset,
width: totalSize.width - (inset + innerInset) * 2,
height: totalSize.height - (inset + innerInset) * 2
)
context.fillEllipse(in: ellipseRect)
// Punch out the center (shadow only, no fill)
context.setFillColor(UIColor.clear.cgColor)
context.setBlendMode(.copy)
context.fillEllipse(in: ellipseRect)
}
return image.stretchableImage(
withLeftCapWidth: Int(inset + cornerRadius),
topCapHeight: Int(inset + cornerRadius)
)
}
// MARK: - Private API Helpers (same approach as Telegram)
private static func createBackdropLayer() -> CALayer? {
let className = ["CA", "Backdrop", "Layer"].joined()
guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil }
return cls.init()
}
private static func setBackdropScale(_ layer: CALayer, scale: Double) {
let sel = NSSelectorFromString("setScale:")
guard layer.responds(to: sel) else { return }
layer.perform(sel, with: NSNumber(value: scale))
}
private static func makeBlurFilter() -> NSObject? {
let className = ["CA", "Filter"].joined()
guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil }
let sel = NSSelectorFromString("filterWithName:")
guard cls.responds(to: sel) else { return nil }
return cls.perform(sel, with: "gaussianBlur")?.takeUnretainedValue() as? NSObject
}
}

View File

@@ -19,6 +19,10 @@ struct AttachmentPanelView: View {
let onSend: ([PendingAttachment], String) -> Void
let onSendAvatar: () -> Void
/// When false, tapping avatar tab offers to set an avatar instead of sending.
var hasAvatar: Bool = true
/// Called when user has no avatar and taps the avatar tab navigate to profile.
var onSetAvatar: (() -> Void)?
@Environment(\.dismiss) private var dismiss
@@ -190,10 +194,7 @@ struct AttachmentPanelView: View {
.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)
TelegramGlassCircle()
}
}
@@ -324,10 +325,7 @@ struct AttachmentPanelView: View {
.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)
TelegramGlassRoundedRect(cornerRadius: 21)
}
}
@@ -358,13 +356,7 @@ struct AttachmentPanelView: View {
.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)
)
TelegramGlassCapsule()
}
}
@@ -375,9 +367,15 @@ struct AttachmentPanelView: View {
return Button {
if tab == .avatar {
// Avatar is an action tab immediately sends avatar + dismisses
if hasAvatar {
onSendAvatar()
dismiss()
} else {
// No avatar set offer to set one
dismiss()
onSetAvatar?()
}
return
} else {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
@@ -395,13 +393,17 @@ struct AttachmentPanelView: View {
.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()
)
.background {
if isSelected {
if #available(iOS 26, *) {
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
TelegramGlassCapsule()
}
}
}
}
.buttonStyle(.plain)
}

View File

@@ -24,8 +24,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
let previewShape: MessageBubbleShape
let readStatusText: String?
/// Called when user single-taps the bubble (e.g., to open fullscreen image).
var onTap: (() -> Void)?
/// Called when user single-taps the bubble. Receives tap location in the overlay's
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
var onTap: ((CGPoint) -> Void)?
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
@@ -41,6 +42,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
view.addInteraction(interaction)
// Single tap recognizer coexists with context menu's long press.
// ALL taps go through this (overlay UIView blocks SwiftUI gestures below).
// onTap handler in ChatDetailView routes to image viewer, file share,
// or posts .triggerAttachmentDownload notification for downloads.
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
view.addGestureRecognizer(tap)
@@ -62,7 +66,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
var actions: [BubbleContextAction]
var previewShape: MessageBubbleShape
var readStatusText: String?
var onTap: (() -> Void)?
var onTap: ((CGPoint) -> Void)?
var replyQuoteHeight: CGFloat = 0
var onReplyQuoteTap: (() -> Void)?
private var snapshotView: UIImageView?
@@ -85,7 +89,8 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
return
}
}
onTap?()
let location = recognizer.location(in: recognizer.view)
onTap?(location)
}
func contextMenuInteraction(

View File

@@ -80,14 +80,15 @@ struct ChatDetailView: View {
@State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false
@State private var showAttachmentPanel = false
@State private var showNoAvatarAlert = false
@State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false
@State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage?
@State private var messageToDelete: ChatMessage?
/// Attachment ID for full-screen image viewer (nil = dismissed).
@State private var fullScreenAttachmentId: String?
/// State for the multi-photo gallery viewer (nil = dismissed).
@State private var imageViewerState: ImageViewerState?
/// ID of message to scroll to (set when tapping a reply quote).
@State private var scrollToMessageId: String?
/// ID of message currently highlighted after scroll-to-reply navigation.
@@ -236,8 +237,6 @@ struct ChatDetailView: View {
// does NOT mutate DialogRepository, so ForEach won't rebuild.
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
clearDeliveredNotifications(for: route.publicKey)
// Reset idle timer user is actively viewing a chat.
SessionManager.shared.recordUserInteraction()
// Request user info (non-mutating, won't trigger list rebuild)
requestUserInfoIfNeeded()
// Delay DialogRepository mutations to let navigation transition complete.
@@ -261,6 +260,13 @@ struct ChatDetailView: View {
.onDisappear {
isViewActive = false
firstUnreadMessageId = nil
// Android parity: mark all messages as read when leaving dialog.
// Android's unmount callback does SQL UPDATE messages SET read = 1.
// Don't re-send read receipt it was already sent during the session.
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(
opponentKey: route.publicKey, myPublicKey: currentPublicKey
)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
// Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
@@ -281,14 +287,16 @@ struct ChatDetailView: View {
}
}
.fullScreenCover(isPresented: Binding(
get: { fullScreenAttachmentId != nil },
set: { if !$0 { fullScreenAttachmentId = nil } }
get: { imageViewerState != nil },
set: { if !$0 { imageViewerState = nil } }
)) {
FullScreenImageFromCache(
attachmentId: fullScreenAttachmentId ?? "",
onDismiss: { fullScreenAttachmentId = nil }
if let state = imageViewerState {
ImageGalleryViewer(
state: state,
onDismiss: { imageViewerState = nil }
)
}
}
.alert("Delete Message", isPresented: Binding(
get: { messageToDelete != nil },
set: { if !$0 { messageToDelete = nil } }
@@ -305,6 +313,30 @@ struct ChatDetailView: View {
} message: {
Text("Are you sure you want to delete this message? This action cannot be undone.")
}
.alert("No Avatar", isPresented: $showNoAvatarAlert) {
Button("OK", role: .cancel) {}
} message: {
Text("Set a profile photo in Settings to share it with contacts.")
}
.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()
},
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
onSetAvatar: {
showNoAvatarAlert = true
}
)
}
}
}
@@ -673,9 +705,10 @@ private extension ChatDetailView {
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
// PERF: use message.id as ForEach identity (stable).
// Integer indices shift on every insert, forcing full diff.
ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in
// PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
// Use message.id identity (stable) integer indices shift on insert.
ForEach(messages.reversed()) { message in
let index = messageIndex(for: message.id)
let position = bubblePosition(for: index)
messageRow(
message,
@@ -717,10 +750,14 @@ private extension ChatDetailView {
}
shouldScrollOnNextMessage = false
}
// Android parity: markVisibleMessagesAsRead when new incoming
// messages appear while chat is open, mark as read and send receipt.
// Safe to call repeatedly: markAsRead guards unreadCount > 0,
// sendReadReceipt deduplicates by timestamp.
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
markDialogAsRead()
}
.onChange(of: isInputFocused) { _, focused in
guard focused else { return }
SessionManager.shared.recordUserInteraction()
}
// Scroll-to-reply: navigate to the original message and highlight it briefly.
.onChange(of: scrollToMessageId) { _, targetId in
@@ -828,15 +865,19 @@ private extension ChatDetailView {
let messageText = message.text.isEmpty ? " " : message.text
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first
// Forward detection: text is empty/space, but has a MESSAGES attachment with data.
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil
if isForward, let reply = replyData {
forwardedMessageBubble(message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position)
} else {
VStack(alignment: .leading, spacing: 0) {
// Reply/forward quote (if present)
// Reply quote (if present, not a forward)
if let reply = replyData {
replyQuoteView(reply: reply, outgoing: outgoing)
}
// 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))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
@@ -852,10 +893,8 @@ private extension ChatDetailView {
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
// Tail protrusion space: the unified shape draws the tail in this padding area
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
// Single unified background: body + tail drawn in one fill (no seam)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
@@ -870,6 +909,161 @@ private extension ChatDetailView {
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
}
// MARK: - Forwarded Message Bubble (Telegram-style)
/// Renders a forwarded message with "Forwarded from" header, small avatar, sender name,
/// optional image/file previews, and the forwarded text as the main bubble content.
/// Android parity: `ForwardedMessagesBubble` + `ForwardedImagePreview` in ChatDetailComponents.kt.
@ViewBuilder
private func forwardedMessageBubble(
message: ChatMessage,
reply: ReplyMessageData,
outgoing: Bool,
hasTail: Bool,
maxBubbleWidth: CGFloat,
position: BubblePosition
) -> some View {
let senderName = senderDisplayName(for: reply.publicKey)
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
// Categorize forwarded attachments (inside the ReplyMessageData, NOT on message itself).
let imageAttachments = reply.attachments.filter { $0.type == 0 }
let fileAttachments = reply.attachments.filter { $0.type == 2 }
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
// Text: show as caption below visual attachments, or as main content if no attachments.
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
// Fallback label when no visual attachments and no text.
let fallbackText: String = {
if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id }
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
return " "
}()
let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
VStack(alignment: .leading, spacing: 0) {
// "Forwarded from" label
Text("Forwarded from")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.top, 6)
// Avatar + sender name
HStack(spacing: 6) {
AvatarView(
initials: senderInitials,
colorIndex: senderColorIndex,
size: 20,
image: senderAvatar
)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
.lineLimit(1)
}
.padding(.leading, 11)
.padding(.top, 3)
// Forwarded image attachments blurhash thumbnails (Android parity: ForwardedImagePreview).
ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in
forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
}
// Forwarded file attachments.
ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
forwardedFilePreview(attachment: att, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
}
// Caption text (if original message had text) or fallback label.
if hasCaption {
Text(parsedMarkdown(reply.message))
.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, 3)
.padding(.bottom, 5)
} else if !hasVisualAttachments {
// No attachments and no text show fallback.
Text(fallbackText)
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 3)
.padding(.bottom, 5)
} else {
// Visual attachments shown but no caption just add bottom padding for timestamp.
Spacer().frame(height: 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) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message)
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
/// Wrapper that delegates to `ForwardedImagePreviewCell` a proper View struct with
/// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`.
@ViewBuilder
private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View {
ForwardedImagePreviewCell(
attachment: attachment,
width: width,
outgoing: outgoing,
onTapCachedImage: { openImageViewer(attachmentId: attachment.id) }
)
}
/// File attachment preview inside a forwarded message bubble.
@ViewBuilder
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
let filename = attachment.id.isEmpty ? "File" : attachment.id
HStack(spacing: 8) {
Image(systemName: "doc.fill")
.font(.system(size: 20))
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
Text(filename)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
}
/// PERF: static cache for decoded reply blobs avoids JSON decode on every re-render.
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
@@ -880,7 +1074,10 @@ private extension ChatDetailView {
if let cached = Self.replyBlobCache[blob] { return cached }
guard let data = blob.data(using: .utf8) else { return nil }
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) }
if Self.replyBlobCache.count > 300 {
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
}
Self.replyBlobCache[blob] = result
return result
}
@@ -910,15 +1107,11 @@ private extension ChatDetailView {
.frame(width: 3)
.padding(.vertical, 4)
// Optional image thumbnail for media replies (32×32)
// PERF: uses static cache BlurHash decode is expensive (DCT transform).
if let hash = blurHash,
let image = Self.cachedBlurHash(hash, width: 32, height: 32) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
// Optional image thumbnail for media replies (32×32).
// Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache
// first (shows actual image), falling back to blurhash if not cached.
if let att = imageAttachment {
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.padding(.leading, 6)
}
@@ -948,9 +1141,14 @@ private extension ChatDetailView {
.padding(.bottom, 0)
}
/// PERF: static cache for sender display names avoids DialogRepository read per cell render.
/// DialogRepository is @Observable; reading `.dialogs[key]` in the body path creates observation
/// on the entire dictionary, causing re-render cascades on any dialog mutation.
@MainActor private static var senderNameCache: [String: String] = [:]
/// Resolves a public key to a display name for reply/forward quotes.
/// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the
/// body path uses route data instead. Only the current opponent is resolved.
/// Checks: current user "You", current opponent route.title, any known dialog title (cached).
/// Falls back to truncated public key if unknown.
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey {
return "You"
@@ -959,7 +1157,33 @@ private extension ChatDetailView {
if publicKey == route.publicKey {
return route.title.isEmpty ? String(publicKey.prefix(8)) + "" : route.title
}
return String(publicKey.prefix(8)) + ""
// PERF: cached lookup avoids creating @Observable tracking on DialogRepository.dialogs
// in the per-cell render path. Cache is populated once per contact, valid for session.
if let cached = Self.senderNameCache[publicKey] {
return cached
}
if let dialog = DialogRepository.shared.dialogs[publicKey],
!dialog.opponentTitle.isEmpty {
Self.senderNameCache[publicKey] = dialog.opponentTitle
return dialog.opponentTitle
}
let fallback = String(publicKey.prefix(8)) + ""
Self.senderNameCache[publicKey] = fallback
return fallback
}
/// PERF: single-pass partition of attachments into image vs non-image.
/// Avoids 3 separate .filter() calls per cell in @ViewBuilder context.
private static func partitionAttachments(
_ attachments: [MessageAttachment]
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
var images: [MessageAttachment] = []
var others: [MessageAttachment] = []
for att in attachments {
if att.type == .image { images.append(att) }
else { others.append(att) }
}
return (images, others)
}
/// Attachment message bubble: images/files with optional text caption.
@@ -978,8 +1202,10 @@ private extension ChatDetailView {
position: BubblePosition
) -> some View {
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
let imageAttachments = attachments.filter { $0.type == .image }
let otherAttachments = attachments.filter { $0.type != .image }
// PERF: single-pass partition instead of 3 separate .filter() calls per cell.
let partitioned = Self.partitionAttachments(attachments)
let imageAttachments = partitioned.images
let otherAttachments = partitioned.others
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) {
@@ -1051,10 +1277,24 @@ private extension ChatDetailView {
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !imageAttachments.isEmpty ? {
// Open the first image attachment in fullscreen viewer
if let firstImage = imageAttachments.first {
fullScreenAttachmentId = firstImage.id
onTap: !attachments.isEmpty ? { tapLocation in
// All taps go through the overlay (UIView blocks SwiftUI below).
// Route to the correct handler based on what was tapped.
if !imageAttachments.isEmpty {
let tappedId = imageAttachments.count == 1
? imageAttachments[0].id
: collageAttachmentId(at: tapLocation, attachments: imageAttachments, maxWidth: maxBubbleWidth)
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
openImageViewer(attachmentId: tappedId)
} else {
// Image not cached trigger download via notification.
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: tappedId)
}
} else {
// No images tap is on file/avatar area.
for att in otherAttachments {
NotificationCenter.default.post(name: .triggerAttachmentDownload, object: att.id)
}
}
} : nil
)
@@ -1117,11 +1357,14 @@ private extension ChatDetailView {
@MainActor private static var blurHashCache: [String: UIImage] = [:]
@MainActor
private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
let key = "\(hash)_\(width)x\(height)"
if let cached = blurHashCache[key] { return cached }
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) }
if blurHashCache.count > 300 {
let keysToRemove = Array(blurHashCache.keys.prefix(150))
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
}
blurHashCache[key] = image
return image
}
@@ -1155,8 +1398,10 @@ private extension ChatDetailView {
} else {
result = AttributedString(withEmoji)
}
if Self.markdownCache.count > 200 {
Self.markdownCache.removeAll(keepingCapacity: true)
// PERF: evict oldest half instead of clearing all preserves hot entries during scroll.
if Self.markdownCache.count > 500 {
let keysToRemove = Array(Self.markdownCache.keys.prefix(250))
for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) }
}
Self.markdownCache[text] = result
return result
@@ -1180,11 +1425,6 @@ private extension ChatDetailView {
var composer: some View {
VStack(spacing: 6) {
// Reply preview bar (Telegram-style)
if let replyMessage = replyingToMessage {
replyBar(for: replyMessage)
}
// Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
@@ -1214,20 +1454,11 @@ 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()
}
)
VStack(spacing: 0) {
// Reply preview bar inside the glass container
if let replyMessage = replyingToMessage {
replyBar(for: replyMessage)
}
HStack(alignment: .bottom, spacing: 0) {
@@ -1289,6 +1520,7 @@ private extension ChatDetailView {
)
}
}
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background { glass(shape: .rounded(21), strokeOpacity: 0.18) }
@@ -1328,6 +1560,26 @@ private extension ChatDetailView {
}
}
// MARK: - Message Index Lookup
/// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change.
/// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach.
@MainActor private static var messageIndexCache: [String: Int] = [:]
@MainActor private static var messageIndexCacheKey: String = ""
private func messageIndex(for messageId: String) -> Int {
// Rebuild cache if messages array changed (first+last+count fingerprint).
let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")"
if Self.messageIndexCacheKey != cacheKey {
Self.messageIndexCache.removeAll(keepingCapacity: true)
for (i, msg) in messages.enumerated() {
Self.messageIndexCache[msg.id] = i
}
Self.messageIndexCacheKey = cacheKey
}
return Self.messageIndexCache[messageId] ?? 0
}
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
@@ -1391,21 +1643,14 @@ private extension ChatDetailView {
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
}
} else {
// iOS < 26: frosted glass with stroke + shadow (Figma spec)
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
switch shape {
case .capsule:
Capsule().fill(.thinMaterial)
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
TelegramGlassCapsule()
case .circle:
Circle().fill(.thinMaterial)
.overlay { Circle().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
TelegramGlassCircle()
case let .rounded(radius):
let r = RoundedRectangle(cornerRadius: radius, style: .continuous)
r.fill(.thinMaterial)
.overlay { r.strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
TelegramGlassRoundedRect(cornerRadius: radius)
}
}
}
@@ -1642,6 +1887,65 @@ private extension ChatDetailView {
return actions
}
/// Determines which attachment was tapped in a photo collage based on tap location.
/// Mirrors the layout logic in PhotoCollageView (spacing=2, same proportions).
func collageAttachmentId(at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat) -> String {
let spacing: CGFloat = 2
let count = attachments.count
let x = point.x
let y = point.y
switch count {
case 2:
let half = (maxWidth - spacing) / 2
return attachments[x < half ? 0 : 1].id
case 3:
let rightWidth = maxWidth * 0.34
let leftWidth = maxWidth - spacing - rightWidth
let totalHeight = min(leftWidth * 1.1, 300)
let rightCellHeight = (totalHeight - spacing) / 2
if x < leftWidth {
return attachments[0].id
} else {
return attachments[y < rightCellHeight ? 1 : 2].id
}
case 4:
let half = (maxWidth - spacing) / 2
let cellHeight = min(half * 0.85, 150)
let row = y < cellHeight ? 0 : 1
let col = x < half ? 0 : 1
return attachments[row * 2 + col].id
case 5:
let topCellWidth = (maxWidth - spacing) / 2
let bottomCellWidth = (maxWidth - spacing * 2) / 3
let topHeight = min(topCellWidth * 0.85, 165)
if y < topHeight {
return attachments[x < topCellWidth ? 0 : 1].id
} else {
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
return attachments[2 + col].id
}
default:
return attachments[0].id
}
}
/// Collects all image attachment IDs from the current chat and opens the gallery.
func openImageViewer(attachmentId: String) {
var allImageIds: [String] = []
for message in messages {
for attachment in message.attachments where attachment.type == .image {
allImageIds.append(attachment.id)
}
}
let index = allImageIds.firstIndex(of: attachmentId) ?? 0
imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index)
}
func retryMessage(_ message: ChatMessage) {
let text = message.text
let toKey = message.toPublicKey
@@ -1661,26 +1965,44 @@ private extension ChatDetailView {
@ViewBuilder
func replyBar(for message: ChatMessage) -> some View {
// PERF: use route.title (non-observable) instead of dialog?.opponentTitle.
// Reading `dialog` here creates @Observable tracking on DialogRepository in the
// composer's render path, which is part of the main body.
let senderName = message.isFromMe(myPublicKey: currentPublicKey)
? "You"
: (dialog?.opponentTitle ?? route.title)
let previewText = message.text.isEmpty
? (message.attachments.isEmpty ? "" : "Attachment")
: message.text
: (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "" : route.title)
let previewText: String = {
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { return message.text }
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
if let file = message.attachments.first(where: { $0.type == .file }) {
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
if !message.attachments.isEmpty { return "Attachment" }
return ""
}()
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(RosettaColors.figmaBlue)
.frame(width: 3, height: 36)
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 0) {
Text("Reply to ")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.figmaBlue)
}
.lineLimit(1)
Text(previewText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.leading, 8)
@@ -1693,12 +2015,15 @@ private extension ChatDetailView {
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.frame(width: 30, height: 30)
}
}
.padding(.horizontal, 16)
.padding(.leading, 6)
.padding(.trailing, 4)
.padding(.top, 6)
.padding(.bottom, 4)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
@@ -1755,7 +2080,11 @@ private extension ChatDetailView {
func messageTime(_ timestamp: Int64) -> String {
if let cached = Self.timeCache[timestamp] { return cached }
let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000))
if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) }
// PERF: evict half instead of clearing all timestamps are reused during scroll.
if Self.timeCache.count > 500 {
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
}
Self.timeCache[timestamp] = result
return result
}
@@ -1823,8 +2152,6 @@ private extension ChatDetailView {
// 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 = []
@@ -2030,6 +2357,137 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier {
}
}
// MARK: - ForwardedImagePreviewCell
/// Proper View struct for forwarded image previews uses `@State` + `.task` so the image
/// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original.
/// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation
/// that never re-checks the cache.
private struct ForwardedImagePreviewCell: View {
let attachment: ReplyAttachmentData
let width: CGFloat
let outgoing: Bool
let onTapCachedImage: () -> Void
@State private var cachedImage: UIImage?
@State private var blurImage: UIImage?
private var imageHeight: CGFloat { min(width * 0.75, 200) }
var body: some View {
Group {
if let image = cachedImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: width, height: imageHeight)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8))
.contentShape(Rectangle())
.onTapGesture { onTapCachedImage() }
} else if let blur = blurImage {
Image(uiImage: blur)
.resizable()
.scaledToFill()
.frame(width: width, height: imageHeight)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
// No image at all show placeholder.
RoundedRectangle(cornerRadius: 8)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
.frame(width: width, height: imageHeight)
.overlay {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundStyle(
outgoing
? Color.white.opacity(0.3)
: RosettaColors.Adaptive.textSecondary.opacity(0.5)
)
}
}
}
.task {
// Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash").
if let hash = extractBlurHash(), !hash.isEmpty {
blurImage = ChatDetailView.cachedBlurHash(hash, width: 64, height: 64)
}
// Check cache immediately image may already be there.
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
// Retry: the original MessageImageView may still be downloading.
// Poll up to 5 times with 500ms intervals (2.5s total) covers most download durations.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
}
}
}
private func extractBlurHash() -> String? {
guard !attachment.preview.isEmpty else { return nil }
let parts = attachment.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : attachment.preview
return hash.isEmpty ? nil : hash
}
}
// MARK: - ReplyQuoteThumbnail
/// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual
/// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling
/// so the thumbnail updates when the original `MessageImageView` finishes downloading.
private struct ReplyQuoteThumbnail: View {
let attachment: ReplyAttachmentData
let blurHash: String?
/// Actual cached image (from AttachmentCache). Overrides blurhash when available.
@State private var cachedImage: UIImage?
var body: some View {
// Blurhash is computed synchronously (static cache) so it shows on the first frame.
// cachedImage overrides it when the real photo is available in AttachmentCache.
let image = cachedImage ?? blurHash.flatMap {
ChatDetailView.cachedBlurHash($0, width: 32, height: 32)
}
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.task {
// Check AttachmentCache for the actual downloaded photo.
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
// Retry image may be downloading in MessageImageView.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
}
}
}
}
#Preview {
NavigationStack {

View File

@@ -6,9 +6,11 @@ struct ForwardChatPickerView: View {
let onSelect: (ChatRoute) -> Void
@Environment(\.dismiss) private var dismiss
/// Android parity: system accounts (Updates, Safe) excluded from forward picker.
/// Saved Messages allowed (forward to self).
private var dialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}

View File

@@ -0,0 +1,169 @@
import SwiftUI
import UIKit
import Photos
// MARK: - Data Types
/// State for the image gallery viewer.
struct ImageViewerState: Equatable {
let attachmentIds: [String]
let initialIndex: Int
}
// MARK: - ImageGalleryViewer
/// Telegram-style multi-photo gallery viewer with horizontal paging.
/// Android parity: `ImageViewerScreen.kt` HorizontalPager, zoom-to-point,
/// velocity dismiss, page counter, share/save.
struct ImageGalleryViewer: View {
let state: ImageViewerState
let onDismiss: () -> Void
@State private var currentPage: Int
@State private var showControls = true
@State private var currentZoomScale: CGFloat = 1.0
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
self.state = state
self.onDismiss = onDismiss
self._currentPage = State(initialValue: state.initialIndex)
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
// Pager
TabView(selection: $currentPage) {
ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in
ZoomableImagePage(
attachmentId: attachmentId,
onDismiss: onDismiss,
showControls: $showControls,
currentScale: $currentZoomScale
)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.disabled(currentZoomScale > 1.05)
// Controls overlay
if showControls {
controlsOverlay
.transition(.opacity)
}
}
.statusBarHidden(true)
.animation(.easeInOut(duration: 0.2), value: showControls)
.onChange(of: currentPage) { _, newPage in
prefetchAdjacentImages(around: newPage)
}
.onAppear {
prefetchAdjacentImages(around: state.initialIndex)
}
}
// MARK: - Controls Overlay
private var controlsOverlay: some View {
VStack {
// Top bar: close + counter inside safe area to avoid notch/Dynamic Island overlap
HStack {
Button { onDismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
.padding(.leading, 16)
Spacer()
if state.attachmentIds.count > 1 {
Text("\(currentPage + 1) / \(state.attachmentIds.count)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
// Invisible spacer to balance the close button
Color.clear.frame(width: 36, height: 36)
.padding(.trailing, 16)
}
.padding(.top, 54)
Spacer()
// Bottom bar: share + save
HStack(spacing: 32) {
Button { shareCurrentImage() } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
Spacer()
Button { saveCurrentImage() } label: {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 34)
}
}
// MARK: - Actions
private func shareCurrentImage() {
guard currentPage < state.attachmentIds.count,
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
else { return }
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController {
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
activityVC.popoverPresentationController?.sourceView = presenter.view
activityVC.popoverPresentationController?.sourceRect = CGRect(
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
width: 0, height: 0
)
presenter.present(activityVC, animated: true)
}
}
private func saveCurrentImage() {
guard currentPage < state.attachmentIds.count,
let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage])
else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else { return }
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
// MARK: - Prefetch
private func prefetchAdjacentImages(around index: Int) {
for offset in [-1, 1] {
let i = index + offset
guard i >= 0, i < state.attachmentIds.count else { continue }
// Touch cache to warm it (loads from disk if needed)
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i])
}
}
}

View File

@@ -72,10 +72,6 @@ struct MessageAvatarView: View {
}
.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 {

View File

@@ -73,15 +73,19 @@ struct MessageFileView: View {
.padding(.vertical, 8)
.frame(width: 220)
.contentShape(Rectangle())
.onTapGesture {
.task {
checkCache()
}
// Download/share triggered by BubbleContextMenuOverlay tap notification.
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
if let id = notif.object as? String, id == attachment.id {
if isDownloaded, let url = cachedFileURL {
shareFile(url)
} else if !isDownloading {
downloadFile()
}
}
.task {
checkCache()
}
}

View File

@@ -62,14 +62,20 @@ struct MessageImageView: View {
} else {
placeholderView
.overlay { downloadArrowOverlay }
.onTapGesture { downloadImage() }
}
}
.task {
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO)
decodeBlurHash()
// PERF: load cached image FIRST skip expensive BlurHash DCT decode
// if the full image is already available.
loadFromCache()
if image == nil {
decodeBlurHash()
}
}
// Download triggered by BubbleContextMenuOverlay tap notification.
// Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire.
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
if let id = notif.object as? String, id == attachment.id, image == nil {
downloadImage()
}
}
@@ -184,10 +190,23 @@ struct MessageImageView: View {
/// Decodes the blurhash from the attachment preview string once and caches in @State.
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`.
/// PERF: static cache for decoded BlurHash images shared across all instances.
/// Avoids redundant DCT decode when the same attachment appears in multiple re-renders.
@MainActor private static var blurHashCache: [String: UIImage] = [:]
private func decodeBlurHash() {
let hash = extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return }
if let cached = Self.blurHashCache[hash] {
blurImage = cached
return
}
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) {
if Self.blurHashCache.count > 200 {
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100))
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) }
}
Self.blurHashCache[hash] = result
blurImage = result
}
}

View File

@@ -224,9 +224,7 @@ struct OpponentProfileView: 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)
TelegramGlassCapsule()
}
}
@@ -240,12 +238,7 @@ struct OpponentProfileView: View {
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)
}
TelegramGlassRoundedRect(cornerRadius: 14)
}
}
}

View File

@@ -228,10 +228,7 @@ struct PhotoPreviewView: View {
.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)
TelegramGlassRoundedRect(cornerRadius: 21)
}
}

View File

@@ -17,15 +17,19 @@ struct SwipeToReplyModifier: ViewModifier {
@State private var offset: CGFloat = 0
@State private var hasTriggeredHaptic = false
@State private var lockedAxis: SwipeAxis?
/// Start X in global coordinates reject if near left screen edge (back gesture zone).
@State private var gestureStartX: CGFloat?
private enum SwipeAxis { case horizontal, vertical }
/// Minimum drag distance to trigger reply action.
private let threshold: CGFloat = 50
private let threshold: CGFloat = 55
/// Offset where elastic resistance begins.
private let elasticCap: CGFloat = 80
private let elasticCap: CGFloat = 85
/// Reply icon circle diameter.
private let iconSize: CGFloat = 34
/// Ignore gestures starting within this distance from the left screen edge (iOS back gesture zone).
private let backGestureEdge: CGFloat = 40
func body(content: Content) -> some View {
content
@@ -65,18 +69,28 @@ struct SwipeToReplyModifier: ViewModifier {
// MARK: - Gesture
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 16, coordinateSpace: .local)
DragGesture(minimumDistance: 20, coordinateSpace: .global)
.onChanged { value in
// Record start position on first event.
if gestureStartX == nil {
gestureStartX = value.startLocation.x
}
// Reject gestures originating near the left screen edge (iOS back gesture zone).
if let startX = gestureStartX, startX < backGestureEdge {
return
}
// Lock axis on first significant movement to avoid
// interfering with vertical scroll or back-swipe navigation.
if lockedAxis == nil {
let dx = abs(value.translation.width)
let dy = abs(value.translation.height)
if dx > 16 || dy > 16 {
// Require clear horizontal dominance (2:1 ratio)
if dx > 20 || dy > 20 {
// Require clear horizontal dominance (2.5:1 ratio)
// AND must be leftward right swipe is back navigation.
let isLeftward = value.translation.width < 0
lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical
lockedAxis = (dx > dy * 2.5 && isLeftward) ? .horizontal : .vertical
}
}
@@ -101,7 +115,7 @@ struct SwipeToReplyModifier: ViewModifier {
// Haptic at threshold (once per gesture)
if abs(offset) >= threshold, !hasTriggeredHaptic {
hasTriggeredHaptic = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
}
}
.onEnded { _ in
@@ -111,6 +125,7 @@ struct SwipeToReplyModifier: ViewModifier {
}
lockedAxis = nil
hasTriggeredHaptic = false
gestureStartX = nil
if shouldReply {
onReply()
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
import UIKit
// MARK: - ZoomableImagePage
/// Single page in the image gallery viewer with centroid-based zoom.
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` pinch zoom to centroid,
/// double-tap to tap point, velocity-based dismiss, touch slop.
struct ZoomableImagePage: View {
let attachmentId: String
let onDismiss: () -> Void
@Binding var showControls: Bool
@Binding var currentScale: CGFloat
@State private var image: UIImage?
@State private var scale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var dismissOffset: CGFloat = 0
@State private var dismissStartTime: Date?
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 5.0
private let doubleTapScale: CGFloat = 2.5
private let dismissDistanceThreshold: CGFloat = 100
private let dismissVelocityThreshold: CGFloat = 800
private let touchSlop: CGFloat = 20
var body: some View {
GeometryReader { geometry in
ZStack {
// Background fade during dismiss
Color.black
.opacity(backgroundOpacity)
.ignoresSafeArea()
if let image {
imageContent(image, in: geometry)
} else {
placeholder
}
}
}
.task {
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}
.onChange(of: scale) { _, newValue in
currentScale = newValue
}
}
// MARK: - Image Content
@ViewBuilder
private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View {
let size = geometry.size
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(x: offset.width, y: offset.height + dismissOffset)
.gesture(doubleTapGesture(in: size))
.gesture(pinchGesture(in: size))
.gesture(dragGesture(in: size))
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
}
// MARK: - Placeholder
private var placeholder: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.white)
Text("Loading...")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
}
}
// MARK: - Background Opacity
private var backgroundOpacity: Double {
let progress = min(abs(dismissOffset) / 300, 1.0)
return 1.0 - progress * 0.6
}
// MARK: - Double Tap (zoom to tap point)
private func doubleTapGesture(in size: CGSize) -> some Gesture {
SpatialTapGesture(count: 2)
.onEnded { value in
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
if scale > 1.05 {
// Zoom out to 1x
scale = 1.0
offset = .zero
} else {
// Zoom in to tap point
let tapPoint = value.location
let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2)
scale = doubleTapScale
// Shift image so tap point ends up at screen center
offset = CGSize(
width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1),
height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1)
)
}
}
}
}
// MARK: - Pinch Gesture (zoom to centroid)
private func pinchGesture(in size: CGSize) -> some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale)
// MagnificationGesture doesn't provide centroid, so zoom to center.
// For true centroid zoom, we'd need UIKit gesture recognizers.
// This is acceptable most users don't notice centroid vs center on mobile.
scale = newScale
}
.onEnded { _ in
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
if scale < minScale {
scale = minScale
offset = .zero
}
clampOffset(in: size)
}
}
}
// MARK: - Drag Gesture (pan when zoomed, dismiss when not)
private func dragGesture(in size: CGSize) -> some Gesture {
DragGesture(minimumDistance: touchSlop)
.onChanged { value in
if scale > 1.05 {
// Zoomed: pan image
offset = CGSize(
width: value.translation.width,
height: value.translation.height
)
} else {
// Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe)
let dx = abs(value.translation.width)
let dy = abs(value.translation.height)
if dy > dx * 1.2 {
if dismissStartTime == nil {
dismissStartTime = Date()
}
dismissOffset = value.translation.height
}
}
}
.onEnded { value in
if scale > 1.05 {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
clampOffset(in: size)
}
} else {
// Calculate velocity for dismiss
let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3
let velocityY = abs(dismissOffset) / max(elapsed, 0.01)
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
onDismiss()
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
dismissOffset = 0
}
}
dismissStartTime = nil
}
}
}
// MARK: - Offset Clamping
private func clampOffset(in size: CGSize) {
guard scale > 1.0 else {
offset = .zero
return
}
let maxOffsetX = size.width * (scale - 1) / 2
let maxOffsetY = size.height * (scale - 1) / 2
offset = CGSize(
width: min(max(offset.width, -maxOffsetX), maxOffsetX),
height: min(max(offset.height, -maxOffsetY), maxOffsetY)
)
}
}

View File

@@ -325,7 +325,8 @@ private struct FavoriteContactsRowSearch: View {
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages
isSavedMessages: dialog.isSavedMessages,
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")

View File

@@ -564,14 +564,13 @@ private struct ChatListDialogContent: View {
@State private var typingDialogs: Set<String> = []
var body: some View {
// Compute once avoids 3× filter (allModeDialogs allModePinned allModeUnpinned).
let allDialogs = viewModel.allModeDialogs
let pinned = allDialogs.filter(\.isPinned)
let unpinned = allDialogs.filter { !$0.isPinned }
// Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
let pinned = viewModel.allModePinned
let unpinned = viewModel.allModeUnpinned
let requestsCount = viewModel.requestsCount
Group {
if allDialogs.isEmpty && !viewModel.isLoading {
if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState()
} else {
dialogList(
@@ -719,52 +718,44 @@ struct SyncAwareChatRow: View {
// MARK: - Device Approval Banner
/// Shown on primary device when another device is requesting access.
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.
/// Desktop: DeviceVerify.tsx height 65px, centered text (dimmed), two transparent buttons.
private struct DeviceApprovalBanner: View {
let device: DeviceEntry
let onAccept: () -> Void
let onDecline: () -> Void
@State private var showAcceptConfirmation = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.shield")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.error)
VStack(spacing: 8) {
Text("New login from \(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(.white.opacity(0.45))
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 2) {
Text("New device login detected")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("\(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
HStack(spacing: 24) {
Button("Accept") {
showAcceptConfirmation = true
}
Spacer(minLength: 0)
}
HStack(spacing: 12) {
Button(action: onAccept) {
Text("Yes, it's me")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
}
Button(action: onDecline) {
Text("No, it's not me!")
Button("Decline") {
onDecline()
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error)
.foregroundStyle(RosettaColors.error.opacity(0.8))
}
Spacer(minLength: 0)
}
.padding(.leading, 34)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.alert("Accept new device", isPresented: $showAcceptConfirmation) {
Button("Accept") { onAccept() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to accept this device? This will allow it to access your account.")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(RosettaColors.error.opacity(0.08))
}
}

View File

@@ -39,65 +39,70 @@ final class ChatListViewModel: ObservableObject {
}
// MARK: - Computed (dialog list for ChatListDialogContent)
// MARK: - Dialog partitions (single pass, cached per observation cycle)
private struct DialogPartition {
var allPinned: [Dialog] = []
var allUnpinned: [Dialog] = []
var requests: [Dialog] = []
var totalUnread: Int = 0
}
/// Cached partition computed once, reused by all properties until dialogs change.
private var _cachedPartition: DialogPartition?
private var _cachedPartitionVersion: Int = -1
private var partition: DialogPartition {
let repo = DialogRepository.shared
let currentVersion = repo.dialogsVersion
if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion {
return cached
}
var result = DialogPartition()
for dialog in repo.sortedDialogs {
let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey)
if isChat {
if dialog.isPinned {
result.allPinned.append(dialog)
} else {
result.allUnpinned.append(dialog)
}
} else {
result.requests.append(dialog)
}
if !dialog.isMuted {
result.totalUnread += dialog.unreadCount
}
}
_cachedPartition = result
_cachedPartitionVersion = currentVersion
return result
}
/// 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] {
let all = DialogRepository.shared.sortedDialogs
let p = partition
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)
}
case .all: return p.allPinned + p.allUnpinned
case .requests: return p.requests
}
}
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 pinnedDialogs: [Dialog] { partition.allPinned }
var unpinnedDialogs: [Dialog] { partition.allUnpinned }
var requestsCount: Int { partition.requests.count }
var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int {
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
var totalUnreadCount: Int { partition.totalUnread }
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 allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
var allModePinned: [Dialog] { partition.allPinned }
var allModeUnpinned: [Dialog] { partition.allUnpinned }
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)
}
}
var requestsModeDialogs: [Dialog] { partition.requests }
// MARK: - Actions

View File

@@ -57,7 +57,7 @@ private extension ChatRowView {
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages,
image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
}
}
@@ -132,6 +132,9 @@ private extension ChatRowView {
.frame(height: 41, alignment: .topLeading)
}
/// Static cache for emoji-parsed message text (avoids regex per row per render).
private static var messageTextCache: [String: String] = [:]
var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages {
@@ -140,9 +143,18 @@ private extension ChatRowView {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
if let cached = Self.messageTextCache[dialog.lastMessage] {
return cached
}
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
return EmojiParser.replaceShortcodes(in: cleaned)
let result = EmojiParser.replaceShortcodes(in: cleaned)
if Self.messageTextCache.count > 500 {
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
}
Self.messageTextCache[dialog.lastMessage] = result
return result
}
}
@@ -282,22 +294,37 @@ private extension ChatRowView {
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
}()
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
private static var timeStringCache: [Int64: String] = [:]
var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" }
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
return cached
}
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
let now = Date()
let calendar = Calendar.current
let result: String
if calendar.isDateInToday(date) {
return Self.timeFormatter.string(from: date)
result = Self.timeFormatter.string(from: date)
} else if calendar.isDateInYesterday(date) {
return "Yesterday"
result = "Yesterday"
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
return Self.dayFormatter.string(from: date)
result = Self.dayFormatter.string(from: date)
} else {
return Self.dateFormatter.string(from: date)
result = Self.dateFormatter.string(from: date)
}
if Self.timeStringCache.count > 500 {
let keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
}
Self.timeStringCache[dialog.lastMessageTimestamp] = result
return result
}
}

View File

@@ -76,9 +76,7 @@ struct RequestChatsView: 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)
TelegramGlassCapsule()
}
}

View File

@@ -106,11 +106,7 @@ struct MainTabView: View {
RosettaTabBar(
selectedTab: selectedTab,
onTabSelected: { tab in
activatedTabs.insert(tab)
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
withAnimation(.easeInOut(duration: 0.15)) {
selectedTab = tab
}
},
onSwipeStateChanged: { state in
if let state {
@@ -150,6 +146,7 @@ struct MainTabView: View {
tabView(for: tab)
.frame(width: width, height: availableSize.height)
.opacity(tabOpacity(for: tab))
.animation(.easeOut(duration: 0.12), value: selectedTab)
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil)
}
}

View File

@@ -92,13 +92,17 @@ private extension ProfileEditView {
VStack(spacing: 0) {
// Display Name field
HStack {
TextField("First Name", text: $displayName)
TextField("First Last", text: $displayName)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
.onChange(of: displayName) { _, newValue in
displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription
let sanitized = ProfileValidator.sanitizeDisplayName(newValue)
if sanitized != newValue {
displayName = sanitized
}
displayNameError = ProfileValidator.validateDisplayName(sanitized)?.errorDescription
}
if !displayName.isEmpty {

View File

@@ -41,17 +41,20 @@ struct SafetyView: View {
let publicKey = SessionManager.shared.currentPublicKey
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
// Clear persisted chat files before session ends
// Start fade overlay FIRST covers the screen before
// deleteAccount() triggers setActiveAccount(next) which
// would cause MainTabView .id() recreation.
onLogout?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
DialogRepository.shared.reset(clearPersisted: true)
MessageRepository.shared.reset(clearPersisted: true)
// Clear per-account UserDefaults entries
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)")
defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)")
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
SessionManager.shared.endSession()
try? AccountManager.shared.deleteAccount()
onLogout?()
}
}
} message: {
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")

View File

@@ -85,6 +85,11 @@ struct SettingsView: View {
let publicKey = SessionManager.shared.currentPublicKey
BiometricAuthManager.shared.clearAll(forAccount: publicKey)
AvatarRepository.shared.removeAvatar(publicKey: publicKey)
// Start fade overlay FIRST covers the screen before
// deleteAccount() triggers setActiveAccount(next) which
// would cause MainTabView .id() recreation.
onLogout?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
DialogRepository.shared.reset(clearPersisted: true)
MessageRepository.shared.reset(clearPersisted: true)
let defaults = UserDefaults.standard
@@ -93,7 +98,7 @@ struct SettingsView: View {
defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)")
SessionManager.shared.endSession()
try? AccountManager.shared.deleteAccount()
onLogout?()
}
}
} message: {
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.")
@@ -277,20 +282,18 @@ struct SettingsView: View {
guard isSaving else { return }
isSaving = false
if let code = ResultCode(rawValue: result.resultCode), code == .success {
// Server confirmed update local profile + avatar
if result.resultCode == ResultCode.usernameTaken.rawValue {
usernameError = "This username is already taken"
} else {
// Server confirmed OR unknown code save locally.
// PacketResult has no request ID, so we can't guarantee this
// response belongs to our PacketUserInfo. Treat non-usernameTaken
// as success and save locally (desktop parity: fallback save).
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
commitPendingAvatar()
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false
}
} else {
// Server returned error
if result.resultCode == ResultCode.usernameTaken.rawValue {
usernameError = "This username is already taken"
} else {
usernameError = "Failed to save profile"
}
}
}
}
@@ -418,6 +421,9 @@ struct SettingsView: View {
// MARK: - Account Switcher Card
@State private var accountToDelete: Account?
@State private var showDeleteAccountSheet = false
private var accountSwitcherCard: some View {
let currentKey = AccountManager.shared.currentAccount?.publicKey
let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey }
@@ -440,6 +446,18 @@ struct SettingsView: View {
}
}
.padding(.top, 16)
.confirmationDialog(
"Are you sure you want to delete this account from this device?",
isPresented: $showDeleteAccountSheet,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let acc = accountToDelete {
deleteOtherAccount(acc)
accountToDelete = nil
}
}
}
}
private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View {
@@ -449,16 +467,20 @@ struct SettingsView: View {
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey)
let unread = totalUnreadCount(for: account.publicKey)
return Button {
// Show fade overlay FIRST covers the screen immediately.
// Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in),
// so the user never sees the account list re-render.
return AccountSwipeRow(
position: position,
onTap: {
onLogout?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
AccountManager.shared.setActiveAccount(publicKey: account.publicKey)
SessionManager.shared.endSession()
}
} label: {
},
onDelete: {
accountToDelete = account
showDeleteAccountSheet = true
}
) {
HStack(spacing: 12) {
AvatarView(
initials: initials,
@@ -487,10 +509,7 @@ struct SettingsView: View {
}
.padding(.horizontal, 16)
.frame(height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.settingsHighlight(position: position)
}
private func addAccountRow(position: SettingsRowPosition) -> some View {
@@ -519,6 +538,17 @@ struct SettingsView: View {
/// Calculates total unread count for the given account's dialogs.
/// Only returns meaningful data for the currently active session.
/// Deletes another (non-active) account from the device.
private func deleteOtherAccount(_ account: Account) {
BiometricAuthManager.shared.clearAll(forAccount: account.publicKey)
AvatarRepository.shared.removeAvatar(publicKey: account.publicKey)
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "rosetta_recent_searches_\(account.publicKey)")
defaults.removeObject(forKey: "rosetta_last_sync_\(account.publicKey)")
defaults.removeObject(forKey: "backgroundBlurColor_\(account.publicKey)")
AccountManager.shared.removeAccount(publicKey: account.publicKey)
}
private func totalUnreadCount(for accountPublicKey: String) -> Int {
guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 }
return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount }
@@ -850,3 +880,128 @@ struct SettingsView: View {
}
}
// MARK: - Account Swipe Row
/// Row with swipe-to-delete that doesn't conflict with tap action.
/// Uses a gesture-priority trick: horizontal DragGesture on a transparent overlay
/// takes priority only when horizontal movement exceeds vertical (swipe left).
/// Vertical drags and taps pass through to the content below.
private struct AccountSwipeRow<Content: View>: View {
let position: SettingsRowPosition
let onTap: () -> Void
let onDelete: () -> Void
@ViewBuilder let content: () -> Content
@State private var swipeOffset: CGFloat = 0
@State private var isRevealed = false
private let deleteWidth: CGFloat = 80
private let cardRadius: CGFloat = 12
/// Top-right corner radius based on row position in the card.
private var deleteTopRadius: CGFloat {
switch position {
case .top, .alone: return cardRadius
default: return 0
}
}
/// Bottom-right corner radius based on row position in the card.
private var deleteBottomRadius: CGFloat {
// Account rows are never .bottom addAccountRow is always below.
// But handle it for completeness.
switch position {
case .bottom, .alone: return cardRadius
default: return 0
}
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .trailing) {
// Delete button behind only when swiped
if swipeOffset < -5 {
HStack(spacing: 0) {
Spacer()
Button {
withAnimation(.easeOut(duration: 0.2)) {
swipeOffset = 0
isRevealed = false
}
onDelete()
} label: {
Text("Delete")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
.frame(width: deleteWidth, height: geo.size.height)
.background(
UnevenRoundedRectangle(
topLeadingRadius: 0,
bottomLeadingRadius: 0,
bottomTrailingRadius: deleteBottomRadius,
topTrailingRadius: deleteTopRadius
)
.fill(Color.red)
)
}
}
}
// Content row
content()
.contentShape(Rectangle())
.offset(x: swipeOffset)
.onTapGesture {
if isRevealed {
withAnimation(.easeOut(duration: 0.2)) {
swipeOffset = 0
isRevealed = false
}
} else {
onTap()
}
}
.highPriorityGesture(
DragGesture(minimumDistance: 20, coordinateSpace: .local)
.onChanged { value in
let dx = value.translation.width
let dy = value.translation.height
// Only respond to horizontal swipes
guard abs(dx) > abs(dy) else { return }
if isRevealed {
// Already open allow closing
let newOffset = -deleteWidth + dx
swipeOffset = max(min(newOffset, 0), -deleteWidth - 20)
} else if dx < 0 {
// Swiping left to reveal
swipeOffset = max(dx, -deleteWidth - 20)
}
}
.onEnded { value in
let dx = value.translation.width
guard abs(dx) > abs(value.translation.height) else {
// Was vertical snap back
withAnimation(.easeOut(duration: 0.2)) {
swipeOffset = isRevealed ? -deleteWidth : 0
}
return
}
withAnimation(.easeOut(duration: 0.2)) {
if swipeOffset < -deleteWidth / 2 {
swipeOffset = -deleteWidth
isRevealed = true
} else {
swipeOffset = 0
isRevealed = false
}
}
}
)
}
}
.frame(height: 48)
.clipped()
}
}

View File

@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.rosetta.dev</string>
</array>
</dict>
</plist>

View File

@@ -64,22 +64,27 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// MARK: - Background Push (Badge + Local Notification with Sound)
/// Called when a push notification arrives with `content-available: 1`.
/// Server does NOT send `sound` in APNs payload we always create a local
/// notification with `.default` sound to ensure vibration works.
/// Two scenarios:
/// 1. Server sends data-only push (no alert) we create a local notification with sound.
/// 2. Server sends visible push + content-available NSE handles sound/badge,
/// we only sync the badge count here.
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Foreground: WebSocket handles messages + haptic feedback skip.
// Foreground: WebSocket handles messages in real-time skip.
guard application.applicationState != .active else {
completionHandler(.noData)
return
}
// Background/inactive: increment badge from persisted count.
let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count")
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
// Background/inactive: increment badge from shared App Group storage.
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
let newBadge = currentBadge + 1
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
@@ -87,13 +92,22 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let senderName = userInfo["sender_name"] as? String ?? "New message"
let messageText = userInfo["message"] as? String ?? "New message"
// Don't notify for muted chats.
let isMuted = Task { @MainActor in
DialogRepository.shared.dialogs[senderKey]?.isMuted == true
}
Task {
let muted = await isMuted.value
guard !muted else {
// Check if the server already sent a visible alert (aps.alert exists).
// If so, NSE already modified it don't create a duplicate local notification.
let aps = userInfo["aps"] as? [String: Any]
let hasVisibleAlert = aps?["alert"] != nil
// Don't notify for muted chats (sync check without MainActor await).
let isMuted: Bool = {
// Access is safe: called from background on MainActor-isolated repo.
// Use standard defaults cache for muted set (no MainActor needed).
let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
return mutedSet.contains(senderKey)
}()
// If server sent visible alert, NSE handles sound+badge. Just sync badge.
// If data-only push, create local notification with sound for vibration.
guard !hasVisibleAlert && !isMuted else {
completionHandler(.newData)
return
}
@@ -113,7 +127,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
content: content,
trigger: nil
)
try? await UNUserNotificationCenter.current().add(request)
// Use non-async path to avoid Task lifetime issues in background.
UNUserNotificationCenter.current().add(request) { _ in
completionHandler(.newData)
}
}
@@ -288,6 +303,11 @@ struct RosettaApp: App {
}
},
)
// Force full view recreation on account switch. Without this,
// SwiftUI may reuse the old MainTabView's @StateObject instances
// (SettingsViewModel, ChatListViewModel) when appState cycles
// .main .unlock .main, causing stale profile data to persist.
.id(AccountManager.shared.currentAccount?.publicKey ?? "")
}
}
@@ -321,4 +341,7 @@ extension Notification.Name {
static let openChatFromNotification = Notification.Name("openChatFromNotification")
/// Posted when own profile (displayName/username) is updated from the server.
static let profileDidUpdate = Notification.Name("profileDidUpdate")
/// Posted when user taps an attachment in the bubble overlay carries attachment ID (String) as `object`.
/// MessageImageView / MessageFileView listen and trigger download/share.
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>RosettaNotificationService</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,54 @@
import UserNotifications
/// Notification Service Extension runs as a separate process even when the main app
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
/// 1. Adds `.default` sound for vibration (server payload has no sound)
/// 2. Increments the app icon badge from shared App Group storage
final class NotificationService: UNNotificationServiceExtension {
private static let appGroupID = "group.com.rosetta.dev"
private static let badgeKey = "app_badge_count"
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// 1. Add sound for vibration server APNs payload has no sound field.
content.sound = .default
// 2. Increment badge count from shared App Group storage.
if let shared = UserDefaults(suiteName: Self.appGroupID) {
let current = shared.integer(forKey: Self.badgeKey)
let newBadge = current + 1
shared.set(newBadge, forKey: Self.badgeKey)
content.badge = NSNumber(value: newBadge)
}
// 3. Ensure notification category for CarPlay parity.
if content.categoryIdentifier.isEmpty {
content.categoryIdentifier = "message"
}
contentHandler(content)
}
/// Called if the extension takes too long (30s limit).
/// Deliver the best attempt content with at least the sound set.
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
content.sound = .default
handler(content)
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.rosetta.dev</string>
</array>
</dict>
</plist>