Уведомления в фоне, оптимизация 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 = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; 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 */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
/* End PBXBuildFile section */ /* 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 */ /* 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
853F29642F4B50410092AD05 /* Rosetta */ = { 853F29642F4B50410092AD05 /* Rosetta */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Rosetta; path = Rosetta;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -39,14 +72,32 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B2C595701A2879A2FD49DDEF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
32A246700D4A2618B3F81039 /* iOS */ = {
isa = PBXGroup;
children = (
272B862BE4D99E7DD751CC3E /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
853F29592F4B50410092AD05 = { 853F29592F4B50410092AD05 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
853F29642F4B50410092AD05 /* Rosetta */, 853F29642F4B50410092AD05 /* Rosetta */,
853F29632F4B50410092AD05 /* Products */, 853F29632F4B50410092AD05 /* Products */,
95676C1A4D239B1FF9E73782 /* Frameworks */,
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -54,10 +105,29 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
853F29622F4B50410092AD05 /* Rosetta.app */, 853F29622F4B50410092AD05 /* Rosetta.app */,
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -68,10 +138,12 @@
853F295E2F4B50410092AD05 /* Sources */, 853F295E2F4B50410092AD05 /* Sources */,
853F295F2F4B50410092AD05 /* Frameworks */, 853F295F2F4B50410092AD05 /* Frameworks */,
853F29602F4B50410092AD05 /* Resources */, 853F29602F4B50410092AD05 /* Resources */,
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
3323872B02212359E2291EE8 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
853F29642F4B50410092AD05 /* Rosetta */, 853F29642F4B50410092AD05 /* Rosetta */,
@@ -88,6 +160,23 @@
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -101,6 +190,9 @@
853F29612F4B50410092AD05 = { 853F29612F4B50410092AD05 = {
CreatedOnToolsVersion = 26.2; CreatedOnToolsVersion = 26.2;
}; };
E47730762E9823BA2D02A197 = {
CreatedOnToolsVersion = 26.2;
};
}; };
}; };
buildConfigurationList = 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */; buildConfigurationList = 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */;
@@ -123,6 +215,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
853F29612F4B50410092AD05 /* Rosetta */, 853F29612F4B50410092AD05 /* Rosetta */,
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -135,6 +228,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
F9F9B9BDE87DB35631992F35 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -145,9 +245,54 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A624149985830F8CA8C2E52D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
3323872B02212359E2291EE8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = RosettaNotificationService;
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration 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 */ = { 853F296B2F4B50420092AD05 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -275,7 +420,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -291,7 +436,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.6; MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -314,7 +459,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17; CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -330,7 +475,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.6; MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -345,6 +490,35 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -366,6 +540,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
93E51266ED50ED634DDBB900 /* Release */,
0140D6320A9CF4B5E933E0B1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */

View File

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

View File

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

View File

@@ -10,8 +10,15 @@ final class DialogRepository {
static let shared = DialogRepository() static let shared = DialogRepository()
private(set) var dialogs: [String: Dialog] = [:] { 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 currentAccount: String = ""
private var storagePassword: String = "" private var storagePassword: String = ""
private var persistTask: Task<Void, Never>? private var persistTask: Task<Void, Never>?
@@ -81,6 +88,7 @@ final class DialogRepository {
) )
_sortedKeysCache = nil _sortedKeysCache = nil
updateAppBadge() updateAppBadge()
syncMutedKeysToDefaults()
} }
func reset(clearPersisted: Bool = false) { func reset(clearPersisted: Bool = false) {
@@ -91,6 +99,7 @@ final class DialogRepository {
storagePassword = "" storagePassword = ""
UNUserNotificationCenter.current().setBadgeCount(0) UNUserNotificationCenter.current().setBadgeCount(0)
UserDefaults.standard.set(0, forKey: "app_badge_count") 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 } guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount let accountToReset = currentAccount
@@ -146,14 +155,14 @@ final class DialogRepository {
) )
// Desktop parity: constructLastMessageTextByAttachments() returns // 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, if decryptedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let firstAttachment = packet.attachments.first { let firstAttachment = packet.attachments.first {
switch firstAttachment.type { switch firstAttachment.type {
case .image: dialog.lastMessage = "Photo" case .image: dialog.lastMessage = "Photo"
case .file: dialog.lastMessage = "File" case .file: dialog.lastMessage = "File"
case .avatar: dialog.lastMessage = "Avatar" case .avatar: dialog.lastMessage = "Avatar"
default: dialog.lastMessage = decryptedText case .messages: dialog.lastMessage = "Forwarded message"
} }
} else { } else {
dialog.lastMessage = decryptedText dialog.lastMessage = decryptedText
@@ -165,13 +174,12 @@ final class DialogRepository {
if fromMe { if fromMe {
dialog.iHaveSent = true dialog.iHaveSent = true
} else { } else {
// Only increment unread count when: // Only increment unread count for REAL-TIME messages (not sync).
// 1. The message is genuinely new (not a dedup hit from sync re-processing) // During sync, messages may already be read on another device but arrive
// 2. The user is NOT currently viewing this dialog // as "new" to iOS. Incrementing here inflates the badge (e.g., 11 4 0).
// Desktop parity: desktop computes unread from `SELECT COUNT(*) WHERE read = 0`, // Android parity: Android recalculates unread from DB after every message
// so duplicates never inflate the count. iOS uses an incremental counter, // via COUNT(*) WHERE read=0. iOS defers to reconcileUnreadCounts() at sync end.
// so we must guard against re-incrementing for known messages. if isNewMessage && !fromSync && !MessageRepository.shared.isDialogActive(opponentKey) {
if isNewMessage && !MessageRepository.shared.isDialogActive(opponentKey) {
dialog.unreadCount += 1 dialog.unreadCount += 1
} }
} }
@@ -341,7 +349,7 @@ final class DialogRepository {
case .image: dialog.lastMessage = "Photo" case .image: dialog.lastMessage = "Photo"
case .file: dialog.lastMessage = "File" case .file: dialog.lastMessage = "File"
case .avatar: dialog.lastMessage = "Avatar" case .avatar: dialog.lastMessage = "Avatar"
default: dialog.lastMessage = lastMsg.text case .messages: dialog.lastMessage = "Forwarded message"
} }
} else { } else {
dialog.lastMessage = lastMsg.text dialog.lastMessage = lastMsg.text
@@ -457,9 +465,19 @@ final class DialogRepository {
guard var dialog = dialogs[opponentKey] else { return } guard var dialog = dialogs[opponentKey] else { return }
dialog.isMuted.toggle() dialog.isMuted.toggle()
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
syncMutedKeysToDefaults()
schedulePersist() 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 { private func normalizeTimestamp(_ raw: Int64) -> Int64 {
raw < 1_000_000_000_000 ? raw * 1000 : raw 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. /// 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() { 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) 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") UserDefaults.standard.set(total, forKey: "app_badge_count")
} }

View File

@@ -96,6 +96,14 @@ final class MessageRepository: ObservableObject {
messagesByDialog[dialogKey]?.last?.id == messageId 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. /// Whether the user is currently viewing any chat.
var hasActiveDialog: Bool { var hasActiveDialog: Bool {
!activeDialogs.isEmpty !activeDialogs.isEmpty
@@ -105,6 +113,12 @@ final class MessageRepository: ObservableObject {
activeDialogs.contains(dialogKey) 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) { func setDialogActive(_ dialogKey: String, isActive: Bool) {
if isActive { if isActive {
activeDialogs.insert(dialogKey) 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) { func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) {
guard let dialogKey = messageToDialog[messageId] else { return } guard let dialogKey = messageToDialog[messageId] else { return }
updateMessages(for: dialogKey) { messages in updateMessages(for: dialogKey) { messages in

View File

@@ -105,14 +105,39 @@ final class ProtocolManager: @unchecked Sendable {
} }
/// Verify connection health after returning from background. /// Verify connection health after returning from background.
/// Always force reconnect after background, the socket is likely dead /// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
/// and a 2s ping timeout just delays the inevitable. /// If pong arrives, connection is alive no reconnect needed.
/// If ping fails or times out (500ms), force full reconnect.
func reconnectIfNeeded() { func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return } guard savedPublicKey != nil, savedPrivateHash != nil else { return }
// Don't interrupt active handshake // Don't interrupt active handshake
if connectionState == .handshaking { return } if connectionState == .handshaking { return }
// 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") Self.logger.info("Foreground reconnect — force reconnecting")
handshakeComplete = false handshakeComplete = false
heartbeatTask?.cancel() heartbeatTask?.cancel()

View File

@@ -31,7 +31,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
override init() { override init() {
super.init() super.init()
let config = URLSessionConfiguration.default 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) session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
startNetworkMonitor() startNetworkMonitor()
} }
@@ -91,6 +95,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
webSocketTask = nil webSocketTask = nil
isConnected = false isConnected = false
disconnectHandledForCurrentSocket = 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") Self.logger.info("Force reconnect triggered")
connect() connect()
} }
@@ -196,16 +202,27 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return } guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return } guard reconnectTask == nil else { return }
// 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 reconnectAttempts += 1
let exponent = min(reconnectAttempts - 1, 4) if reconnectAttempts == 1 {
let delayMs = min(1000 * (1 << exponent), 16000) // Immediate retry no delay on first attempt.
reconnectTask = Task { [weak self] in Self.logger.info("Reconnecting immediately (attempt #1)...")
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...") reconnectTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) guard let self, !isManuallyClosed, !Task.isCancelled else { return }
guard let self, !isManuallyClosed, !Task.isCancelled else { return } self.reconnectTask = nil
self.reconnectTask = nil self.connect()
self.connect() }
} 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))...")
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil
self.connect()
}
} }
} }
} }

View File

@@ -77,6 +77,9 @@ final class TransportManager: @unchecked Sendable {
/// - id: Unique file identifier (used as filename in multipart). /// - id: Unique file identifier (used as filename in multipart).
/// - content: Raw file content to upload. /// - content: Raw file content to upload.
/// - Returns: Server-assigned tag for later download. /// - 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 { func uploadFile(id: String, content: Data) async throws -> String {
guard let serverUrl = await MainActor.run(body: { transportServer }) else { guard let serverUrl = await MainActor.run(body: { transportServer }) else {
throw TransportError.noTransportServer throw TransportError.noTransportServer
@@ -102,25 +105,38 @@ final class TransportManager: @unchecked Sendable {
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body request.httpBody = body
let (data, response) = try await session.data(for: request) 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 { guard let httpResponse = response as? HTTPURLResponse else {
throw TransportError.invalidResponse throw TransportError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
Self.logger.error("Upload failed: HTTP \(httpResponse.statusCode)")
throw TransportError.uploadFailed(statusCode: httpResponse.statusCode)
}
// Parse JSON response: {"t": "tag"}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tag = json["t"] as? String else {
throw TransportError.missingTag
}
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
guard httpResponse.statusCode == 200 else {
Self.logger.error("Upload failed: HTTP \(httpResponse.statusCode)")
throw TransportError.uploadFailed(statusCode: httpResponse.statusCode)
}
// Parse JSON response: {"t": "tag"}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tag = json["t"] as? String else {
throw TransportError.missingTag
}
Self.logger.info("Upload complete: id=\(id), tag=\(tag)")
return tag
} }
// MARK: - Download // MARK: - Download

View File

@@ -28,8 +28,9 @@ final class SessionManager {
private var syncRequestInFlight = false private var syncRequestInFlight = false
private var pendingIncomingMessages: [PacketMessage] = [] private var pendingIncomingMessages: [PacketMessage] = []
private var isProcessingIncomingMessages = false private var isProcessingIncomingMessages = false
private var pendingReadReceiptKeys: Set<String> = [] /// Android parity: tracks the latest incoming message timestamp per dialog
private var lastReadReceiptSentAt: [String: Int64] = [:] /// for which a read receipt was already sent. Prevents redundant sends.
private var lastReadReceiptTimestamp: [String: Int64] = [:]
private var requestedUserInfoKeys: Set<String> = [] private var requestedUserInfoKeys: Set<String> = []
private var onlineSubscribedKeys: Set<String> = [] private var onlineSubscribedKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:] private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
@@ -38,17 +39,9 @@ final class SessionManager {
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000 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. private var foregroundObserverToken: NSObjectProtocol?
/// 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
}
/// Whether the app is in the foreground. /// Whether the app is in the foreground.
private var isAppInForeground: Bool { private var isAppInForeground: Bool {
@@ -60,13 +53,22 @@ final class SessionManager {
private init() { private init() {
setupProtocolCallbacks() setupProtocolCallbacks()
setupUserInfoSearchHandler() setupUserInfoSearchHandler()
setupIdleDetection() setupForegroundObserver()
} }
/// Desktop parity: track user interaction to implement idle detection. /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
/// Call this from any user-facing interaction (tap, scroll, keyboard). /// Called on foreground resume. Android has no idle detection just re-marks on resume.
func recordUserInteraction() { func markActiveDialogsAsRead() {
lastUserInteractionTime = Date() 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 // MARK: - Session Lifecycle
@@ -218,10 +220,15 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey recipientPublicKeyHex: toPublicKey
) )
// Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string // Attachment password: Android-style UTF-8 decoder (1:1 parity with Android).
// (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts) let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce)
// Must use UTF-8 decoding with replacement characters (U+FFFD) to match Node.js behavior.
let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) // 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...") // 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. // 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 avatarData = Data(dataURI.utf8)
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
avatarData, avatarData,
password: latin1String password: attachmentPassword
) )
// Upload encrypted blob to transport server (desktop: uploadFile) // Upload encrypted blob to transport server (desktop: uploadFile)
@@ -243,8 +250,8 @@ final class SessionManager {
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? "" let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
let preview = "\(tag)::\(blurhash)" let preview = "\(tag)::\(blurhash)"
// Build aesChachaKey (same as regular messages) // Build aesChachaKey with Latin-1 payload (desktop sync parity)
let aesChachaPayload = Data(latin1String.utf8) let aesChachaPayload = Data(latin1ForSync.utf8)
let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
aesChachaPayload, aesChachaPayload,
password: privKey password: privKey
@@ -297,10 +304,15 @@ final class SessionManager {
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) 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 { if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
// 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 return
} }
@@ -342,9 +354,11 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey recipientPublicKeyHex: toPublicKey
) )
// Attachment password: WHATWG UTF-8 of raw key+nonce bytes. // Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity.
// Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation. // Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in
let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) // 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 #if DEBUG
// Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS. // Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS.
@@ -361,92 +375,81 @@ final class SessionManager {
Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)") Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)")
#endif #endif
// Process each attachment: encrypt upload build metadata // Phase 1: Encrypt all attachments sequentially (same password, CPU-bound).
var messageAttachments: [MessageAttachment] = [] struct EncryptedAttachment {
let original: PendingAttachment
let encryptedData: Data
let preview: String // partially built tag placeholder
}
var encryptedAttachments: [EncryptedAttachment] = []
for attachment in attachments { for attachment in attachments {
// Build data URI (desktop: FileReader.readAsDataURL)
let dataURI = buildDataURI(attachment) let dataURI = buildDataURI(attachment)
#if DEBUG
Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)")
#endif
// Encrypt blob with desktop-compatible encryption
let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(dataURI.utf8), Data(dataURI.utf8),
password: attachmentPassword password: attachmentPassword
) )
#if DEBUG #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. // Self-test: decrypt with the SAME WHATWG password.
if let selfTestData = try? CryptoManager.shared.decryptWithPassword( if let selfTestData = try? CryptoManager.shared.decryptWithPassword(
encryptedBlob, password: attachmentPassword, requireCompression: true encryptedBlob, password: attachmentPassword, requireCompression: true
), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == 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 { } 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 #endif
// Upload to transport server // Pre-compute blurhash/preview prefix (everything except tag)
let uploadData = Data(encryptedBlob.utf8) let previewSuffix: String
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
switch attachment.type { switch attachment.type {
case .image: case .image:
// Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3))
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
preview = "\(tag)::\(blurhash)" previewSuffix = blurhash
case .file: case .file:
// Desktop: preview = "tag::size::filename" previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
preview = "\(tag)::\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
default: default:
preview = "\(tag)::" previewSuffix = ""
} }
messageAttachments.append(MessageAttachment( encryptedAttachments.append(EncryptedAttachment(
id: attachment.id, original: attachment,
preview: preview, encryptedData: Data(encryptedBlob.utf8),
blob: "", // Desktop parity: blob cleared after upload preview: previewSuffix
type: attachment.type
)) ))
}
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). // Build aesChachaKey (for sync/backup same encoding as makeOutgoingPacket).
@@ -606,9 +609,9 @@ final class SessionManager {
recipientPublicKeyHex: toPublicKey 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. // 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 // Build the reply JSON blob
let replyJSON = try JSONEncoder().encode(replyMessages) let replyJSON = try JSONEncoder().encode(replyMessages)
@@ -736,9 +739,47 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(packet) 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) { 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). /// Updates locally cached display name and username (called from ProfileEditView).
@@ -757,14 +798,12 @@ final class SessionManager {
syncRequestInFlight = false syncRequestInFlight = false
pendingIncomingMessages.removeAll() pendingIncomingMessages.removeAll()
isProcessingIncomingMessages = false isProcessingIncomingMessages = false
pendingReadReceiptKeys.removeAll() lastReadReceiptTimestamp.removeAll()
lastReadReceiptSentAt.removeAll()
requestedUserInfoKeys.removeAll() requestedUserInfoKeys.removeAll()
pendingOutgoingRetryTasks.values.forEach { $0.cancel() } pendingOutgoingRetryTasks.values.forEach { $0.cancel() }
pendingOutgoingRetryTasks.removeAll() pendingOutgoingRetryTasks.removeAll()
pendingOutgoingPackets.removeAll() pendingOutgoingPackets.removeAll()
pendingOutgoingAttempts.removeAll() pendingOutgoingAttempts.removeAll()
lastUserInteractionTime = Date()
isAuthenticated = false isAuthenticated = false
currentPublicKey = "" currentPublicKey = ""
displayName = "" displayName = ""
@@ -809,6 +848,10 @@ final class SessionManager {
newTimestamp: deliveryTimestamp newTimestamp: deliveryTimestamp
) )
self?.resolveOutgoingRetry(messageId: packet.messageId) 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, opponentKey: opponentKey,
myPublicKey: ownKey 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() await self.waitForInboundQueueToDrain()
let serverCursor = packet.timestamp let serverCursor = packet.timestamp
self.saveLastSyncTimestamp(serverCursor) 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.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
self.requestSynchronize(cursor: serverCursor) self.requestSynchronize(cursor: serverCursor)
case .notNeeded: case .notNeeded:
self.syncBatchInProgress = false self.syncBatchInProgress = false
self.flushPendingReadReceipts()
DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileDeliveryStatuses()
DialogRepository.shared.reconcileUnreadCounts() DialogRepository.shared.reconcileUnreadCounts()
Self.logger.debug("SYNC NOT_NEEDED") Self.logger.debug("SYNC NOT_NEEDED")
@@ -1228,13 +1283,12 @@ final class SessionManager {
// Sending 0x08 for every received message was causing a packet flood // Sending 0x08 for every received message was causing a packet flood
// that triggered server RST disconnects. // that triggered server RST disconnects.
// Desktop parity: only mark as read if user is NOT idle AND app is in foreground. // Android parity: mark as read if dialog is active AND app is in foreground.
// Desktop also skips system accounts and blocked users. // Android has NO idle detection only isDialogActive flag (ON_RESUME/ON_PAUSE).
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
let isSystem = SystemAccounts.isSystemAccount(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey)
let idle = isUserIdle
let fg = isAppInForeground let fg = isAppInForeground
let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem let shouldMarkRead = dialogIsActive && fg && !isSystem
if shouldMarkRead { if shouldMarkRead {
DialogRepository.shared.markAsRead(opponentKey: opponentKey) DialogRepository.shared.markAsRead(opponentKey: opponentKey)
@@ -1243,11 +1297,9 @@ final class SessionManager {
myPublicKey: myKey myPublicKey: myKey
) )
if !fromMe && !wasKnownBefore { if !fromMe && !wasKnownBefore {
if syncBatchInProgress { // Android/Desktop parity: send read receipt immediately,
pendingReadReceiptKeys.insert(opponentKey) // even during sync. 400ms debounce prevents flooding.
} else { sendReadReceipt(toPublicKey: opponentKey)
sendReadReceipt(toPublicKey: opponentKey)
}
} }
} }
@@ -1327,9 +1379,8 @@ final class SessionManager {
guard !syncRequestInFlight else { return } guard !syncRequestInFlight else { return }
syncRequestInFlight = true syncRequestInFlight = true
// Desktop parity: pass server cursor as-is (seconds). NO normalization // Server and all platforms use MILLISECONDS for sync cursors.
// server uses seconds, converting to milliseconds made the server see a // Pass cursor as-is no normalization needed.
// "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs.
let lastSync = cursor ?? loadLastSyncTimestamp() let lastSync = cursor ?? loadLastSyncTimestamp()
var packet = PacketSync() var packet = PacketSync()
@@ -1343,10 +1394,11 @@ final class SessionManager {
private func loadLastSyncTimestamp() -> Int64 { private func loadLastSyncTimestamp() -> Int64 {
guard !currentPublicKey.isEmpty else { return 0 } guard !currentPublicKey.isEmpty else { return 0 }
let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey))
// Migration: old code normalized seconds milliseconds. If the stored value // Android parity (normalizeSyncTimestamp): all platforms store milliseconds.
// is in milliseconds (>= 1 trillion), convert back to seconds for server parity. // If stored value is in seconds (< 1 trillion), convert to milliseconds.
if stored >= 1_000_000_000_000 { // Values >= 1 trillion are already in milliseconds return as-is.
let corrected = stored / 1000 if stored > 0, stored < 1_000_000_000_000 {
let corrected = stored * 1000
UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey) UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey)
return corrected return corrected
} }
@@ -1355,7 +1407,7 @@ final class SessionManager {
private func saveLastSyncTimestamp(_ raw: Int64) { private func saveLastSyncTimestamp(_ raw: Int64) {
guard !currentPublicKey.isEmpty else { return } 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 } guard raw > 0 else { return }
let existing = loadLastSyncTimestamp() let existing = loadLastSyncTimestamp()
guard raw > existing else { return } guard raw > existing else { return }
@@ -1632,11 +1684,18 @@ final class SessionManager {
) )
do { 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( let packet = try makeOutgoingPacket(
text: text, text: text,
toPublicKey: message.toPublicKey, toPublicKey: message.toPublicKey,
messageId: message.id, messageId: message.id,
timestamp: message.timestamp, timestamp: retryTimestamp,
privateKeyHex: privateKeyHex, privateKeyHex: privateKeyHex,
privateKeyHash: privateKeyHash 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) { private func registerOutgoingRetry(for packet: PacketMessage) {
let messageId = packet.messageId let messageId = packet.messageId
pendingOutgoingRetryTasks[messageId]?.cancel() pendingOutgoingRetryTasks[messageId]?.cancel()
@@ -1708,20 +1731,26 @@ final class SessionManager {
guard let packet = self.pendingOutgoingPackets[messageId] else { return } guard let packet = self.pendingOutgoingPackets[messageId] else { return }
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0 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 nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let ageMs = nowMs - packet.timestamp let ageMs = nowMs - packet.timestamp
if ageMs >= self.maxOutgoingWaitingLifetimeMs { let attachCount = max(1, Int64(packet.attachments.count))
Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error") let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
self.markOutgoingAsError(messageId: messageId, packet: packet) 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 return
} }
guard attempts < self.maxOutgoingRetryAttempts else { guard attempts < self.maxOutgoingRetryAttempts else {
// Max retries exhausted for this connection session mark as error. // Max retries exhausted while connected same reasoning:
// The user sees the error icon immediately instead of a stuck clock. // packets were sent, no error from server, likely delivered.
Self.logger.warning("Message \(messageId) exhausted \(attempts) retries marking as error") Self.logger.info("Message \(messageId) — no ACK after \(attempts) retries, marking as delivered (optimistic)")
self.markOutgoingAsError(messageId: messageId, packet: packet) self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
return 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. /// 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) { private func markOutgoingAsError(messageId: String, packet: PacketMessage) {
let fromMe = packet.fromPublicKey == currentPublicKey let fromMe = packet.fromPublicKey == currentPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey 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) MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus( DialogRepository.shared.updateDeliveryStatus(
messageId: messageId, messageId: messageId,
@@ -1761,6 +1828,18 @@ final class SessionManager {
pendingOutgoingAttempts.removeValue(forKey: messageId) 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 // MARK: - Push Notifications
/// Stores the APNs device token received from AppDelegate. /// Stores the APNs device token received from AppDelegate.
@@ -1795,16 +1874,16 @@ final class SessionManager {
/// Desktop equivalent: `useUpdateMessage.ts` `useSendSystemMessage("updates")`. /// Desktop equivalent: `useUpdateMessage.ts` `useSendSystemMessage("updates")`.
private func sendReleaseNotesIfNeeded(publicKey: String) { private func sendReleaseNotesIfNeeded(publicKey: String) {
let key = "lastReleaseNoticeVersion_\(publicKey)" let key = "lastReleaseNoticeVersion_\(publicKey)"
let lastVersion = UserDefaults.standard.string(forKey: key) ?? "" let lastKey = UserDefaults.standard.string(forKey: key) ?? ""
let currentVersion = ReleaseNotes.appVersion // Android parity: version + text hash re-sends if text changed within same version.
guard lastVersion != currentVersion else { return }
let noticeText = ReleaseNotes.releaseNoticeText let noticeText = ReleaseNotes.releaseNoticeText
guard !noticeText.isEmpty else { return } guard !noticeText.isEmpty else { return }
let currentKey = "\(ReleaseNotes.appVersion)_\(noticeText.hashValue)"
guard lastKey != currentKey else { return }
let now = Int64(Date().timeIntervalSince1970 * 1000) 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. // Create synthetic PacketMessage local-only, never sent to server.
var packet = PacketMessage() var packet = PacketMessage()
@@ -1837,23 +1916,23 @@ final class SessionManager {
verified: 1 verified: 1
) )
UserDefaults.standard.set(currentVersion, forKey: key) UserDefaults.standard.set(currentKey, forKey: key)
Self.logger.info("Release notes v\(currentVersion) sent to Updates chat") Self.logger.info("Release notes v\(ReleaseNotes.appVersion) sent to Updates chat")
} }
// MARK: - Idle Detection Setup // MARK: - Foreground Observer (Android parity)
private func setupIdleDetection() { private func setupForegroundObserver() {
// Track app going to background/foreground to reset idle state + reconnect. // Android parity: ON_RESUME markVisibleMessagesAsRead() + reconnect.
idleObserverToken = NotificationCenter.default.addObserver( foregroundObserverToken = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, forName: UIApplication.willEnterForegroundNotification,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor [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. // Always verify connection on foreground don't trust cached state.
// reconnectIfNeeded() pings to check if "authenticated" connection is alive.
ProtocolManager.shared.reconnectIfNeeded() ProtocolManager.shared.reconnectIfNeeded()
} }
} }

View File

@@ -9,11 +9,13 @@ enum ProfileValidator {
enum DisplayNameError: LocalizedError { enum DisplayNameError: LocalizedError {
case empty case empty
case tooLong case tooLong
case tooManyWords
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .empty: return "Name cannot be empty" case .empty: return "Name cannot be empty"
case .tooLong: return "Name cannot exceed 64 characters" 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) let trimmed = name.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return .empty } if trimmed.isEmpty { return .empty }
if trimmed.count > 64 { return .tooLong } 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 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 // MARK: - Username
enum UsernameError: LocalizedError { enum UsernameError: LocalizedError {

View File

@@ -9,16 +9,15 @@ enum ProtocolConstants {
static let maxMessagesLoad = 20 static let maxMessagesLoad = 20
/// Maximum messages kept in memory per dialog. /// 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. /// Outgoing message delivery timeout in seconds.
/// If a WAITING message is older than this, mark it as ERROR. /// If a WAITING message is older than this, mark it as ERROR.
static let messageDeliveryTimeoutS: Int64 = 80 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. /// Maximum number of file attachments per message.
static let maxAttachmentsInMessage = 5 static let maxAttachmentsInMessage = 5
@@ -41,9 +40,6 @@ enum ProtocolConstants {
/// Maximum number of outgoing message retry attempts. /// Maximum number of outgoing message retry attempts.
static let maxOutgoingRetryAttempts = 3 static let maxOutgoingRetryAttempts = 3
/// Read receipt throttle interval in milliseconds.
static let readReceiptThrottleMs: Int64 = 400
/// Typing indicator throttle interval in milliseconds (desktop parity). /// Typing indicator throttle interval in milliseconds (desktop parity).
static let typingThrottleMs: Int64 = 3_000 static let typingThrottleMs: Int64 = 3_000

View File

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

View File

@@ -26,10 +26,7 @@ struct GlassBackButton: View {
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle) .glassEffect(.regular, in: .circle)
} else { } else {
Circle() TelegramGlassCircle()
.fill(.thinMaterial)
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }
} }

View File

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

View File

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

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 onSend: ([PendingAttachment], String) -> Void
let onSendAvatar: () -> 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 @Environment(\.dismiss) private var dismiss
@@ -190,10 +194,7 @@ struct AttachmentPanelView: View {
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle) .glassEffect(.regular, in: .circle)
} else { } else {
Circle() TelegramGlassCircle()
.fill(.thinMaterial)
.overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }
@@ -324,10 +325,7 @@ struct AttachmentPanelView: View {
.fill(.clear) .fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else { } else {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) TelegramGlassRoundedRect(cornerRadius: 21)
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }
@@ -358,13 +356,7 @@ struct AttachmentPanelView: View {
.fill(.clear) .fill(.clear)
.glassEffect(.regular, in: .capsule) .glassEffect(.regular, in: .capsule)
} else { } else {
// iOS < 26 frosted glass material (matches RosettaTabBar) TelegramGlassCapsule()
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
} }
} }
@@ -375,9 +367,15 @@ struct AttachmentPanelView: View {
return Button { return Button {
if tab == .avatar { if tab == .avatar {
// Avatar is an action tab immediately sends avatar + dismisses if hasAvatar {
onSendAvatar() onSendAvatar()
dismiss() dismiss()
} else {
// No avatar set offer to set one
dismiss()
onSetAvatar?()
}
return
} else { } else {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab selectedTab = tab
@@ -395,13 +393,17 @@ struct AttachmentPanelView: View {
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white) .foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
.frame(minWidth: 66, maxWidth: .infinity) .frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background {
// Selected tab: thin material pill (matches RosettaTabBar selection style) if isSelected {
isSelected if #available(iOS 26, *) {
? AnyShapeStyle(.thinMaterial) Capsule()
: AnyShapeStyle(.clear), .fill(.clear)
in: Capsule() .glassEffect(.regular, in: .capsule)
) } else {
TelegramGlassCapsule()
}
}
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -24,8 +24,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
let previewShape: MessageBubbleShape let previewShape: MessageBubbleShape
let readStatusText: String? let readStatusText: String?
/// Called when user single-taps the bubble (e.g., to open fullscreen image). /// Called when user single-taps the bubble. Receives tap location in the overlay's
var onTap: (() -> Void)? /// 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). /// 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`. /// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
@@ -41,6 +42,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
view.addInteraction(interaction) view.addInteraction(interaction)
// Single tap recognizer coexists with context menu's long press. // 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(_:))) let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
view.addGestureRecognizer(tap) view.addGestureRecognizer(tap)
@@ -62,7 +66,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
var actions: [BubbleContextAction] var actions: [BubbleContextAction]
var previewShape: MessageBubbleShape var previewShape: MessageBubbleShape
var readStatusText: String? var readStatusText: String?
var onTap: (() -> Void)? var onTap: ((CGPoint) -> Void)?
var replyQuoteHeight: CGFloat = 0 var replyQuoteHeight: CGFloat = 0
var onReplyQuoteTap: (() -> Void)? var onReplyQuoteTap: (() -> Void)?
private var snapshotView: UIImageView? private var snapshotView: UIImageView?
@@ -85,7 +89,8 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
return return
} }
} }
onTap?() let location = recognizer.location(in: recognizer.view)
onTap?(location)
} }
func contextMenuInteraction( func contextMenuInteraction(

View File

@@ -80,14 +80,15 @@ struct ChatDetailView: View {
@State private var firstUnreadMessageId: String? @State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false @State private var isSendingAvatar = false
@State private var showAttachmentPanel = false @State private var showAttachmentPanel = false
@State private var showNoAvatarAlert = false
@State private var pendingAttachments: [PendingAttachment] = [] @State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false @State private var showOpponentProfile = false
@State private var replyingToMessage: ChatMessage? @State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false @State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage? @State private var forwardingMessage: ChatMessage?
@State private var messageToDelete: ChatMessage? @State private var messageToDelete: ChatMessage?
/// Attachment ID for full-screen image viewer (nil = dismissed). /// State for the multi-photo gallery viewer (nil = dismissed).
@State private var fullScreenAttachmentId: String? @State private var imageViewerState: ImageViewerState?
/// ID of message to scroll to (set when tapping a reply quote). /// ID of message to scroll to (set when tapping a reply quote).
@State private var scrollToMessageId: String? @State private var scrollToMessageId: String?
/// ID of message currently highlighted after scroll-to-reply navigation. /// 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. // does NOT mutate DialogRepository, so ForEach won't rebuild.
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
clearDeliveredNotifications(for: route.publicKey) 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) // Request user info (non-mutating, won't trigger list rebuild)
requestUserInfoIfNeeded() requestUserInfoIfNeeded()
// Delay DialogRepository mutations to let navigation transition complete. // Delay DialogRepository mutations to let navigation transition complete.
@@ -261,6 +260,13 @@ struct ChatDetailView: View {
.onDisappear { .onDisappear {
isViewActive = false isViewActive = false
firstUnreadMessageId = nil 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) MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
// Desktop parity: save draft text on chat close. // Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
@@ -281,13 +287,15 @@ struct ChatDetailView: View {
} }
} }
.fullScreenCover(isPresented: Binding( .fullScreenCover(isPresented: Binding(
get: { fullScreenAttachmentId != nil }, get: { imageViewerState != nil },
set: { if !$0 { fullScreenAttachmentId = nil } } set: { if !$0 { imageViewerState = nil } }
)) { )) {
FullScreenImageFromCache( if let state = imageViewerState {
attachmentId: fullScreenAttachmentId ?? "", ImageGalleryViewer(
onDismiss: { fullScreenAttachmentId = nil } state: state,
) onDismiss: { imageViewerState = nil }
)
}
} }
.alert("Delete Message", isPresented: Binding( .alert("Delete Message", isPresented: Binding(
get: { messageToDelete != nil }, get: { messageToDelete != nil },
@@ -305,6 +313,30 @@ struct ChatDetailView: View {
} message: { } message: {
Text("Are you sure you want to delete this message? This action cannot be undone.") 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 } } .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
// PERF: use message.id as ForEach identity (stable). // PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation.
// Integer indices shift on every insert, forcing full diff. // Use message.id identity (stable) integer indices shift on insert.
ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in ForEach(messages.reversed()) { message in
let index = messageIndex(for: message.id)
let position = bubblePosition(for: index) let position = bubblePosition(for: index)
messageRow( messageRow(
message, message,
@@ -717,10 +750,14 @@ private extension ChatDetailView {
} }
shouldScrollOnNextMessage = false shouldScrollOnNextMessage = false
} }
} // Android parity: markVisibleMessagesAsRead when new incoming
.onChange(of: isInputFocused) { _, focused in // messages appear while chat is open, mark as read and send receipt.
guard focused else { return } // Safe to call repeatedly: markAsRead guards unreadCount > 0,
SessionManager.shared.recordUserInteraction() // sendReadReceipt deduplicates by timestamp.
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
markDialogAsRead()
}
} }
// Scroll-to-reply: navigate to the original message and highlight it briefly. // Scroll-to-reply: navigate to the original message and highlight it briefly.
.onChange(of: scrollToMessageId) { _, targetId in .onChange(of: scrollToMessageId) { _, targetId in
@@ -828,49 +865,206 @@ private extension ChatDetailView {
let messageText = message.text.isEmpty ? " " : message.text let messageText = message.text.isEmpty ? " " : message.text
let replyAttachment = message.attachments.first(where: { $0.type == .messages }) let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first 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 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.
Text(parsedMarkdown(messageText))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in self.scrollToMessageId = reply.message_id }
}
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
}
// 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) { VStack(alignment: .leading, spacing: 0) {
// Reply/forward quote (if present) // "Forwarded from" label
if let reply = replyData { Text("Forwarded from")
replyQuoteView(reply: reply, outgoing: outgoing) .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)
} }
// Telegram-style compact bubble: inline time+status at bottom-trailing. // Forwarded file attachments.
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in
Text(parsedMarkdown(messageText)) forwardedFilePreview(attachment: att, outgoing: outgoing)
.font(.system(size: 17, weight: .regular)) .padding(.horizontal, 6)
.tracking(-0.43) .padding(.top, 4)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) }
.multilineTextAlignment(.leading)
.lineSpacing(0) // Caption text (if original message had text) or fallback label.
.fixedSize(horizontal: false, vertical: true) if hasCaption {
.padding(.leading, 11) Text(parsedMarkdown(reply.message))
.padding(.trailing, outgoing ? 64 : 48) .font(.system(size: 17, weight: .regular))
.padding(.vertical, 5) .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) .frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing) 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(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !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) } .background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay { .overlay {
BubbleContextMenuOverlay( BubbleContextMenuOverlay(
actions: bubbleActions(for: message), actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing), previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message), readStatusText: contextMenuReadStatus(for: message)
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in self.scrollToMessageId = reply.message_id }
}
) )
} }
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) .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. /// PERF: static cache for decoded reply blobs avoids JSON decode on every re-render.
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:] @MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
@@ -880,7 +1074,10 @@ private extension ChatDetailView {
if let cached = Self.replyBlobCache[blob] { return cached } if let cached = Self.replyBlobCache[blob] { return cached }
guard let data = blob.data(using: .utf8) else { return nil } guard let data = blob.data(using: .utf8) else { return nil }
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) 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 Self.replyBlobCache[blob] = result
return result return result
} }
@@ -910,15 +1107,11 @@ private extension ChatDetailView {
.frame(width: 3) .frame(width: 3)
.padding(.vertical, 4) .padding(.vertical, 4)
// Optional image thumbnail for media replies (32×32) // Optional image thumbnail for media replies (32×32).
// PERF: uses static cache BlurHash decode is expensive (DCT transform). // Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache
if let hash = blurHash, // first (shows actual image), falling back to blurhash if not cached.
let image = Self.cachedBlurHash(hash, width: 32, height: 32) { if let att = imageAttachment {
Image(uiImage: image) ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(.leading, 6) .padding(.leading, 6)
} }
@@ -948,9 +1141,14 @@ private extension ChatDetailView {
.padding(.bottom, 0) .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. /// Resolves a public key to a display name for reply/forward quotes.
/// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the /// Checks: current user "You", current opponent route.title, any known dialog title (cached).
/// body path uses route data instead. Only the current opponent is resolved. /// Falls back to truncated public key if unknown.
private func senderDisplayName(for publicKey: String) -> String { private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey { if publicKey == currentPublicKey {
return "You" return "You"
@@ -959,7 +1157,33 @@ private extension ChatDetailView {
if publicKey == route.publicKey { if publicKey == route.publicKey {
return route.title.isEmpty ? String(publicKey.prefix(8)) + "" : route.title 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. /// Attachment message bubble: images/files with optional text caption.
@@ -978,8 +1202,10 @@ private extension ChatDetailView {
position: BubblePosition position: BubblePosition
) -> some View { ) -> some View {
let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " "
let imageAttachments = attachments.filter { $0.type == .image } // PERF: single-pass partition instead of 3 separate .filter() calls per cell.
let otherAttachments = attachments.filter { $0.type != .image } let partitioned = Self.partitionAttachments(attachments)
let imageAttachments = partitioned.images
let otherAttachments = partitioned.others
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -1051,10 +1277,24 @@ private extension ChatDetailView {
actions: bubbleActions(for: message), actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing), previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message), readStatusText: contextMenuReadStatus(for: message),
onTap: !imageAttachments.isEmpty ? { onTap: !attachments.isEmpty ? { tapLocation in
// Open the first image attachment in fullscreen viewer // All taps go through the overlay (UIView blocks SwiftUI below).
if let firstImage = imageAttachments.first { // Route to the correct handler based on what was tapped.
fullScreenAttachmentId = firstImage.id 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 } : nil
) )
@@ -1117,11 +1357,14 @@ private extension ChatDetailView {
@MainActor private static var blurHashCache: [String: UIImage] = [:] @MainActor private static var blurHashCache: [String: UIImage] = [:]
@MainActor @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)" let key = "\(hash)_\(width)x\(height)"
if let cached = blurHashCache[key] { return cached } if let cached = blurHashCache[key] { return cached }
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil } 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 blurHashCache[key] = image
return image return image
} }
@@ -1155,8 +1398,10 @@ private extension ChatDetailView {
} else { } else {
result = AttributedString(withEmoji) result = AttributedString(withEmoji)
} }
if Self.markdownCache.count > 200 { // PERF: evict oldest half instead of clearing all preserves hot entries during scroll.
Self.markdownCache.removeAll(keepingCapacity: true) 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 Self.markdownCache[text] = result
return result return result
@@ -1180,11 +1425,6 @@ private extension ChatDetailView {
var composer: some View { var composer: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
// Reply preview bar (Telegram-style)
if let replyMessage = replyingToMessage {
replyBar(for: replyMessage)
}
// Attachment preview strip shows selected images/files before send // Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty { if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments) AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
@@ -1214,79 +1454,71 @@ private extension ChatDetailView {
} }
.accessibilityLabel("Attach") .accessibilityLabel("Attach")
.buttonStyle(ChatDetailGlassPressButtonStyle()) .buttonStyle(ChatDetailGlassPressButtonStyle())
.sheet(isPresented: $showAttachmentPanel) {
AttachmentPanelView(
onSend: { attachments, caption in
// Pre-fill caption as message text (sent alongside attachments)
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
if !trimmedCaption.isEmpty {
messageText = trimmedCaption
}
handleAttachmentsSend(attachments)
},
onSendAvatar: {
sendAvatarToChat()
}
)
}
HStack(alignment: .bottom, spacing: 0) { VStack(spacing: 0) {
ChatTextInput( // Reply preview bar inside the glass container
text: $messageText, if let replyMessage = replyingToMessage {
isFocused: $isInputFocused, replyBar(for: replyMessage)
onKeyboardHeightChange: { height in
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
},
onUserTextInsertion: handleComposerUserTyping,
textColor: UIColor(RosettaColors.Adaptive.text),
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
)
.padding(.leading, 6)
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
HStack(alignment: .center, spacing: 0) {
Button { } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
}
.accessibilityLabel("Quick actions")
.buttonStyle(ChatDetailGlassPressButtonStyle())
} }
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
.frame(height: 36, alignment: .center) HStack(alignment: .bottom, spacing: 0) {
.overlay(alignment: .trailing) { ChatTextInput(
Button(action: sendCurrentMessage) { text: $messageText,
TelegramVectorIcon( isFocused: $isInputFocused,
pathData: TelegramIconPath.sendPlane, onKeyboardHeightChange: { height in
viewBox: CGSize(width: 22, height: 19), KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
color: .white },
) onUserTextInsertion: handleComposerUserTyping,
.opacity(0.42 + (0.58 * sendButtonProgress)) textColor: UIColor(RosettaColors.Adaptive.text),
.scaleEffect(0.72 + (0.28 * sendButtonProgress)) placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.frame(width: 22, height: 19)
.frame(width: sendButtonWidth, height: sendButtonHeight)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
.accessibilityLabel("Send")
.disabled(!canSend)
.buttonStyle(ChatDetailGlassPressButtonStyle())
.allowsHitTesting(shouldShowSendButton)
.opacity(Double(sendButtonProgress))
.scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing)
.blur(radius: (1 - sendButtonProgress) * 2.1)
.mask(
Capsule()
.frame(
width: max(0.001, sendButtonWidth * sendButtonProgress),
height: max(0.001, sendButtonHeight * sendButtonProgress)
)
.frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing)
) )
.padding(.leading, 6)
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
HStack(alignment: .center, spacing: 0) {
Button { } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
}
.accessibilityLabel("Quick actions")
.buttonStyle(ChatDetailGlassPressButtonStyle())
}
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
.frame(height: 36, alignment: .center)
.overlay(alignment: .trailing) {
Button(action: sendCurrentMessage) {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.opacity(0.42 + (0.58 * sendButtonProgress))
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
.frame(width: 22, height: 19)
.frame(width: sendButtonWidth, height: sendButtonHeight)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
.accessibilityLabel("Send")
.disabled(!canSend)
.buttonStyle(ChatDetailGlassPressButtonStyle())
.allowsHitTesting(shouldShowSendButton)
.opacity(Double(sendButtonProgress))
.scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing)
.blur(radius: (1 - sendButtonProgress) * 2.1)
.mask(
Capsule()
.frame(
width: max(0.001, sendButtonWidth * sendButtonProgress),
height: max(0.001, sendButtonHeight * sendButtonProgress)
)
.frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing)
)
}
} }
} }
.padding(3) .padding(3)
@@ -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) // MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
/// Determines bubble position within a group of consecutive same-sender plain-text messages. /// 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)) .glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
} }
} else { } else {
// iOS < 26: frosted glass with stroke + shadow (Figma spec) // iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
switch shape { switch shape {
case .capsule: case .capsule:
Capsule().fill(.thinMaterial) TelegramGlassCapsule()
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
case .circle: case .circle:
Circle().fill(.thinMaterial) TelegramGlassCircle()
.overlay { Circle().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
case let .rounded(radius): case let .rounded(radius):
let r = RoundedRectangle(cornerRadius: radius, style: .continuous) TelegramGlassRoundedRect(cornerRadius: radius)
r.fill(.thinMaterial)
.overlay { r.strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }
} }
@@ -1642,6 +1887,65 @@ private extension ChatDetailView {
return actions 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) { func retryMessage(_ message: ChatMessage) {
let text = message.text let text = message.text
let toKey = message.toPublicKey let toKey = message.toPublicKey
@@ -1661,26 +1965,44 @@ private extension ChatDetailView {
@ViewBuilder @ViewBuilder
func replyBar(for message: ChatMessage) -> some View { 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) let senderName = message.isFromMe(myPublicKey: currentPublicKey)
? "You" ? "You"
: (dialog?.opponentTitle ?? route.title) : (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "" : route.title)
let previewText = message.text.isEmpty let previewText: String = {
? (message.attachments.isEmpty ? "" : "Attachment") let trimmed = message.text.trimmingCharacters(in: .whitespaces)
: message.text 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) { HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5) RoundedRectangle(cornerRadius: 1.5)
.fill(RosettaColors.figmaBlue) .fill(RosettaColors.figmaBlue)
.frame(width: 3, height: 36) .frame(width: 3, height: 36)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 2) {
Text(senderName) HStack(spacing: 0) {
.font(.system(size: 14, weight: .semibold)) Text("Reply to ")
.foregroundStyle(RosettaColors.figmaBlue) .font(.system(size: 14, weight: .medium))
.lineLimit(1) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.figmaBlue)
}
.lineLimit(1)
Text(previewText) Text(previewText)
.font(.system(size: 14, weight: .regular)) .font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary) .foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1) .lineLimit(1)
} }
.padding(.leading, 8) .padding(.leading, 8)
@@ -1693,12 +2015,15 @@ private extension ChatDetailView {
} }
} label: { } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.frame(width: 30, height: 30) .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)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }
@@ -1755,7 +2080,11 @@ private extension ChatDetailView {
func messageTime(_ timestamp: Int64) -> String { func messageTime(_ timestamp: Int64) -> String {
if let cached = Self.timeCache[timestamp] { return cached } if let cached = Self.timeCache[timestamp] { return cached }
let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) 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 Self.timeCache[timestamp] = result
return result return result
} }
@@ -1823,8 +2152,6 @@ private extension ChatDetailView {
// Must have either text or attachments // Must have either text or attachments
guard !message.isEmpty || !attachments.isEmpty else { return } guard !message.isEmpty || !attachments.isEmpty else { return }
// User is sending a message reset idle timer.
SessionManager.shared.recordUserInteraction()
shouldScrollOnNextMessage = true shouldScrollOnNextMessage = true
messageText = "" messageText = ""
pendingAttachments = [] 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 { #Preview {
NavigationStack { NavigationStack {

View File

@@ -6,9 +6,11 @@ struct ForwardChatPickerView: View {
let onSelect: (ChatRoute) -> Void let onSelect: (ChatRoute) -> Void
@Environment(\.dismiss) private var dismiss @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] { private var dialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter { 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(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.1), lineWidth: 1)
)
.task { .task {
loadFromCache() loadFromCache()
if avatarImage == nil { if avatarImage == nil {

View File

@@ -73,16 +73,20 @@ struct MessageFileView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
.frame(width: 220) .frame(width: 220)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture {
if isDownloaded, let url = cachedFileURL {
shareFile(url)
} else if !isDownloading {
downloadFile()
}
}
.task { .task {
checkCache() 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()
}
}
}
} }
// MARK: - Metadata Parsing // MARK: - Metadata Parsing

View File

@@ -62,14 +62,20 @@ struct MessageImageView: View {
} else { } else {
placeholderView placeholderView
.overlay { downloadArrowOverlay } .overlay { downloadArrowOverlay }
.onTapGesture { downloadImage() }
} }
} }
.task { .task {
// Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO) // PERF: load cached image FIRST skip expensive BlurHash DCT decode
decodeBlurHash() // if the full image is already available.
loadFromCache() loadFromCache()
if image == nil { 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() downloadImage()
} }
} }
@@ -184,10 +190,23 @@ struct MessageImageView: View {
/// Decodes the blurhash from the attachment preview string once and caches in @State. /// Decodes the blurhash from the attachment preview string once and caches in @State.
/// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`. /// 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() { private func decodeBlurHash() {
let hash = extractBlurHash(from: attachment.preview) let hash = extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return } 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 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 blurImage = result
} }
} }

View File

@@ -224,9 +224,7 @@ struct OpponentProfileView: View {
if #available(iOS 26.0, *) { if #available(iOS 26.0, *) {
Capsule().fill(.clear).glassEffect(.regular, in: .capsule) Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
} else { } else {
Capsule().fill(.thinMaterial) TelegramGlassCapsule()
.overlay { Capsule().strokeBorder(Color.white.opacity(0.22), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }
@@ -240,12 +238,7 @@ struct OpponentProfileView: View {
in: RoundedRectangle(cornerRadius: 14, style: .continuous) in: RoundedRectangle(cornerRadius: 14, style: .continuous)
) )
} else { } else {
RoundedRectangle(cornerRadius: 14, style: .continuous) TelegramGlassRoundedRect(cornerRadius: 14)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
}
} }
} }
} }

View File

@@ -228,10 +228,7 @@ struct PhotoPreviewView: View {
.fill(.clear) .fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else { } else {
let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) TelegramGlassRoundedRect(cornerRadius: 21)
shape.fill(.thinMaterial)
.overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }

View File

@@ -17,15 +17,19 @@ struct SwipeToReplyModifier: ViewModifier {
@State private var offset: CGFloat = 0 @State private var offset: CGFloat = 0
@State private var hasTriggeredHaptic = false @State private var hasTriggeredHaptic = false
@State private var lockedAxis: SwipeAxis? @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 } private enum SwipeAxis { case horizontal, vertical }
/// Minimum drag distance to trigger reply action. /// Minimum drag distance to trigger reply action.
private let threshold: CGFloat = 50 private let threshold: CGFloat = 55
/// Offset where elastic resistance begins. /// Offset where elastic resistance begins.
private let elasticCap: CGFloat = 80 private let elasticCap: CGFloat = 85
/// Reply icon circle diameter. /// Reply icon circle diameter.
private let iconSize: CGFloat = 34 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 { func body(content: Content) -> some View {
content content
@@ -65,18 +69,28 @@ struct SwipeToReplyModifier: ViewModifier {
// MARK: - Gesture // MARK: - Gesture
private var dragGesture: some Gesture { private var dragGesture: some Gesture {
DragGesture(minimumDistance: 16, coordinateSpace: .local) DragGesture(minimumDistance: 20, coordinateSpace: .global)
.onChanged { value in .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 // Lock axis on first significant movement to avoid
// interfering with vertical scroll or back-swipe navigation. // interfering with vertical scroll or back-swipe navigation.
if lockedAxis == nil { if lockedAxis == nil {
let dx = abs(value.translation.width) let dx = abs(value.translation.width)
let dy = abs(value.translation.height) let dy = abs(value.translation.height)
if dx > 16 || dy > 16 { if dx > 20 || dy > 20 {
// Require clear horizontal dominance (2:1 ratio) // Require clear horizontal dominance (2.5:1 ratio)
// AND must be leftward right swipe is back navigation. // AND must be leftward right swipe is back navigation.
let isLeftward = value.translation.width < 0 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) // Haptic at threshold (once per gesture)
if abs(offset) >= threshold, !hasTriggeredHaptic { if abs(offset) >= threshold, !hasTriggeredHaptic {
hasTriggeredHaptic = true hasTriggeredHaptic = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
} }
} }
.onEnded { _ in .onEnded { _ in
@@ -111,6 +125,7 @@ struct SwipeToReplyModifier: ViewModifier {
} }
lockedAxis = nil lockedAxis = nil
hasTriggeredHaptic = false hasTriggeredHaptic = false
gestureStartX = nil
if shouldReply { if shouldReply {
onReply() 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, colorIndex: dialog.avatarColorIndex,
size: 62, size: 62,
isOnline: dialog.isOnline, 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 ?? "") 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> = [] @State private var typingDialogs: Set<String> = []
var body: some View { var body: some View {
// Compute once avoids 3× filter (allModeDialogs allModePinned allModeUnpinned). // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter).
let allDialogs = viewModel.allModeDialogs let pinned = viewModel.allModePinned
let pinned = allDialogs.filter(\.isPinned) let unpinned = viewModel.allModeUnpinned
let unpinned = allDialogs.filter { !$0.isPinned }
let requestsCount = viewModel.requestsCount let requestsCount = viewModel.requestsCount
Group { Group {
if allDialogs.isEmpty && !viewModel.isLoading { if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading {
SyncAwareEmptyState() SyncAwareEmptyState()
} else { } else {
dialogList( dialogList(
@@ -719,52 +718,44 @@ struct SyncAwareChatRow: View {
// MARK: - Device Approval Banner // 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 { private struct DeviceApprovalBanner: View {
let device: DeviceEntry let device: DeviceEntry
let onAccept: () -> Void let onAccept: () -> Void
let onDecline: () -> Void let onDecline: () -> Void
@State private var showAcceptConfirmation = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(spacing: 8) {
HStack(spacing: 10) { Text("New login from \(device.deviceName) (\(device.deviceOs))")
Image(systemName: "exclamationmark.shield") .font(.system(size: 13, weight: .regular))
.font(.system(size: 22)) .foregroundStyle(.white.opacity(0.45))
.foregroundStyle(RosettaColors.error) .multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 2) { HStack(spacing: 24) {
Text("New device login detected") Button("Accept") {
.font(.system(size: 14, weight: .semibold)) showAcceptConfirmation = true
.foregroundStyle(RosettaColors.Adaptive.text)
Text("\(device.deviceName) (\(device.deviceOs))")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} }
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.primaryBlue)
Spacer(minLength: 0) Button("Decline") {
onDecline()
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error.opacity(0.8))
} }
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!")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.error)
}
Spacer(minLength: 0)
}
.padding(.leading, 34)
} }
.padding(.horizontal, 16) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 10)
.background(RosettaColors.error.opacity(0.08)) .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.")
}
} }
} }

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] { var filteredDialogs: [Dialog] {
let all = DialogRepository.shared.sortedDialogs let p = partition
switch dialogsMode { switch dialogsMode {
case .all: case .all: return p.allPinned + p.allUnpinned
return all.filter { case .requests: return p.requests
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
case .requests:
return all.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
} }
} }
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) } var pinnedDialogs: [Dialog] { partition.allPinned }
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } } var unpinnedDialogs: [Dialog] { partition.allUnpinned }
/// 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 requestsCount: Int { partition.requests.count }
var hasRequests: Bool { requestsCount > 0 } var hasRequests: Bool { requestsCount > 0 }
var totalUnreadCount: Int { var totalUnreadCount: Int { partition.totalUnread }
DialogRepository.shared.dialogs.values
.lazy.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
var hasUnread: Bool { totalUnreadCount > 0 } var hasUnread: Bool { totalUnreadCount > 0 }
// MARK: - Per-mode dialogs (for TabView pages) // MARK: - Per-mode dialogs (for TabView pages)
/// "All" dialogs conversations where I have sent (+ Saved Messages + system accounts). var allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned }
/// Used by the All page in the swipeable TabView. var allModePinned: [Dialog] { partition.allPinned }
var allModeDialogs: [Dialog] { var allModeUnpinned: [Dialog] { partition.allUnpinned }
DialogRepository.shared.sortedDialogs.filter {
$0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey)
}
}
var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) } var requestsModeDialogs: [Dialog] { partition.requests }
var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } }
/// "Requests" dialogs conversations where only opponent has messaged me.
/// Used by the Requests page in the swipeable TabView.
var requestsModeDialogs: [Dialog] {
DialogRepository.shared.sortedDialogs.filter {
!$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey)
}
}
// MARK: - Actions // MARK: - Actions

View File

@@ -57,7 +57,7 @@ private extension ChatRowView {
size: 62, size: 62,
isOnline: dialog.isOnline, isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages, 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) .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 { var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing. // Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages { if isTyping && !dialog.isSavedMessages {
@@ -140,9 +143,18 @@ private extension ChatRowView {
if dialog.lastMessage.isEmpty { if dialog.lastMessage.isEmpty {
return "No messages yet" return "No messages yet"
} }
if let cached = Self.messageTextCache[dialog.lastMessage] {
return cached
}
// Strip inline markdown markers and convert emoji shortcodes for clean preview. // Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "") 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 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 { var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" } guard dialog.lastMessageTimestamp > 0 else { return "" }
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
return cached
}
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
let now = Date() let now = Date()
let calendar = Calendar.current let calendar = Calendar.current
let result: String
if calendar.isDateInToday(date) { if calendar.isDateInToday(date) {
return Self.timeFormatter.string(from: date) result = Self.timeFormatter.string(from: date)
} else if calendar.isDateInYesterday(date) { } else if calendar.isDateInYesterday(date) {
return "Yesterday" result = "Yesterday"
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { } 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 { } 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, *) { if #available(iOS 26.0, *) {
Capsule().fill(.clear).glassEffect(.regular, in: .capsule) Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
} else { } else {
Capsule().fill(.thinMaterial) TelegramGlassCapsule()
.overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) }
.shadow(color: .black.opacity(0.12), radius: 20, y: 8)
} }
} }

View File

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

View File

@@ -92,13 +92,17 @@ private extension ProfileEditView {
VStack(spacing: 0) { VStack(spacing: 0) {
// Display Name field // Display Name field
HStack { HStack {
TextField("First Name", text: $displayName) TextField("First Last", text: $displayName)
.font(.system(size: 17)) .font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
.onChange(of: displayName) { _, newValue in .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 { if !displayName.isEmpty {

View File

@@ -41,17 +41,20 @@ struct SafetyView: View {
let publicKey = SessionManager.shared.currentPublicKey let publicKey = SessionManager.shared.currentPublicKey
BiometricAuthManager.shared.clearAll(forAccount: publicKey) BiometricAuthManager.shared.clearAll(forAccount: publicKey)
AvatarRepository.shared.removeAvatar(publicKey: publicKey) AvatarRepository.shared.removeAvatar(publicKey: publicKey)
// Clear persisted chat files before session ends // Start fade overlay FIRST covers the screen before
DialogRepository.shared.reset(clearPersisted: true) // deleteAccount() triggers setActiveAccount(next) which
MessageRepository.shared.reset(clearPersisted: true) // would cause MainTabView .id() recreation.
// 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?() onLogout?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
DialogRepository.shared.reset(clearPersisted: true)
MessageRepository.shared.reset(clearPersisted: true)
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()
}
} }
} message: { } message: {
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") 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,15 +85,20 @@ struct SettingsView: View {
let publicKey = SessionManager.shared.currentPublicKey let publicKey = SessionManager.shared.currentPublicKey
BiometricAuthManager.shared.clearAll(forAccount: publicKey) BiometricAuthManager.shared.clearAll(forAccount: publicKey)
AvatarRepository.shared.removeAvatar(publicKey: publicKey) AvatarRepository.shared.removeAvatar(publicKey: publicKey)
DialogRepository.shared.reset(clearPersisted: true) // Start fade overlay FIRST covers the screen before
MessageRepository.shared.reset(clearPersisted: true) // deleteAccount() triggers setActiveAccount(next) which
let defaults = UserDefaults.standard // would cause MainTabView .id() recreation.
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?() onLogout?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
DialogRepository.shared.reset(clearPersisted: true)
MessageRepository.shared.reset(clearPersisted: true)
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()
}
} }
} message: { } message: {
Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") 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 } guard isSaving else { return }
isSaving = false isSaving = false
if let code = ResultCode(rawValue: result.resultCode), code == .success { if result.resultCode == ResultCode.usernameTaken.rawValue {
// Server confirmed update local profile + avatar 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) updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
commitPendingAvatar() commitPendingAvatar()
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false 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 // MARK: - Account Switcher Card
@State private var accountToDelete: Account?
@State private var showDeleteAccountSheet = false
private var accountSwitcherCard: some View { private var accountSwitcherCard: some View {
let currentKey = AccountManager.shared.currentAccount?.publicKey let currentKey = AccountManager.shared.currentAccount?.publicKey
let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey } let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey }
@@ -440,6 +446,18 @@ struct SettingsView: View {
} }
} }
.padding(.top, 16) .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 { 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 avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey)
let unread = totalUnreadCount(for: account.publicKey) let unread = totalUnreadCount(for: account.publicKey)
return Button { return AccountSwipeRow(
// Show fade overlay FIRST covers the screen immediately. position: position,
// Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in), onTap: {
// so the user never sees the account list re-render. onLogout?()
onLogout?() DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { AccountManager.shared.setActiveAccount(publicKey: account.publicKey)
AccountManager.shared.setActiveAccount(publicKey: account.publicKey) SessionManager.shared.endSession()
SessionManager.shared.endSession() }
},
onDelete: {
accountToDelete = account
showDeleteAccountSheet = true
} }
} label: { ) {
HStack(spacing: 12) { HStack(spacing: 12) {
AvatarView( AvatarView(
initials: initials, initials: initials,
@@ -487,10 +509,7 @@ struct SettingsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 48) .frame(height: 48)
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
.settingsHighlight(position: position)
} }
private func addAccountRow(position: SettingsRowPosition) -> some View { private func addAccountRow(position: SettingsRowPosition) -> some View {
@@ -519,6 +538,17 @@ struct SettingsView: View {
/// Calculates total unread count for the given account's dialogs. /// Calculates total unread count for the given account's dialogs.
/// Only returns meaningful data for the currently active session. /// 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 { private func totalUnreadCount(for accountPublicKey: String) -> Int {
guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 } guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 }
return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount } 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> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.rosetta.dev</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -64,22 +64,27 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// MARK: - Background Push (Badge + Local Notification with Sound) // MARK: - Background Push (Badge + Local Notification with Sound)
/// Called when a push notification arrives with `content-available: 1`. /// Called when a push notification arrives with `content-available: 1`.
/// Server does NOT send `sound` in APNs payload we always create a local /// Two scenarios:
/// notification with `.default` sound to ensure vibration works. /// 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( func application(
_ application: UIApplication, _ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any], didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void 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 { guard application.applicationState != .active else {
completionHandler(.noData) completionHandler(.noData)
return return
} }
// Background/inactive: increment badge from persisted count. let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count")
// Background/inactive: increment badge from shared App Group storage.
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
let newBadge = currentBadge + 1 let newBadge = currentBadge + 1
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count") UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge) UNUserNotificationCenter.current().setBadgeCount(newBadge)
@@ -87,33 +92,43 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let senderName = userInfo["sender_name"] as? String ?? "New message" let senderName = userInfo["sender_name"] as? String ?? "New message"
let messageText = userInfo["message"] as? String ?? "New message" let messageText = userInfo["message"] as? String ?? "New message"
// Don't notify for muted chats. // Check if the server already sent a visible alert (aps.alert exists).
let isMuted = Task { @MainActor in // If so, NSE already modified it don't create a duplicate local notification.
DialogRepository.shared.dialogs[senderKey]?.isMuted == true 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
} }
Task {
let muted = await isMuted.value
guard !muted else {
completionHandler(.newData)
return
}
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = senderName content.title = senderName
content.body = messageText content.body = messageText
content.sound = .default content.sound = .default
content.badge = NSNumber(value: newBadge) content.badge = NSNumber(value: newBadge)
content.categoryIdentifier = "message" content.categoryIdentifier = "message"
if !senderKey.isEmpty { if !senderKey.isEmpty {
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
} }
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))", identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))",
content: content, content: content,
trigger: nil 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) 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") static let openChatFromNotification = Notification.Name("openChatFromNotification")
/// Posted when own profile (displayName/username) is updated from the server. /// Posted when own profile (displayName/username) is updated from the server.
static let profileDidUpdate = Notification.Name("profileDidUpdate") 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>