Уведомления в фоне, оптимизация FPS чата, release notes, read receipts паритет с Android
This commit is contained in:
@@ -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 */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 idle→active 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
**Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов
|
|
||||||
**Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
Rosetta/DesignSystem/Components/TelegramGlassView.swift
Normal file
274
Rosetta/DesignSystem/Components/TelegramGlassView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal file
169
Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal file
198
Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? "")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
31
RosettaNotificationService/Info.plist
Normal file
31
RosettaNotificationService/Info.plist
Normal 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>
|
||||||
54
RosettaNotificationService/NotificationService.swift
Normal file
54
RosettaNotificationService/NotificationService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user