Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ CLAUDE.md
|
|||||||
.claude.local.md
|
.claude.local.md
|
||||||
desktop
|
desktop
|
||||||
server
|
server
|
||||||
|
docs
|
||||||
Telegram-iOS
|
Telegram-iOS
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,19 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; };
|
||||||
|
3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; };
|
||||||
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
|
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
|
||||||
|
4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */; };
|
||||||
|
806C964D76E024430307C151 /* 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 */; };
|
||||||
85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; };
|
85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; };
|
||||||
|
CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; };
|
||||||
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; };
|
||||||
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; };
|
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; };
|
||||||
|
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.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 */; };
|
||||||
@@ -26,6 +33,13 @@
|
|||||||
remoteGlobalIDString = E47730762E9823BA2D02A197;
|
remoteGlobalIDString = E47730762E9823BA2D02A197;
|
||||||
remoteInfo = RosettaNotificationService;
|
remoteInfo = RosettaNotificationService;
|
||||||
};
|
};
|
||||||
|
D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 853F29612F4B50410092AD05;
|
||||||
|
remoteInfo = Rosetta;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -44,10 +58,17 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; };
|
||||||
|
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.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; };
|
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; };
|
||||||
|
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = "<group>"; };
|
||||||
|
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SchemaParityTests.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
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; };
|
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = "<group>"; };
|
||||||
|
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -59,6 +80,14 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
392BE571D30FB1DDB2423F0D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
806C964D76E024430307C151 /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
853F295F2F4B50410092AD05 /* Frameworks */ = {
|
853F295F2F4B50410092AD05 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -83,6 +112,19 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
0D5BD0581AA976925F688CDA /* RosettaTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */,
|
||||||
|
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */,
|
||||||
|
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */,
|
||||||
|
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */,
|
||||||
|
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */,
|
||||||
|
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */,
|
||||||
|
);
|
||||||
|
path = RosettaTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
32A246700D4A2618B3F81039 /* iOS */ = {
|
32A246700D4A2618B3F81039 /* iOS */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -98,6 +140,7 @@
|
|||||||
853F29632F4B50410092AD05 /* Products */,
|
853F29632F4B50410092AD05 /* Products */,
|
||||||
95676C1A4D239B1FF9E73782 /* Frameworks */,
|
95676C1A4D239B1FF9E73782 /* Frameworks */,
|
||||||
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
|
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
|
||||||
|
0D5BD0581AA976925F688CDA /* RosettaTests */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -106,6 +149,7 @@
|
|||||||
children = (
|
children = (
|
||||||
853F29622F4B50410092AD05 /* Rosetta.app */,
|
853F29622F4B50410092AD05 /* Rosetta.app */,
|
||||||
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
|
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
|
||||||
|
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -130,6 +174,24 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
219188CF4FCBF8E8CF11BEC2 /* RosettaTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 2C9787043011C6880A9B5CFD /* Build configuration list for PBXNativeTarget "RosettaTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
898AFF6966F70D158AA7D3A5 /* Sources */,
|
||||||
|
392BE571D30FB1DDB2423F0D /* Frameworks */,
|
||||||
|
7158F51EC726FE216D7D8374 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
48C7E25DF2079AC460C3DCE2 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = RosettaTests;
|
||||||
|
productName = RosettaTests;
|
||||||
|
productReference = 75BA8A97FE297E450BB1452E /* RosettaTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
853F29612F4B50410092AD05 /* Rosetta */ = {
|
853F29612F4B50410092AD05 /* Rosetta */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */;
|
buildConfigurationList = 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */;
|
||||||
@@ -217,11 +279,19 @@
|
|||||||
targets = (
|
targets = (
|
||||||
853F29612F4B50410092AD05 /* Rosetta */,
|
853F29612F4B50410092AD05 /* Rosetta */,
|
||||||
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
||||||
|
219188CF4FCBF8E8CF11BEC2 /* RosettaTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
7158F51EC726FE216D7D8374 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
853F29602F4B50410092AD05 /* Resources */ = {
|
853F29602F4B50410092AD05 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -246,6 +316,19 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
898AFF6966F70D158AA7D3A5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */,
|
||||||
|
3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */,
|
||||||
|
CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */,
|
||||||
|
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */,
|
||||||
|
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */,
|
||||||
|
4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
A624149985830F8CA8C2E52D /* Sources */ = {
|
A624149985830F8CA8C2E52D /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -263,6 +346,12 @@
|
|||||||
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
|
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
|
||||||
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
|
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
48C7E25DF2079AC460C3DCE2 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = Rosetta;
|
||||||
|
target = 853F29612F4B50410092AD05 /* Rosetta */;
|
||||||
|
targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -522,9 +611,53 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
9CC9EC814343CC91E1C020A3 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rosetta.app/Rosetta";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
C19929D9466573F31997B2C0 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.devTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rosetta.app/Rosetta";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
2C9787043011C6880A9B5CFD /* Build configuration list for PBXNativeTarget "RosettaTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
9CC9EC814343CC91E1C020A3 /* Debug */,
|
||||||
|
C19929D9466573F31997B2C0 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
|
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2620"
|
LastUpgradeVersion = "2620"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES"
|
||||||
@@ -21,14 +21,49 @@
|
|||||||
ReferencedContainer = "container:Rosetta.xcodeproj">
|
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "219188CF4FCBF8E8CF11BEC2"
|
||||||
|
BuildableName = "RosettaTests.xctest"
|
||||||
|
BlueprintName = "RosettaTests"
|
||||||
|
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "YES">
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "853F29612F4B50410092AD05"
|
||||||
|
BuildableName = "Rosetta.app"
|
||||||
|
BlueprintName = "Rosetta"
|
||||||
|
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "219188CF4FCBF8E8CF11BEC2"
|
||||||
|
BuildableName = "RosettaTests.xctest"
|
||||||
|
BlueprintName = "RosettaTests"
|
||||||
|
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import GRDB
|
|||||||
final class DatabaseManager {
|
final class DatabaseManager {
|
||||||
|
|
||||||
static let shared = DatabaseManager()
|
static let shared = DatabaseManager()
|
||||||
|
/// Internal migration identifiers exposed for migration harness tests.
|
||||||
|
nonisolated static let migrationIdentifiers: [String] = [
|
||||||
|
"v1_initial",
|
||||||
|
"v2_sync_cursors",
|
||||||
|
"v3_split_read_status",
|
||||||
|
"v4_schema_parity_android_desktop",
|
||||||
|
"v5_full_schema_superset_parity",
|
||||||
|
"v6_bidirectional_alias_sync",
|
||||||
|
"v7_sync_cursor_reconcile_and_perf_indexes",
|
||||||
|
]
|
||||||
|
nonisolated static let migrationV7SyncCursorReconcile = "v7_sync_cursor_reconcile_and_perf_indexes"
|
||||||
|
|
||||||
private var dbPool: DatabasePool?
|
private var dbPool: DatabasePool?
|
||||||
private var currentAccount: String = ""
|
private var currentAccount: String = ""
|
||||||
@@ -175,6 +186,9 @@ final class DatabaseManager {
|
|||||||
WHERE m.account = dialogs.account
|
WHERE m.account = dialogs.account
|
||||||
AND m.dialog_key = CASE
|
AND m.dialog_key = CASE
|
||||||
WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account
|
WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account
|
||||||
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key
|
||||||
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key
|
||||||
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'conversation:%' THEN dialogs.opponent_key
|
||||||
WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
|
WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
|
||||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||||
END
|
END
|
||||||
@@ -198,9 +212,22 @@ final class DatabaseManager {
|
|||||||
if try db.tableExists("sync_cursors") {
|
if try db.tableExists("sync_cursors") {
|
||||||
try db.execute(
|
try db.execute(
|
||||||
sql: """
|
sql: """
|
||||||
INSERT INTO accounts_sync_times (account, last_sync)
|
INSERT OR IGNORE INTO accounts_sync_times (account, last_sync)
|
||||||
SELECT account, timestamp FROM sync_cursors
|
SELECT account, timestamp FROM sync_cursors
|
||||||
ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync
|
"""
|
||||||
|
)
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
UPDATE accounts_sync_times
|
||||||
|
SET last_sync = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account),
|
||||||
|
last_sync
|
||||||
|
) > last_sync
|
||||||
|
THEN (SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account)
|
||||||
|
ELSE last_sync
|
||||||
|
END
|
||||||
|
WHERE account IN (SELECT account FROM sync_cursors)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -393,6 +420,7 @@ final class DatabaseManager {
|
|||||||
WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account
|
WHEN dialogs.account = dialogs.opponent_key THEN dialogs.account
|
||||||
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE '#group:%' THEN dialogs.opponent_key
|
||||||
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'group:%' THEN dialogs.opponent_key
|
||||||
|
WHEN LOWER(TRIM(dialogs.opponent_key)) LIKE 'conversation:%' THEN dialogs.opponent_key
|
||||||
WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
|
WHEN dialogs.account < dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
|
||||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||||
END
|
END
|
||||||
@@ -421,6 +449,7 @@ final class DatabaseManager {
|
|||||||
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
||||||
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key
|
||||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||||
ELSE NEW.opponent_key || ':' || NEW.account
|
ELSE NEW.opponent_key || ':' || NEW.account
|
||||||
END
|
END
|
||||||
@@ -435,6 +464,7 @@ final class DatabaseManager {
|
|||||||
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
||||||
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key
|
||||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||||
ELSE NEW.opponent_key || ':' || NEW.account
|
ELSE NEW.opponent_key || ':' || NEW.account
|
||||||
END
|
END
|
||||||
@@ -462,6 +492,7 @@ final class DatabaseManager {
|
|||||||
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
||||||
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key
|
||||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||||
ELSE NEW.opponent_key || ':' || NEW.account
|
ELSE NEW.opponent_key || ':' || NEW.account
|
||||||
END
|
END
|
||||||
@@ -476,6 +507,7 @@ final class DatabaseManager {
|
|||||||
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
WHEN NEW.account = NEW.opponent_key THEN NEW.account
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE '#group:%' THEN NEW.opponent_key
|
||||||
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'group:%' THEN NEW.opponent_key
|
||||||
|
WHEN LOWER(TRIM(NEW.opponent_key)) LIKE 'conversation:%' THEN NEW.opponent_key
|
||||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||||
ELSE NEW.opponent_key || ':' || NEW.account
|
ELSE NEW.opponent_key || ':' || NEW.account
|
||||||
END
|
END
|
||||||
@@ -555,6 +587,202 @@ final class DatabaseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v6: enforce bidirectional alias sync so canonical and iOS-native writes stay semantically identical.
|
||||||
|
migrator.registerMigration("v6_bidirectional_alias_sync") { db in
|
||||||
|
// MARK: - messages alias <-> native sync
|
||||||
|
|
||||||
|
try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_ai_sync_aliases")
|
||||||
|
try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_au_sync_aliases")
|
||||||
|
try db.execute(sql: "DROP TRIGGER IF EXISTS trg_messages_au_sync_native")
|
||||||
|
|
||||||
|
// INSERT normalization: supports both native-first and canonical-first writes.
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE TRIGGER trg_messages_ai_sync_aliases
|
||||||
|
AFTER INSERT ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE messages
|
||||||
|
SET is_read = CASE
|
||||||
|
WHEN NEW.is_read != NEW.read THEN
|
||||||
|
CASE WHEN NEW.read != 0 THEN NEW.read ELSE NEW.is_read END
|
||||||
|
ELSE NEW.is_read
|
||||||
|
END,
|
||||||
|
read = CASE
|
||||||
|
WHEN NEW.is_read != NEW.read THEN
|
||||||
|
CASE WHEN NEW.read != 0 THEN NEW.read ELSE NEW.is_read END
|
||||||
|
ELSE NEW.read
|
||||||
|
END,
|
||||||
|
delivery_status = CASE
|
||||||
|
WHEN NEW.delivery_status = 0 AND NEW.delivered != 0 THEN NEW.delivered
|
||||||
|
ELSE NEW.delivery_status
|
||||||
|
END,
|
||||||
|
delivered = CASE
|
||||||
|
WHEN NEW.delivery_status = 0 AND NEW.delivered != 0 THEN NEW.delivered
|
||||||
|
ELSE NEW.delivery_status
|
||||||
|
END,
|
||||||
|
text = CASE
|
||||||
|
WHEN NEW.text = '' AND NEW.plain_message != '' THEN NEW.plain_message
|
||||||
|
ELSE NEW.text
|
||||||
|
END,
|
||||||
|
plain_message = CASE
|
||||||
|
WHEN NEW.plain_message = '' THEN NEW.text
|
||||||
|
ELSE NEW.plain_message
|
||||||
|
END
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Native -> canonical sync.
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE TRIGGER trg_messages_au_sync_aliases
|
||||||
|
AFTER UPDATE OF is_read, delivery_status, text ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE messages
|
||||||
|
SET read = NEW.is_read,
|
||||||
|
delivered = NEW.delivery_status,
|
||||||
|
plain_message = CASE
|
||||||
|
WHEN NEW.plain_message = '' THEN NEW.text
|
||||||
|
ELSE NEW.plain_message
|
||||||
|
END
|
||||||
|
WHERE id = NEW.id
|
||||||
|
AND (read != NEW.is_read OR delivered != NEW.delivery_status OR plain_message != CASE
|
||||||
|
WHEN NEW.plain_message = '' THEN NEW.text
|
||||||
|
ELSE NEW.plain_message
|
||||||
|
END);
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Canonical -> native sync.
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE TRIGGER trg_messages_au_sync_native
|
||||||
|
AFTER UPDATE OF read, delivered, plain_message ON messages
|
||||||
|
BEGIN
|
||||||
|
UPDATE messages
|
||||||
|
SET is_read = NEW.read,
|
||||||
|
delivery_status = NEW.delivered,
|
||||||
|
text = CASE
|
||||||
|
WHEN NEW.plain_message = '' THEN NEW.text
|
||||||
|
ELSE NEW.plain_message
|
||||||
|
END
|
||||||
|
WHERE id = NEW.id
|
||||||
|
AND (is_read != NEW.read OR delivery_status != NEW.delivered OR text != CASE
|
||||||
|
WHEN NEW.plain_message = '' THEN NEW.text
|
||||||
|
ELSE NEW.plain_message
|
||||||
|
END);
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - dialogs alias -> native compatibility mapping
|
||||||
|
|
||||||
|
try db.execute(sql: "DROP TRIGGER IF EXISTS trg_dialogs_au_sync_native")
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE TRIGGER trg_dialogs_au_sync_native
|
||||||
|
AFTER UPDATE OF dialog_id, last_timestamp, is_request ON dialogs
|
||||||
|
BEGIN
|
||||||
|
UPDATE dialogs
|
||||||
|
SET opponent_key = CASE
|
||||||
|
WHEN NEW.dialog_id = '' THEN NEW.opponent_key
|
||||||
|
ELSE NEW.dialog_id
|
||||||
|
END,
|
||||||
|
last_message_timestamp = CASE
|
||||||
|
WHEN NEW.last_timestamp = 0 THEN NEW.last_message_timestamp
|
||||||
|
ELSE NEW.last_timestamp
|
||||||
|
END,
|
||||||
|
i_have_sent = CASE
|
||||||
|
WHEN NEW.is_request = 1 THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
WHERE id = NEW.id
|
||||||
|
AND (
|
||||||
|
opponent_key != CASE
|
||||||
|
WHEN NEW.dialog_id = '' THEN NEW.opponent_key
|
||||||
|
ELSE NEW.dialog_id
|
||||||
|
END
|
||||||
|
OR last_message_timestamp != CASE
|
||||||
|
WHEN NEW.last_timestamp = 0 THEN NEW.last_message_timestamp
|
||||||
|
ELSE NEW.last_timestamp
|
||||||
|
END
|
||||||
|
OR i_have_sent != CASE
|
||||||
|
WHEN NEW.is_request = 1 THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
);
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v7: reconcile sync cursor data safely on all SQLite variants and add perf indexes.
|
||||||
|
migrator.registerMigration("v7_sync_cursor_reconcile_and_perf_indexes") { db in
|
||||||
|
func hasColumn(_ table: String, _ column: String) throws -> Bool {
|
||||||
|
try db.columns(in: table).contains { $0.name == column }
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts_sync_times (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account TEXT NOT NULL UNIQUE,
|
||||||
|
last_sync INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if try !hasColumn("accounts_sync_times", "id") {
|
||||||
|
try db.execute(sql: "ALTER TABLE accounts_sync_times ADD COLUMN id INTEGER")
|
||||||
|
}
|
||||||
|
|
||||||
|
if try db.tableExists("sync_cursors") {
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
INSERT OR IGNORE INTO accounts_sync_times (account, last_sync)
|
||||||
|
SELECT account, timestamp FROM sync_cursors
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
UPDATE accounts_sync_times
|
||||||
|
SET last_sync = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account),
|
||||||
|
last_sync
|
||||||
|
) > last_sync
|
||||||
|
THEN (SELECT s.timestamp FROM sync_cursors s WHERE s.account = accounts_sync_times.account)
|
||||||
|
ELSE last_sync
|
||||||
|
END
|
||||||
|
WHERE account IN (SELECT account FROM sync_cursors)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
UPDATE accounts_sync_times
|
||||||
|
SET id = rowid
|
||||||
|
WHERE id IS NULL OR id = 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_account_dialog_fromme_isread
|
||||||
|
ON messages(account, dialog_key, from_me, is_read)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_account_dialog_fromme_timestamp
|
||||||
|
ON messages(account, dialog_key, from_me, timestamp)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try migrator.migrate(pool)
|
try migrator.migrate(pool)
|
||||||
dbPool = pool
|
dbPool = pool
|
||||||
|
|
||||||
@@ -600,7 +828,7 @@ final class DatabaseManager {
|
|||||||
|
|
||||||
// MARK: - Database URL
|
// MARK: - Database URL
|
||||||
|
|
||||||
private static func databaseURL(for accountPublicKey: String) -> URL {
|
nonisolated private static func databaseURL(for accountPublicKey: String) -> URL {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
|
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
@@ -614,7 +842,12 @@ final class DatabaseManager {
|
|||||||
return dir.appendingPathComponent("rosetta_\(normalized).sqlite")
|
return dir.appendingPathComponent("rosetta_\(normalized).sqlite")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizedKey(_ key: String) -> String {
|
/// Internal test-support accessor for deterministic DB path setup in unit tests.
|
||||||
|
nonisolated static func databaseURLForTesting(accountPublicKey: String) -> URL {
|
||||||
|
databaseURL(for: accountPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func normalizedKey(_ key: String) -> String {
|
||||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty { return "anonymous" }
|
if trimmed.isEmpty { return "anonymous" }
|
||||||
return String(trimmed.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" })
|
return String(trimmed.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" })
|
||||||
@@ -622,13 +855,29 @@ final class DatabaseManager {
|
|||||||
|
|
||||||
// MARK: - Dialog Key (Android parity)
|
// MARK: - Dialog Key (Android parity)
|
||||||
|
|
||||||
|
/// Returns true for group/conversation dialog identifiers that should not be pair-sorted.
|
||||||
|
nonisolated static func isGroupDialogKey(_ value: String) -> Bool {
|
||||||
|
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard !normalized.isEmpty else { return false }
|
||||||
|
return normalized.hasPrefix("#group:")
|
||||||
|
|| normalized.hasPrefix("group:")
|
||||||
|
|| normalized.hasPrefix("conversation:")
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute dialog_key from account and opponent public keys.
|
/// Compute dialog_key from account and opponent public keys.
|
||||||
/// Android: `MessageRepository.getDialogKey()` — sorted pair for direct chats.
|
/// Android: `MessageRepository.getDialogKey()` — sorted pair for direct chats.
|
||||||
nonisolated static func dialogKey(account: String, opponentKey: String) -> String {
|
nonisolated static func dialogKey(account: String, opponentKey: String) -> String {
|
||||||
|
let normalizedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedOpponent = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Group/conversation dialogs keep stable server-provided identity.
|
||||||
|
if isGroupDialogKey(normalizedOpponent) { return normalizedOpponent }
|
||||||
// Saved Messages: dialogKey = account
|
// Saved Messages: dialogKey = account
|
||||||
if account == opponentKey { return account }
|
if normalizedAccount == normalizedOpponent { return normalizedAccount }
|
||||||
// Normal: lexicographic sort
|
// Normal: lexicographic sort
|
||||||
return account < opponentKey ? "\(account):\(opponentKey)" : "\(opponentKey):\(account)"
|
return normalizedAccount < normalizedOpponent
|
||||||
|
? "\(normalizedAccount):\(normalizedOpponent)"
|
||||||
|
: "\(normalizedOpponent):\(normalizedAccount)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync Cursor (Android parity: SQLite, not UserDefaults)
|
// MARK: - Sync Cursor (Android parity: SQLite, not UserDefaults)
|
||||||
@@ -684,10 +933,21 @@ final class DatabaseManager {
|
|||||||
sql: """
|
sql: """
|
||||||
INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?)
|
INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?)
|
||||||
ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync
|
ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync
|
||||||
""",
|
""",
|
||||||
arguments: [account, timestamp]
|
arguments: [account, timestamp]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if try db.columns(in: "accounts_sync_times").contains(where: { $0.name == "id" }) {
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
UPDATE accounts_sync_times
|
||||||
|
SET id = rowid
|
||||||
|
WHERE account = ? AND (id IS NULL OR id = 0)
|
||||||
|
""",
|
||||||
|
arguments: [account]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if try db.tableExists("sync_cursors") {
|
if try db.tableExists("sync_cursors") {
|
||||||
try db.execute(
|
try db.execute(
|
||||||
sql: """
|
sql: """
|
||||||
@@ -709,3 +969,195 @@ final class DatabaseManager {
|
|||||||
try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm"))
|
try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + "-shm"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Group Repository (SQLite)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GroupRepository {
|
||||||
|
static let shared = GroupRepository()
|
||||||
|
|
||||||
|
private static let groupInvitePassword = "rosetta_group"
|
||||||
|
private let db = DatabaseManager.shared
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
struct GroupMetadata: Equatable {
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ParsedGroupInvite {
|
||||||
|
let groupId: String
|
||||||
|
let title: String
|
||||||
|
let encryptKey: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGroupDialog(_ value: String) -> Bool {
|
||||||
|
DatabaseManager.isGroupDialogKey(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGroupId(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
if lower.hasPrefix("#group:") {
|
||||||
|
return String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("group:") {
|
||||||
|
return String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("conversation:") {
|
||||||
|
return String(trimmed.dropFirst("conversation:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func toGroupDialogKey(_ value: String) -> String {
|
||||||
|
let normalized = normalizeGroupId(value)
|
||||||
|
guard !normalized.isEmpty else {
|
||||||
|
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return "#group:\(normalized)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? {
|
||||||
|
let groupId = normalizeGroupId(groupDialogKey)
|
||||||
|
guard !groupId.isEmpty else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let storedKey = try db.read { db in
|
||||||
|
try String.fetchOne(
|
||||||
|
db,
|
||||||
|
sql: """
|
||||||
|
SELECT "key"
|
||||||
|
FROM groups
|
||||||
|
WHERE account = ? AND group_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
arguments: [account, groupId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let storedKey, !storedKey.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if let decrypted = try? CryptoManager.shared.decryptWithPassword(storedKey, password: privateKeyHex),
|
||||||
|
let plain = String(data: decrypted, encoding: .utf8),
|
||||||
|
!plain.isEmpty {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: tolerate legacy plain stored keys.
|
||||||
|
return storedKey
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? {
|
||||||
|
let groupId = normalizeGroupId(groupDialogKey)
|
||||||
|
guard !groupId.isEmpty else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try db.read { db in
|
||||||
|
guard let row = try Row.fetchOne(
|
||||||
|
db,
|
||||||
|
sql: """
|
||||||
|
SELECT title, description
|
||||||
|
FROM groups
|
||||||
|
WHERE account = ? AND group_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
arguments: [account, groupId]
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return GroupMetadata(
|
||||||
|
title: row["title"],
|
||||||
|
description: row["description"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func upsertFromGroupJoin(
|
||||||
|
account: String,
|
||||||
|
privateKeyHex: String,
|
||||||
|
packet: PacketGroupJoin
|
||||||
|
) -> String? {
|
||||||
|
guard packet.status == .joined else { return nil }
|
||||||
|
guard !packet.groupString.isEmpty else { return nil }
|
||||||
|
|
||||||
|
guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword(
|
||||||
|
packet.groupString,
|
||||||
|
password: privateKeyHex
|
||||||
|
), let inviteString = String(data: decryptedInviteData, encoding: .utf8),
|
||||||
|
let parsed = parseGroupInviteString(inviteString)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword(
|
||||||
|
Data(parsed.encryptKey.utf8),
|
||||||
|
password: privateKeyHex
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try db.writeSync { db in
|
||||||
|
try db.execute(
|
||||||
|
sql: """
|
||||||
|
INSERT INTO groups (account, group_id, title, description, "key")
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account, group_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
description = excluded.description,
|
||||||
|
"key" = excluded."key"
|
||||||
|
""",
|
||||||
|
arguments: [account, parsed.groupId, parsed.title, parsed.description, encryptedGroupKey]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return toGroupDialogKey(parsed.groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseGroupInviteString(_ inviteString: String) -> ParsedGroupInvite? {
|
||||||
|
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
|
||||||
|
let encodedPayload: String
|
||||||
|
if lower.hasPrefix("#group:") {
|
||||||
|
encodedPayload = String(trimmed.dropFirst("#group:".count))
|
||||||
|
} else if lower.hasPrefix("group:") {
|
||||||
|
encodedPayload = String(trimmed.dropFirst("group:".count))
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard !encodedPayload.isEmpty else { return nil }
|
||||||
|
|
||||||
|
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||||
|
encodedPayload,
|
||||||
|
password: Self.groupInvitePassword
|
||||||
|
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
||||||
|
guard parts.count >= 3 else { return nil }
|
||||||
|
|
||||||
|
let groupId = normalizeGroupId(parts[0])
|
||||||
|
guard !groupId.isEmpty else { return nil }
|
||||||
|
|
||||||
|
return ParsedGroupInvite(
|
||||||
|
groupId: groupId,
|
||||||
|
title: parts[1],
|
||||||
|
encryptKey: parts[2],
|
||||||
|
description: parts.dropFirst(3).joined(separator: ":")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Images
|
// MARK: - Images
|
||||||
|
|
||||||
|
/// Returns an image only if it's already in in-memory cache.
|
||||||
|
/// Never touches disk/crypto — safe for hot UI paths (scrolling/layout).
|
||||||
|
nonisolated func cachedImage(forAttachmentId id: String) -> UIImage? {
|
||||||
|
imageCache.object(forKey: id as NSString)
|
||||||
|
}
|
||||||
|
|
||||||
/// Saves a decoded image to cache, encrypted with private key (Android parity).
|
/// Saves a decoded image to cache, encrypted with private key (Android parity).
|
||||||
nonisolated func saveImage(_ image: UIImage, forAttachmentId id: String) {
|
nonisolated func saveImage(_ image: UIImage, forAttachmentId id: String) {
|
||||||
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ final class DialogRepository {
|
|||||||
let account = currentAccount
|
let account = currentAccount
|
||||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||||
let isSavedMessages = account == opponentKey
|
let isSavedMessages = account == opponentKey
|
||||||
|
let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)
|
||||||
|
|
||||||
// Preserve fields not derived from messages
|
// Preserve fields not derived from messages
|
||||||
let existing = dialogs[opponentKey]
|
let existing = dialogs[opponentKey]
|
||||||
@@ -163,6 +164,7 @@ final class DialogRepository {
|
|||||||
case .file: lastMessageText = "File"
|
case .file: lastMessageText = "File"
|
||||||
case .avatar: lastMessageText = "Avatar"
|
case .avatar: lastMessageText = "Avatar"
|
||||||
case .messages: lastMessageText = "Forwarded message"
|
case .messages: lastMessageText = "Forwarded message"
|
||||||
|
case .call: lastMessageText = "Call"
|
||||||
}
|
}
|
||||||
} else if textIsEmpty {
|
} else if textIsEmpty {
|
||||||
lastMessageText = ""
|
lastMessageText = ""
|
||||||
@@ -187,7 +189,8 @@ final class DialogRepository {
|
|||||||
dialog.lastMessage = lastMessageText
|
dialog.lastMessage = lastMessageText
|
||||||
dialog.lastMessageTimestamp = lastMsg.timestamp
|
dialog.lastMessageTimestamp = lastMsg.timestamp
|
||||||
dialog.unreadCount = unread
|
dialog.unreadCount = unread
|
||||||
dialog.iHaveSent = hasSent || isSystem
|
// DB+Flow Safe: group dialogs are always treated as chat (not request).
|
||||||
|
dialog.iHaveSent = hasSent || isSystem || isGroupDialog
|
||||||
dialog.lastMessageFromMe = lastFromMe
|
dialog.lastMessageFromMe = lastFromMe
|
||||||
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
|
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
|
||||||
// Android parity: separate read flag from last outgoing message's is_read column.
|
// Android parity: separate read flag from last outgoing message's is_read column.
|
||||||
@@ -203,6 +206,7 @@ final class DialogRepository {
|
|||||||
opponentKey: String, title: String, username: String,
|
opponentKey: String, title: String, username: String,
|
||||||
verified: Int = 0, myPublicKey: String
|
verified: Int = 0, myPublicKey: String
|
||||||
) {
|
) {
|
||||||
|
let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)
|
||||||
if var existing = dialogs[opponentKey] {
|
if var existing = dialogs[opponentKey] {
|
||||||
var changed = false
|
var changed = false
|
||||||
if !title.isEmpty, existing.opponentTitle != title {
|
if !title.isEmpty, existing.opponentTitle != title {
|
||||||
@@ -214,6 +218,9 @@ final class DialogRepository {
|
|||||||
if verified > existing.verified {
|
if verified > existing.verified {
|
||||||
existing.verified = verified; changed = true
|
existing.verified = verified; changed = true
|
||||||
}
|
}
|
||||||
|
if isGroupDialog, !existing.iHaveSent {
|
||||||
|
existing.iHaveSent = true; changed = true
|
||||||
|
}
|
||||||
guard changed else { return }
|
guard changed else { return }
|
||||||
dialogs[opponentKey] = existing
|
dialogs[opponentKey] = existing
|
||||||
persistDialog(existing)
|
persistDialog(existing)
|
||||||
@@ -225,7 +232,7 @@ final class DialogRepository {
|
|||||||
opponentTitle: title, opponentUsername: username,
|
opponentTitle: title, opponentUsername: username,
|
||||||
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
||||||
isOnline: false, lastSeen: 0, verified: verified,
|
isOnline: false, lastSeen: 0, verified: verified,
|
||||||
iHaveSent: false, isPinned: false, isMuted: false,
|
iHaveSent: isGroupDialog, isPinned: false, isMuted: false,
|
||||||
lastMessageFromMe: false, lastMessageDelivered: .waiting,
|
lastMessageFromMe: false, lastMessageDelivered: .waiting,
|
||||||
lastMessageRead: false
|
lastMessageRead: false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -280,11 +280,18 @@ final class MessageRepository: ObservableObject {
|
|||||||
myPublicKey: String,
|
myPublicKey: String,
|
||||||
decryptedText: String,
|
decryptedText: String,
|
||||||
attachmentPassword: String? = nil,
|
attachmentPassword: String? = nil,
|
||||||
fromSync: Bool = false
|
fromSync: Bool = false,
|
||||||
|
dialogIdentityOverride: String? = nil
|
||||||
) {
|
) {
|
||||||
PerformanceLogger.shared.track("message.upsert")
|
PerformanceLogger.shared.track("message.upsert")
|
||||||
let fromMe = packet.fromPublicKey == myPublicKey
|
let fromMe = packet.fromPublicKey == myPublicKey
|
||||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
let opponentKey: String = {
|
||||||
|
if let override = dialogIdentityOverride?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!override.isEmpty {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
return fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||||
|
}()
|
||||||
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
|
let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId
|
||||||
let timestamp = normalizeTimestamp(packet.timestamp)
|
let timestamp = normalizeTimestamp(packet.timestamp)
|
||||||
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
|
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
|
||||||
|
|||||||
89
Rosetta/Core/Layout/BubbleGeometryEngine+Tail.swift
Normal file
89
Rosetta/Core/Layout/BubbleGeometryEngine+Tail.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension BubbleGeometryEngine {
|
||||||
|
static func addFigmaTail(
|
||||||
|
to path: UIBezierPath,
|
||||||
|
bodyRect: CGRect,
|
||||||
|
outgoing: Bool,
|
||||||
|
tailProtrusion: CGFloat
|
||||||
|
) {
|
||||||
|
let svgStraightX: CGFloat = 5.59961
|
||||||
|
let svgMaxY: CGFloat = 33.2305
|
||||||
|
let scale = tailProtrusion / svgStraightX
|
||||||
|
let tailHeight = svgMaxY * scale
|
||||||
|
|
||||||
|
let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX
|
||||||
|
let bottom = bodyRect.maxY
|
||||||
|
let top = bottom - tailHeight
|
||||||
|
let direction: CGFloat = outgoing ? 1 : -1
|
||||||
|
|
||||||
|
func point(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
|
||||||
|
let dx = (svgStraightX - svgX) * scale * direction
|
||||||
|
return CGPoint(x: bodyEdge + dx, y: top + svgY * scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
if outgoing {
|
||||||
|
path.move(to: point(5.59961, 24.2305))
|
||||||
|
path.addCurve(
|
||||||
|
to: point(0, 33.0244),
|
||||||
|
controlPoint1: point(5.42042, 28.0524),
|
||||||
|
controlPoint2: point(3.19779, 31.339)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(2.6123, 33.2305),
|
||||||
|
controlPoint1: point(0.851596, 33.1596),
|
||||||
|
controlPoint2: point(1.72394, 33.2305)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(13.0293, 29.5596),
|
||||||
|
controlPoint1: point(6.53776, 33.2305),
|
||||||
|
controlPoint2: point(10.1517, 31.8599)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(7.57422, 23.1719),
|
||||||
|
controlPoint1: point(10.7434, 27.898),
|
||||||
|
controlPoint2: point(8.86922, 25.7134)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(5.6123, 4.2002),
|
||||||
|
controlPoint1: point(5.61235, 19.3215),
|
||||||
|
controlPoint2: point(5.6123, 14.281)
|
||||||
|
)
|
||||||
|
path.addLine(to: point(5.6123, 0))
|
||||||
|
path.addLine(to: point(5.59961, 0))
|
||||||
|
path.addLine(to: point(5.59961, 24.2305))
|
||||||
|
path.close()
|
||||||
|
} else {
|
||||||
|
path.move(to: point(5.59961, 24.2305))
|
||||||
|
path.addLine(to: point(5.59961, 0))
|
||||||
|
path.addLine(to: point(5.6123, 0))
|
||||||
|
path.addLine(to: point(5.6123, 4.2002))
|
||||||
|
path.addCurve(
|
||||||
|
to: point(7.57422, 23.1719),
|
||||||
|
controlPoint1: point(5.6123, 14.281),
|
||||||
|
controlPoint2: point(5.61235, 19.3215)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(13.0293, 29.5596),
|
||||||
|
controlPoint1: point(8.86922, 25.7134),
|
||||||
|
controlPoint2: point(10.7434, 27.898)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(2.6123, 33.2305),
|
||||||
|
controlPoint1: point(10.1517, 31.8599),
|
||||||
|
controlPoint2: point(6.53776, 33.2305)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(0, 33.0244),
|
||||||
|
controlPoint1: point(1.72394, 33.2305),
|
||||||
|
controlPoint2: point(0.851596, 33.1596)
|
||||||
|
)
|
||||||
|
path.addCurve(
|
||||||
|
to: point(5.59961, 24.2305),
|
||||||
|
controlPoint1: point(3.19779, 31.339),
|
||||||
|
controlPoint2: point(5.42042, 28.0524)
|
||||||
|
)
|
||||||
|
path.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
Rosetta/Core/Layout/BubbleGeometryEngine.swift
Normal file
223
Rosetta/Core/Layout/BubbleGeometryEngine.swift
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum BubblePosition: Sendable, Equatable {
|
||||||
|
case single, top, mid, bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BubbleMergeType: Sendable, Hashable {
|
||||||
|
case none
|
||||||
|
case top(side: Bool)
|
||||||
|
case bottom
|
||||||
|
case both
|
||||||
|
case side
|
||||||
|
case extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BubbleMetrics: Sendable {
|
||||||
|
let mainRadius: CGFloat
|
||||||
|
let auxiliaryRadius: CGFloat
|
||||||
|
let tailProtrusion: CGFloat
|
||||||
|
let defaultSpacing: CGFloat
|
||||||
|
let mergedSpacing: CGFloat
|
||||||
|
let textInsets: UIEdgeInsets
|
||||||
|
let mediaStatusInsets: UIEdgeInsets
|
||||||
|
|
||||||
|
static func telegram(screenScale: CGFloat = max(UIScreen.main.scale, 1)) -> BubbleMetrics {
|
||||||
|
let screenPixel = 1.0 / screenScale
|
||||||
|
return BubbleMetrics(
|
||||||
|
mainRadius: 16,
|
||||||
|
auxiliaryRadius: 8,
|
||||||
|
tailProtrusion: 6,
|
||||||
|
defaultSpacing: 2 + screenPixel,
|
||||||
|
mergedSpacing: 0,
|
||||||
|
textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11),
|
||||||
|
mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BubbleGeometryEngine {
|
||||||
|
|
||||||
|
static func mergeType(for position: BubblePosition) -> BubbleMergeType {
|
||||||
|
switch position {
|
||||||
|
case .single:
|
||||||
|
return .none
|
||||||
|
case .top:
|
||||||
|
return .top(side: false)
|
||||||
|
case .mid:
|
||||||
|
return .both
|
||||||
|
case .bottom:
|
||||||
|
return .bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hasTail(for mergeType: BubbleMergeType) -> Bool {
|
||||||
|
switch mergeType {
|
||||||
|
case .none, .bottom:
|
||||||
|
return true
|
||||||
|
case .top, .both, .side, .extracted:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hasTail(for position: BubblePosition) -> Bool {
|
||||||
|
hasTail(for: mergeType(for: position))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cornerRadii(
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool,
|
||||||
|
metrics: BubbleMetrics
|
||||||
|
) -> (topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
|
||||||
|
var topLeftRadius: CGFloat
|
||||||
|
var topRightRadius: CGFloat
|
||||||
|
var bottomLeftRadius: CGFloat
|
||||||
|
var bottomRightRadius: CGFloat
|
||||||
|
|
||||||
|
switch mergeType {
|
||||||
|
case .none:
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.mainRadius
|
||||||
|
bottomLeftRadius = metrics.mainRadius
|
||||||
|
bottomRightRadius = metrics.mainRadius
|
||||||
|
case .both:
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.auxiliaryRadius
|
||||||
|
bottomLeftRadius = metrics.mainRadius
|
||||||
|
bottomRightRadius = metrics.auxiliaryRadius
|
||||||
|
case .bottom:
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.auxiliaryRadius
|
||||||
|
bottomLeftRadius = metrics.mainRadius
|
||||||
|
bottomRightRadius = metrics.mainRadius
|
||||||
|
case .side:
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.mainRadius
|
||||||
|
bottomLeftRadius = metrics.auxiliaryRadius
|
||||||
|
bottomRightRadius = metrics.auxiliaryRadius
|
||||||
|
case let .top(side):
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.mainRadius
|
||||||
|
bottomLeftRadius = side ? metrics.auxiliaryRadius : metrics.mainRadius
|
||||||
|
bottomRightRadius = metrics.auxiliaryRadius
|
||||||
|
case .extracted:
|
||||||
|
topLeftRadius = metrics.mainRadius
|
||||||
|
topRightRadius = metrics.mainRadius
|
||||||
|
bottomLeftRadius = metrics.mainRadius
|
||||||
|
bottomRightRadius = metrics.mainRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
if outgoing {
|
||||||
|
return (topLeftRadius, topRightRadius, bottomLeftRadius, bottomRightRadius)
|
||||||
|
}
|
||||||
|
return (topRightRadius, topLeftRadius, bottomRightRadius, bottomLeftRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bodyRect(
|
||||||
|
in rect: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool,
|
||||||
|
metrics: BubbleMetrics
|
||||||
|
) -> CGRect {
|
||||||
|
guard hasTail(for: mergeType) else {
|
||||||
|
return rect
|
||||||
|
}
|
||||||
|
if outgoing {
|
||||||
|
return CGRect(
|
||||||
|
x: rect.minX,
|
||||||
|
y: rect.minY,
|
||||||
|
width: rect.width - metrics.tailProtrusion,
|
||||||
|
height: rect.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CGRect(
|
||||||
|
x: rect.minX + metrics.tailProtrusion,
|
||||||
|
y: rect.minY,
|
||||||
|
width: rect.width - metrics.tailProtrusion,
|
||||||
|
height: rect.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeBezierPath(
|
||||||
|
in rect: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool,
|
||||||
|
metrics: BubbleMetrics = .telegram()
|
||||||
|
) -> UIBezierPath {
|
||||||
|
let path = UIBezierPath()
|
||||||
|
let bodyRect = bodyRect(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics)
|
||||||
|
let radii = cornerRadii(mergeType: mergeType, outgoing: outgoing, metrics: metrics)
|
||||||
|
|
||||||
|
let maxRadius = min(bodyRect.width, bodyRect.height) / 2
|
||||||
|
let cTL = min(radii.topLeft, maxRadius)
|
||||||
|
let cTR = min(radii.topRight, maxRadius)
|
||||||
|
let cBL = min(radii.bottomLeft, maxRadius)
|
||||||
|
let cBR = min(radii.bottomRight, maxRadius)
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY + cTR),
|
||||||
|
radius: cTR,
|
||||||
|
startAngle: -.pi / 2,
|
||||||
|
endAngle: 0,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY - cBR),
|
||||||
|
radius: cBR,
|
||||||
|
startAngle: 0,
|
||||||
|
endAngle: .pi / 2,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY - cBL),
|
||||||
|
radius: cBL,
|
||||||
|
startAngle: .pi / 2,
|
||||||
|
endAngle: .pi,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY + cTL),
|
||||||
|
radius: cTL,
|
||||||
|
startAngle: .pi,
|
||||||
|
endAngle: -.pi / 2,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.close()
|
||||||
|
|
||||||
|
if hasTail(for: mergeType) {
|
||||||
|
addFigmaTail(
|
||||||
|
to: path,
|
||||||
|
bodyRect: bodyRect,
|
||||||
|
outgoing: outgoing,
|
||||||
|
tailProtrusion: metrics.tailProtrusion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeCGPath(
|
||||||
|
in rect: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool,
|
||||||
|
metrics: BubbleMetrics = .telegram()
|
||||||
|
) -> CGPath {
|
||||||
|
makeBezierPath(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics).cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeSwiftUIPath(
|
||||||
|
in rect: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool,
|
||||||
|
metrics: BubbleMetrics = .telegram()
|
||||||
|
) -> Path {
|
||||||
|
Path(makeBezierPath(in: rect, mergeType: mergeType, outgoing: outgoing, metrics: metrics).cgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ struct MessageCellLayout: Sendable {
|
|||||||
|
|
||||||
let bubbleFrame: CGRect // Bubble view frame in cell coords
|
let bubbleFrame: CGRect // Bubble view frame in cell coords
|
||||||
let bubbleSize: CGSize // Bubble size (for shape path)
|
let bubbleSize: CGSize // Bubble size (for shape path)
|
||||||
|
let mergeType: BubbleMergeType
|
||||||
let hasTail: Bool
|
let hasTail: Bool
|
||||||
|
|
||||||
// MARK: - Text
|
// MARK: - Text
|
||||||
@@ -88,6 +89,7 @@ extension MessageCellLayout {
|
|||||||
let position: BubblePosition
|
let position: BubblePosition
|
||||||
let deliveryStatus: DeliveryStatus
|
let deliveryStatus: DeliveryStatus
|
||||||
let text: String
|
let text: String
|
||||||
|
let timestampText: String
|
||||||
let hasReplyQuote: Bool
|
let hasReplyQuote: Bool
|
||||||
let replyName: String?
|
let replyName: String?
|
||||||
let replyText: String?
|
let replyText: String?
|
||||||
@@ -100,6 +102,36 @@ extension MessageCellLayout {
|
|||||||
let forwardCaption: String?
|
let forwardCaption: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct MediaDimensions {
|
||||||
|
let maxWidth: CGFloat
|
||||||
|
let maxHeight: CGFloat
|
||||||
|
let minWidth: CGFloat
|
||||||
|
let minHeight: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TextStatusLaneMetrics {
|
||||||
|
let textToMetadataGap: CGFloat
|
||||||
|
let timeToCheckGap: CGFloat
|
||||||
|
let textStatusRightInset: CGFloat
|
||||||
|
let statusWidth: CGFloat
|
||||||
|
let checkOffset: CGFloat
|
||||||
|
let verticalOffset: CGFloat
|
||||||
|
let checkBaselineOffset: CGFloat
|
||||||
|
|
||||||
|
static func telegram(fontPointSize: CGFloat, screenPixel: CGFloat) -> TextStatusLaneMetrics {
|
||||||
|
TextStatusLaneMetrics(
|
||||||
|
textToMetadataGap: 5,
|
||||||
|
timeToCheckGap: 5,
|
||||||
|
textStatusRightInset: 5,
|
||||||
|
statusWidth: floor(floor(fontPointSize * 13.0 / 17.0)),
|
||||||
|
checkOffset: floor(fontPointSize * 6.0 / 17.0),
|
||||||
|
// Lift text status lane by one pixel to match Telegram vertical seating.
|
||||||
|
verticalOffset: -screenPixel,
|
||||||
|
checkBaselineOffset: 3.0 - screenPixel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate complete cell layout on ANY thread.
|
/// Calculate complete cell layout on ANY thread.
|
||||||
/// Uses CoreText for text measurement (thread-safe).
|
/// Uses CoreText for text measurement (thread-safe).
|
||||||
/// Returns layout with all frame rects + cached CoreTextTextLayout for rendering.
|
/// Returns layout with all frame rects + cached CoreTextTextLayout for rendering.
|
||||||
@@ -109,14 +141,10 @@ extension MessageCellLayout {
|
|||||||
static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) {
|
static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) {
|
||||||
let font = UIFont.systemFont(ofSize: 17, weight: .regular)
|
let font = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||||
let tsFont = UIFont.systemFont(ofSize: floor(font.pointSize * 11.0 / 17.0), weight: .regular)
|
let tsFont = UIFont.systemFont(ofSize: floor(font.pointSize * 11.0 / 17.0), weight: .regular)
|
||||||
let screenScale = max(UIScreen.main.scale, 1)
|
let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
|
||||||
let screenPixel = 1.0 / screenScale
|
let metrics = BubbleMetrics.telegram()
|
||||||
|
let mergeType = BubbleGeometryEngine.mergeType(for: config.position)
|
||||||
let hasTail = (config.position == .single || config.position == .bottom)
|
let hasTail = BubbleGeometryEngine.hasTail(for: mergeType)
|
||||||
let isTopOrSingle = (config.position == .single || config.position == .top)
|
|
||||||
// Keep a visible separator between grouped bubbles in native UIKit mode.
|
|
||||||
// A single-screen-pixel gap was too tight and visually merged into one blob.
|
|
||||||
let groupGap: CGFloat = isTopOrSingle ? (2 + screenPixel) : (1 + screenPixel)
|
|
||||||
let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error
|
let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error
|
||||||
let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0
|
let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0
|
||||||
let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset)
|
let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset)
|
||||||
@@ -137,12 +165,29 @@ extension MessageCellLayout {
|
|||||||
messageType = .text
|
messageType = .text
|
||||||
}
|
}
|
||||||
let isTextMessage = (messageType == .text || messageType == .textWithReply)
|
let isTextMessage = (messageType == .text || messageType == .textWithReply)
|
||||||
|
let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
|
||||||
|
fontPointSize: font.pointSize,
|
||||||
|
screenPixel: screenPixel
|
||||||
|
)
|
||||||
|
let groupGap: CGFloat = {
|
||||||
|
guard config.position == .mid || config.position == .bottom else {
|
||||||
|
return metrics.defaultSpacing
|
||||||
|
}
|
||||||
|
// Keep grouped text bubbles compact, but still visually split.
|
||||||
|
if isTextMessage {
|
||||||
|
return screenPixel
|
||||||
|
}
|
||||||
|
return metrics.mergedSpacing
|
||||||
|
}()
|
||||||
|
|
||||||
// ── STEP 1: Asymmetric paddings + base text measurement (full width) ──
|
// ── STEP 1: Asymmetric paddings + base text measurement (full width) ──
|
||||||
let topPad: CGFloat = 6 + screenPixel
|
let topPad: CGFloat = metrics.textInsets.top
|
||||||
let bottomPad: CGFloat = 6 - screenPixel
|
let bottomPad: CGFloat = metrics.textInsets.bottom
|
||||||
let leftPad: CGFloat = 11
|
let leftPad: CGFloat = metrics.textInsets.left
|
||||||
let rightPad: CGFloat = 11
|
let rightPad: CGFloat = metrics.textInsets.right
|
||||||
|
let statusTrailingCompensation: CGFloat = isTextMessage
|
||||||
|
? max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||||
|
: 0
|
||||||
|
|
||||||
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
|
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
|
||||||
// Text is measured at the WIDEST possible constraint.
|
// Text is measured at the WIDEST possible constraint.
|
||||||
@@ -165,22 +210,21 @@ extension MessageCellLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── STEP 2: Meta-info dimensions ──
|
// ── STEP 2: Meta-info dimensions ──
|
||||||
let tsSize = measureText("00:00", maxWidth: 60, font: tsFont)
|
let timestampText = config.timestampText.isEmpty ? "00:00" : config.timestampText
|
||||||
|
let tsSize = measureText(timestampText, maxWidth: 60, font: tsFont)
|
||||||
let hasStatusIcon = config.isOutgoing && !isOutgoingFailed
|
let hasStatusIcon = config.isOutgoing && !isOutgoingFailed
|
||||||
let isMediaOnly = config.imageCount > 0 && config.text.isEmpty
|
let isMediaMessage = config.imageCount > 0
|
||||||
let statusWidth: CGFloat = hasStatusIcon
|
let statusWidth: CGFloat = hasStatusIcon
|
||||||
? floor(floor(font.pointSize * 13.0 / 17.0))
|
? textStatusLaneMetrics.statusWidth
|
||||||
: 0
|
: 0
|
||||||
let checkW: CGFloat = statusWidth
|
let checkW: CGFloat = statusWidth
|
||||||
// Telegram date/status lane keeps a wider visual gap before checks.
|
let timeToCheckGap: CGFloat = hasStatusIcon
|
||||||
let timeGap: CGFloat = hasStatusIcon ? 5 : 0
|
? textStatusLaneMetrics.timeToCheckGap
|
||||||
let statusGap: CGFloat = 2
|
: 0
|
||||||
let metadataWidth = tsSize.width + timeGap + checkW
|
let metadataWidth = tsSize.width + timeToCheckGap + checkW
|
||||||
|
|
||||||
// ── STEP 3: Inline vs Wrapped determination ──
|
let trailingWidthForStatus: CGFloat
|
||||||
let timestampInline: Bool
|
|
||||||
if isTextMessage && !config.text.isEmpty {
|
if isTextMessage && !config.text.isEmpty {
|
||||||
let trailingWidthForStatus: CGFloat
|
|
||||||
if let cachedTextLayout {
|
if let cachedTextLayout {
|
||||||
if cachedTextLayout.lastLineHasRTL {
|
if cachedTextLayout.lastLineHasRTL {
|
||||||
trailingWidthForStatus = 10_000
|
trailingWidthForStatus = 10_000
|
||||||
@@ -192,7 +236,34 @@ extension MessageCellLayout {
|
|||||||
} else {
|
} else {
|
||||||
trailingWidthForStatus = textMeasurement.trailingLineWidth
|
trailingWidthForStatus = textMeasurement.trailingLineWidth
|
||||||
}
|
}
|
||||||
timestampInline = trailingWidthForStatus + statusGap + metadataWidth <= maxTextWidth
|
} else {
|
||||||
|
trailingWidthForStatus = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let inlineStatusContentWidth = max(
|
||||||
|
0,
|
||||||
|
trailingWidthForStatus
|
||||||
|
+ textStatusLaneMetrics.textToMetadataGap
|
||||||
|
+ metadataWidth
|
||||||
|
- statusTrailingCompensation
|
||||||
|
)
|
||||||
|
let wrappedStatusContentWidth = max(
|
||||||
|
0,
|
||||||
|
metadataWidth - statusTrailingCompensation
|
||||||
|
)
|
||||||
|
let isShortSingleLineText: Bool = {
|
||||||
|
guard messageType == .text, !config.text.isEmpty else { return false }
|
||||||
|
guard !config.text.contains("\n") else { return false }
|
||||||
|
if let cachedTextLayout {
|
||||||
|
return cachedTextLayout.lines.count == 1
|
||||||
|
}
|
||||||
|
return textMeasurement.size.height <= ceil(font.lineHeight + 1.0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ── STEP 3: Inline vs Wrapped determination ──
|
||||||
|
let timestampInline: Bool
|
||||||
|
if isTextMessage && !config.text.isEmpty {
|
||||||
|
timestampInline = inlineStatusContentWidth <= maxTextWidth
|
||||||
} else {
|
} else {
|
||||||
timestampInline = true
|
timestampInline = true
|
||||||
}
|
}
|
||||||
@@ -200,10 +271,16 @@ extension MessageCellLayout {
|
|||||||
// ── STEP 4: Bubble dimensions (unified width + height) ──
|
// ── STEP 4: Bubble dimensions (unified width + height) ──
|
||||||
|
|
||||||
// Content blocks above the text area
|
// Content blocks above the text area
|
||||||
|
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
|
||||||
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
|
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
|
||||||
var photoH: CGFloat = 0
|
var photoH: CGFloat = 0
|
||||||
if config.imageCount > 0 {
|
if config.imageCount > 0 {
|
||||||
photoH = Self.collageHeight(count: config.imageCount, width: effectiveMaxBubbleWidth - 8)
|
photoH = Self.collageHeight(
|
||||||
|
count: config.imageCount,
|
||||||
|
width: effectiveMaxBubbleWidth - 8,
|
||||||
|
maxHeight: mediaDimensions.maxHeight,
|
||||||
|
minHeight: mediaDimensions.minHeight
|
||||||
|
)
|
||||||
}
|
}
|
||||||
let forwardHeaderH: CGFloat = config.isForward ? 40 : 0
|
let forwardHeaderH: CGFloat = config.isForward ? 40 : 0
|
||||||
let fileH: CGFloat = CGFloat(config.fileCount) * 56
|
let fileH: CGFloat = CGFloat(config.fileCount) * 56
|
||||||
@@ -211,30 +288,21 @@ extension MessageCellLayout {
|
|||||||
// Tiny floor just to prevent zero-width collapse.
|
// Tiny floor just to prevent zero-width collapse.
|
||||||
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
||||||
let minW: CGFloat = 40
|
let minW: CGFloat = 40
|
||||||
let mediaWidthFraction: CGFloat
|
let mediaBubbleMaxWidth = min(effectiveMaxBubbleWidth, mediaDimensions.maxWidth)
|
||||||
let mediaAbsoluteCap: CGFloat
|
let mediaBubbleMinWidth = min(mediaDimensions.minWidth, mediaBubbleMaxWidth)
|
||||||
if config.imageCount == 1 {
|
|
||||||
mediaWidthFraction = 0.64
|
|
||||||
mediaAbsoluteCap = 288
|
|
||||||
} else if config.imageCount == 2 {
|
|
||||||
mediaWidthFraction = 0.67
|
|
||||||
mediaAbsoluteCap = 300
|
|
||||||
} else {
|
|
||||||
mediaWidthFraction = 0.7
|
|
||||||
mediaAbsoluteCap = 312
|
|
||||||
}
|
|
||||||
let mediaBubbleMaxWidth = min(
|
|
||||||
effectiveMaxBubbleWidth,
|
|
||||||
min(mediaAbsoluteCap, max(200, UIScreen.main.bounds.width * mediaWidthFraction))
|
|
||||||
)
|
|
||||||
|
|
||||||
var bubbleW: CGFloat
|
var bubbleW: CGFloat
|
||||||
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
|
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
|
||||||
|
|
||||||
if config.imageCount > 0 {
|
if config.imageCount > 0 {
|
||||||
// Media bubbles should not stretch edge-to-edge; keep Telegram-like cap.
|
// Media bubbles should not stretch edge-to-edge; keep Telegram-like cap.
|
||||||
bubbleW = mediaBubbleMaxWidth
|
bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
|
||||||
photoH = Self.collageHeight(count: config.imageCount, width: bubbleW - 8)
|
photoH = Self.collageHeight(
|
||||||
|
count: config.imageCount,
|
||||||
|
width: bubbleW - 8,
|
||||||
|
maxHeight: mediaDimensions.maxHeight,
|
||||||
|
minHeight: mediaDimensions.minHeight
|
||||||
|
)
|
||||||
bubbleH += photoH
|
bubbleH += photoH
|
||||||
if !config.text.isEmpty {
|
if !config.text.isEmpty {
|
||||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||||
@@ -243,16 +311,18 @@ extension MessageCellLayout {
|
|||||||
} else if isTextMessage && !config.text.isEmpty {
|
} else if isTextMessage && !config.text.isEmpty {
|
||||||
// ── EXACT TELEGRAM MATH — no other modifiers ──
|
// ── EXACT TELEGRAM MATH — no other modifiers ──
|
||||||
let actualTextW = textMeasurement.size.width
|
let actualTextW = textMeasurement.size.width
|
||||||
let lastLineW = textMeasurement.trailingLineWidth
|
|
||||||
|
|
||||||
let finalContentW: CGFloat
|
let finalContentW: CGFloat
|
||||||
if timestampInline {
|
if timestampInline {
|
||||||
// INLINE: width = max(widest line, last line + gap + status)
|
// INLINE: width = max(widest line, last line + gap + status)
|
||||||
finalContentW = max(actualTextW, lastLineW + statusGap + metadataWidth)
|
finalContentW = max(
|
||||||
|
actualTextW,
|
||||||
|
inlineStatusContentWidth
|
||||||
|
)
|
||||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||||
} else {
|
} else {
|
||||||
// WRAPPED: status drops to new line below text
|
// WRAPPED: status drops to new line below text
|
||||||
finalContentW = max(actualTextW, metadataWidth)
|
finalContentW = max(actualTextW, wrappedStatusContentWidth)
|
||||||
bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
|
bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +354,63 @@ extension MessageCellLayout {
|
|||||||
|
|
||||||
// ── STEP 5: Geometry assignment ──
|
// ── STEP 5: Geometry assignment ──
|
||||||
|
|
||||||
|
// Metadata frames:
|
||||||
|
// checkFrame.maxX = bubbleW - textStatusRightInset for text bubbles
|
||||||
|
// tsFrame.maxX = checkFrame.minX - timeToCheckGap
|
||||||
|
// checkFrame.minX = bubbleW - inset - checkW
|
||||||
|
let metadataRightInset: CGFloat = isMediaMessage
|
||||||
|
? 6
|
||||||
|
: (isTextMessage ? textStatusLaneMetrics.textStatusRightInset : rightPad)
|
||||||
|
let metadataBottomInset: CGFloat = isMediaMessage ? 6 : bottomPad
|
||||||
|
let statusEndX = bubbleW - metadataRightInset
|
||||||
|
let statusEndY = bubbleH - metadataBottomInset
|
||||||
|
let statusVerticalOffset: CGFloat = isTextMessage
|
||||||
|
? textStatusLaneMetrics.verticalOffset
|
||||||
|
: 0
|
||||||
|
|
||||||
|
let tsFrame: CGRect
|
||||||
|
if config.isOutgoing {
|
||||||
|
// [timestamp][timeGap][checkW] anchored right at statusEndX
|
||||||
|
tsFrame = CGRect(
|
||||||
|
x: statusEndX - checkW - timeToCheckGap - tsSize.width,
|
||||||
|
y: statusEndY - tsSize.height + statusVerticalOffset,
|
||||||
|
width: tsSize.width, height: tsSize.height
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Incoming: [timestamp] anchored right at statusEndX
|
||||||
|
tsFrame = CGRect(
|
||||||
|
x: statusEndX - tsSize.width,
|
||||||
|
y: statusEndY - tsSize.height + statusVerticalOffset,
|
||||||
|
width: tsSize.width, height: tsSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let checkSentFrame: CGRect
|
||||||
|
let checkReadFrame: CGRect
|
||||||
|
let clockFrame: CGRect
|
||||||
|
if hasStatusIcon {
|
||||||
|
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
|
||||||
|
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
|
||||||
|
let checkOffset: CGFloat = isTextMessage
|
||||||
|
? textStatusLaneMetrics.checkOffset
|
||||||
|
: floor(font.pointSize * 6.0 / 17.0)
|
||||||
|
let checkReadX = statusEndX - checkImgW
|
||||||
|
let checkSentX = checkReadX - checkOffset
|
||||||
|
let checkBaselineOffset: CGFloat = isTextMessage
|
||||||
|
? textStatusLaneMetrics.checkBaselineOffset
|
||||||
|
: (3 - screenPixel)
|
||||||
|
let checkY = tsFrame.minY + checkBaselineOffset
|
||||||
|
checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH)
|
||||||
|
checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH)
|
||||||
|
// Telegram DateAndStatusNode:
|
||||||
|
// clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks.
|
||||||
|
clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11)
|
||||||
|
} else {
|
||||||
|
checkSentFrame = .zero
|
||||||
|
checkReadFrame = .zero
|
||||||
|
clockFrame = .zero
|
||||||
|
}
|
||||||
|
|
||||||
// Text frame — MUST fill bubbleW - leftPad - rightPad (the content area),
|
// Text frame — MUST fill bubbleW - leftPad - rightPad (the content area),
|
||||||
// NOT textMeasurement.size.width. Using the measured width causes UILabel to
|
// NOT textMeasurement.size.width. Using the measured width causes UILabel to
|
||||||
// re-wrap at a narrower constraint than CoreText measured, producing different
|
// re-wrap at a narrower constraint than CoreText measured, producing different
|
||||||
@@ -297,56 +424,20 @@ extension MessageCellLayout {
|
|||||||
}
|
}
|
||||||
if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad }
|
if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad }
|
||||||
|
|
||||||
let textFrame = CGRect(x: leftPad, y: textY,
|
if isShortSingleLineText && timestampInline {
|
||||||
width: bubbleW - leftPad - rightPad,
|
// Optical centering for short one-line text without inflating bubble height.
|
||||||
height: textMeasurement.size.height)
|
let maxTextY = tsFrame.minY - textStatusLaneMetrics.textToMetadataGap - textMeasurement.size.height
|
||||||
|
if maxTextY > textY {
|
||||||
// Metadata frames:
|
textY = min(textY + 1.5, maxTextY)
|
||||||
// checkFrame.maxX = bubbleW - rightPad (inset from bubble edge, NOT glued)
|
}
|
||||||
// tsFrame.maxX = checkFrame.minX - timeGap
|
|
||||||
// checkFrame.minX = bubbleW - rightPad - checkW
|
|
||||||
let metadataRightInset: CGFloat = isMediaOnly ? 6 : rightPad
|
|
||||||
let metadataBottomInset: CGFloat = isMediaOnly ? 6 : bottomPad
|
|
||||||
let statusEndX = bubbleW - metadataRightInset
|
|
||||||
let statusEndY = bubbleH - metadataBottomInset
|
|
||||||
|
|
||||||
let tsFrame: CGRect
|
|
||||||
if config.isOutgoing {
|
|
||||||
// [timestamp][timeGap][checkW] anchored right at statusEndX
|
|
||||||
tsFrame = CGRect(
|
|
||||||
x: statusEndX - checkW - timeGap - tsSize.width,
|
|
||||||
y: statusEndY - tsSize.height,
|
|
||||||
width: tsSize.width, height: tsSize.height
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Incoming: [timestamp] anchored right at statusEndX
|
|
||||||
tsFrame = CGRect(
|
|
||||||
x: statusEndX - tsSize.width,
|
|
||||||
y: statusEndY - tsSize.height,
|
|
||||||
width: tsSize.width, height: tsSize.height
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let checkSentFrame: CGRect
|
let textFrame = CGRect(
|
||||||
let checkReadFrame: CGRect
|
x: leftPad,
|
||||||
let clockFrame: CGRect
|
y: textY,
|
||||||
if hasStatusIcon {
|
width: bubbleW - leftPad - rightPad,
|
||||||
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
|
height: textMeasurement.size.height
|
||||||
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
|
)
|
||||||
let checkOffset: CGFloat = floor(font.pointSize * 6.0 / 17.0)
|
|
||||||
let checkReadX = statusEndX - checkImgW
|
|
||||||
let checkSentX = checkReadX - checkOffset
|
|
||||||
let checkY = tsFrame.minY + (3 - screenPixel)
|
|
||||||
checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH)
|
|
||||||
checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH)
|
|
||||||
// Telegram DateAndStatusNode:
|
|
||||||
// clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks.
|
|
||||||
clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11)
|
|
||||||
} else {
|
|
||||||
checkSentFrame = .zero
|
|
||||||
checkReadFrame = .zero
|
|
||||||
clockFrame = .zero
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accessory frames (reply, photo, file, forward)
|
// Accessory frames (reply, photo, file, forward)
|
||||||
let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41)
|
let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41)
|
||||||
@@ -369,6 +460,7 @@ extension MessageCellLayout {
|
|||||||
messageType: messageType,
|
messageType: messageType,
|
||||||
bubbleFrame: bubbleFrame,
|
bubbleFrame: bubbleFrame,
|
||||||
bubbleSize: CGSize(width: bubbleW, height: bubbleH),
|
bubbleSize: CGSize(width: bubbleW, height: bubbleH),
|
||||||
|
mergeType: mergeType,
|
||||||
hasTail: hasTail,
|
hasTail: hasTail,
|
||||||
textFrame: textFrame,
|
textFrame: textFrame,
|
||||||
textSize: textMeasurement.size,
|
textSize: textMeasurement.size,
|
||||||
@@ -400,26 +492,38 @@ extension MessageCellLayout {
|
|||||||
// MARK: - Collage Height (Thread-Safe)
|
// MARK: - Collage Height (Thread-Safe)
|
||||||
|
|
||||||
/// Photo collage height — same formulas as PhotoCollageView.swift.
|
/// Photo collage height — same formulas as PhotoCollageView.swift.
|
||||||
private static func collageHeight(count: Int, width: CGFloat) -> CGFloat {
|
private static func mediaDimensions(for screenWidth: CGFloat) -> MediaDimensions {
|
||||||
|
if screenWidth > 680 {
|
||||||
|
return MediaDimensions(maxWidth: 440, maxHeight: 440, minWidth: 170, minHeight: 74)
|
||||||
|
}
|
||||||
|
return MediaDimensions(maxWidth: 300, maxHeight: 380, minWidth: 170, minHeight: 74)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func collageHeight(
|
||||||
|
count: Int,
|
||||||
|
width: CGFloat,
|
||||||
|
maxHeight: CGFloat,
|
||||||
|
minHeight: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
guard count > 0 else { return 0 }
|
guard count > 0 else { return 0 }
|
||||||
if count == 1 { return max(180, min(width * 0.93, 340)) }
|
if count == 1 { return min(max(width * 0.93, minHeight), maxHeight) }
|
||||||
if count == 2 {
|
if count == 2 {
|
||||||
let cellW = (width - 2) / 2
|
let cellW = (width - 2) / 2
|
||||||
return min(cellW * 1.28, 330)
|
return min(max(cellW * 1.28, minHeight), maxHeight)
|
||||||
}
|
}
|
||||||
if count == 3 {
|
if count == 3 {
|
||||||
let leftW = width * 0.66
|
let leftW = width * 0.66
|
||||||
return min(leftW * 1.16, 330)
|
return min(max(leftW * 1.16, minHeight), maxHeight)
|
||||||
}
|
}
|
||||||
if count == 4 {
|
if count == 4 {
|
||||||
let cellW = (width - 2) / 2
|
let cellW = (width - 2) / 2
|
||||||
let cellH = min(cellW * 0.85, 160)
|
let cellH = min(max(cellW * 0.85, minHeight / 2), maxHeight / 2)
|
||||||
return cellH * 2 + 2
|
return min(max(cellH * 2 + 2, minHeight), maxHeight)
|
||||||
}
|
}
|
||||||
// 5+
|
// 5+
|
||||||
let topH = min(width / 2 * 0.85, 176)
|
let topH = min(width / 2 * 0.85, 176)
|
||||||
let botH = min(width / 3 * 0.85, 144)
|
let botH = min(width / 3 * 0.85, 144)
|
||||||
return topH + 2 + botH
|
return min(max(topH + 2 + botH, minHeight), maxHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Text Measurement (Thread-Safe)
|
// MARK: - Text Measurement (Thread-Safe)
|
||||||
@@ -579,9 +683,16 @@ extension MessageCellLayout {
|
|||||||
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
|
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
|
||||||
var result: [String: MessageCellLayout] = [:]
|
var result: [String: MessageCellLayout] = [:]
|
||||||
var textResult: [String: CoreTextTextLayout] = [:]
|
var textResult: [String: CoreTextTextLayout] = [:]
|
||||||
|
let timestampFormatter = DateFormatter()
|
||||||
|
timestampFormatter.dateFormat = "HH:mm"
|
||||||
|
timestampFormatter.locale = .autoupdatingCurrent
|
||||||
|
timestampFormatter.timeZone = .autoupdatingCurrent
|
||||||
|
|
||||||
for (index, message) in messages.enumerated() {
|
for (index, message) in messages.enumerated() {
|
||||||
let isOutgoing = message.fromPublicKey == currentPublicKey
|
let isOutgoing = message.fromPublicKey == currentPublicKey
|
||||||
|
let timestampText = timestampFormatter.string(
|
||||||
|
from: Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
|
||||||
|
)
|
||||||
|
|
||||||
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
|
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
|
||||||
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||||
@@ -633,6 +744,7 @@ extension MessageCellLayout {
|
|||||||
position: position,
|
position: position,
|
||||||
deliveryStatus: message.deliveryStatus,
|
deliveryStatus: message.deliveryStatus,
|
||||||
text: displayText,
|
text: displayText,
|
||||||
|
timestampText: timestampText,
|
||||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||||
replyName: nil,
|
replyName: nil,
|
||||||
replyText: nil,
|
replyText: nil,
|
||||||
|
|||||||
@@ -23,9 +23,16 @@ enum PacketRegistry {
|
|||||||
0x07: { PacketRead() },
|
0x07: { PacketRead() },
|
||||||
0x08: { PacketDelivery() },
|
0x08: { PacketDelivery() },
|
||||||
0x09: { PacketDeviceNew() },
|
0x09: { PacketDeviceNew() },
|
||||||
|
0x0A: { PacketRequestUpdate() },
|
||||||
0x0B: { PacketTyping() },
|
0x0B: { PacketTyping() },
|
||||||
0x0F: { PacketRequestTransport() },
|
0x0F: { PacketRequestTransport() },
|
||||||
0x10: { PacketPushNotification() },
|
0x10: { PacketPushNotification() },
|
||||||
|
0x11: { PacketCreateGroup() },
|
||||||
|
0x12: { PacketGroupInfo() },
|
||||||
|
0x13: { PacketGroupInviteInfo() },
|
||||||
|
0x14: { PacketGroupJoin() },
|
||||||
|
0x15: { PacketGroupLeave() },
|
||||||
|
0x16: { PacketGroupBan() },
|
||||||
0x17: { PacketDeviceList() },
|
0x17: { PacketDeviceList() },
|
||||||
0x18: { PacketDeviceResolve() },
|
0x18: { PacketDeviceResolve() },
|
||||||
0x19: { PacketSync() },
|
0x19: { PacketSync() },
|
||||||
@@ -61,6 +68,7 @@ enum AttachmentType: Int, Codable {
|
|||||||
case messages = 1
|
case messages = 1
|
||||||
case file = 2
|
case file = 2
|
||||||
case avatar = 3
|
case avatar = 3
|
||||||
|
case call = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MessageAttachment: Codable, Equatable {
|
struct MessageAttachment: Codable, Equatable {
|
||||||
@@ -100,3 +108,145 @@ struct SearchUser {
|
|||||||
var verified: Int = 0
|
var verified: Int = 0
|
||||||
var online: Int = 0
|
var online: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Update Server Packet (0x0A)
|
||||||
|
|
||||||
|
/// Request/response packet for update server discovery.
|
||||||
|
/// Mirrors Android/Desktop `PacketRequestUpdate`.
|
||||||
|
struct PacketRequestUpdate: Packet {
|
||||||
|
static let packetId = 0x0A
|
||||||
|
|
||||||
|
var updateServer: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(updateServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
updateServer = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group Packets (0x11...0x16)
|
||||||
|
|
||||||
|
enum GroupStatus: Int, Codable {
|
||||||
|
case joined = 0
|
||||||
|
case invalid = 1
|
||||||
|
case notJoined = 2
|
||||||
|
case banned = 3
|
||||||
|
|
||||||
|
init(value: Int) {
|
||||||
|
self = GroupStatus(rawValue: value) ?? .notJoined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketCreateGroup: Packet {
|
||||||
|
static let packetId = 0x11
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketGroupInfo: Packet {
|
||||||
|
static let packetId = 0x12
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
var members: [String] = []
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
stream.writeInt16(members.count)
|
||||||
|
for member in members {
|
||||||
|
stream.writeString(member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
let count = max(stream.readInt16(), 0)
|
||||||
|
var parsed: [String] = []
|
||||||
|
parsed.reserveCapacity(count)
|
||||||
|
for _ in 0..<count {
|
||||||
|
parsed.append(stream.readString())
|
||||||
|
}
|
||||||
|
members = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketGroupInviteInfo: Packet {
|
||||||
|
static let packetId = 0x13
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
var membersCount: Int = 0
|
||||||
|
var status: GroupStatus = .notJoined
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
stream.writeInt16(membersCount)
|
||||||
|
stream.writeInt8(status.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
membersCount = max(stream.readInt16(), 0)
|
||||||
|
status = GroupStatus(value: stream.readInt8())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketGroupJoin: Packet {
|
||||||
|
static let packetId = 0x14
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
var status: GroupStatus = .notJoined
|
||||||
|
var groupString: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
stream.writeInt8(status.rawValue)
|
||||||
|
stream.writeString(groupString)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
status = GroupStatus(value: stream.readInt8())
|
||||||
|
groupString = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketGroupLeave: Packet {
|
||||||
|
static let packetId = 0x15
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PacketGroupBan: Packet {
|
||||||
|
static let packetId = 0x16
|
||||||
|
|
||||||
|
var groupId: String = ""
|
||||||
|
var publicKey: String = ""
|
||||||
|
|
||||||
|
func write(to stream: Stream) {
|
||||||
|
stream.writeString(groupId)
|
||||||
|
stream.writeString(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func read(from stream: Stream) {
|
||||||
|
groupId = stream.readString()
|
||||||
|
publicKey = stream.readString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
var onUserInfoReceived: ((PacketUserInfo) -> Void)?
|
var onUserInfoReceived: ((PacketUserInfo) -> Void)?
|
||||||
var onSearchResult: ((PacketSearch) -> Void)?
|
var onSearchResult: ((PacketSearch) -> Void)?
|
||||||
var onTypingReceived: ((PacketTyping) -> Void)?
|
var onTypingReceived: ((PacketTyping) -> Void)?
|
||||||
|
var onRequestUpdateReceived: ((PacketRequestUpdate) -> Void)?
|
||||||
|
var onCreateGroupReceived: ((PacketCreateGroup) -> Void)?
|
||||||
|
var onGroupInfoReceived: ((PacketGroupInfo) -> Void)?
|
||||||
|
var onGroupInviteInfoReceived: ((PacketGroupInviteInfo) -> Void)?
|
||||||
|
var onGroupJoinReceived: ((PacketGroupJoin) -> Void)?
|
||||||
|
var onGroupLeaveReceived: ((PacketGroupLeave) -> Void)?
|
||||||
|
var onGroupBanReceived: ((PacketGroupBan) -> Void)?
|
||||||
var onSyncReceived: ((PacketSync) -> Void)?
|
var onSyncReceived: ((PacketSync) -> Void)?
|
||||||
var onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
var onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
||||||
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
||||||
@@ -482,10 +489,38 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
if let p = packet as? PacketDeviceNew {
|
if let p = packet as? PacketDeviceNew {
|
||||||
onDeviceNewReceived?(p)
|
onDeviceNewReceived?(p)
|
||||||
}
|
}
|
||||||
|
case 0x0A:
|
||||||
|
if let p = packet as? PacketRequestUpdate {
|
||||||
|
onRequestUpdateReceived?(p)
|
||||||
|
}
|
||||||
case 0x0B:
|
case 0x0B:
|
||||||
if let p = packet as? PacketTyping {
|
if let p = packet as? PacketTyping {
|
||||||
onTypingReceived?(p)
|
onTypingReceived?(p)
|
||||||
}
|
}
|
||||||
|
case 0x11:
|
||||||
|
if let p = packet as? PacketCreateGroup {
|
||||||
|
onCreateGroupReceived?(p)
|
||||||
|
}
|
||||||
|
case 0x12:
|
||||||
|
if let p = packet as? PacketGroupInfo {
|
||||||
|
onGroupInfoReceived?(p)
|
||||||
|
}
|
||||||
|
case 0x13:
|
||||||
|
if let p = packet as? PacketGroupInviteInfo {
|
||||||
|
onGroupInviteInfoReceived?(p)
|
||||||
|
}
|
||||||
|
case 0x14:
|
||||||
|
if let p = packet as? PacketGroupJoin {
|
||||||
|
onGroupJoinReceived?(p)
|
||||||
|
}
|
||||||
|
case 0x15:
|
||||||
|
if let p = packet as? PacketGroupLeave {
|
||||||
|
onGroupLeaveReceived?(p)
|
||||||
|
}
|
||||||
|
case 0x16:
|
||||||
|
if let p = packet as? PacketGroupBan {
|
||||||
|
onGroupBanReceived?(p)
|
||||||
|
}
|
||||||
case 0x0F:
|
case 0x0F:
|
||||||
if let p = packet as? PacketRequestTransport {
|
if let p = packet as? PacketRequestTransport {
|
||||||
Self.logger.info("📥 Transport server: \(p.transportServer)")
|
Self.logger.info("📥 Transport server: \(p.transportServer)")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
typealias Stream = PacketBitStream
|
||||||
|
|
||||||
/// Bit-aligned binary stream for protocol packets.
|
/// Bit-aligned binary stream for protocol packets.
|
||||||
/// Matches the React Native / Android implementation exactly.
|
/// Matches the React Native / Android implementation exactly.
|
||||||
final class Stream: @unchecked Sendable {
|
final class PacketBitStream: NSObject {
|
||||||
|
|
||||||
private var bytes: [UInt8]
|
private var bytes: [UInt8]
|
||||||
private var readPointer: Int = 0
|
private var readPointer: Int = 0
|
||||||
@@ -10,19 +12,25 @@ final class Stream: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
override init() {
|
||||||
bytes = []
|
bytes = []
|
||||||
bytes.reserveCapacity(256)
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: Data) {
|
init(data: Data) {
|
||||||
bytes = Array(data)
|
bytes = Array(data)
|
||||||
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Output
|
// MARK: - Output
|
||||||
|
|
||||||
func toData() -> Data {
|
func toData() -> Data {
|
||||||
Data(bytes)
|
guard bytes.isEmpty == false else {
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
var data = Data(count: bytes.count)
|
||||||
|
data.replaceSubrange(0..<bytes.count, with: bytes)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bit-Level I/O
|
// MARK: - Bit-Level I/O
|
||||||
@@ -37,6 +45,9 @@ final class Stream: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readBit() -> Int {
|
func readBit() -> Int {
|
||||||
|
guard readPointer < bytes.count * 8 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
let byteIndex = readPointer >> 3
|
let byteIndex = readPointer >> 3
|
||||||
let shift = 7 - (readPointer & 7)
|
let shift = 7 - (readPointer & 7)
|
||||||
let bit = (bytes[byteIndex] >> shift) & 1
|
let bit = (bytes[byteIndex] >> shift) & 1
|
||||||
@@ -76,6 +87,11 @@ final class Stream: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readInt8() -> Int {
|
func readInt8() -> Int {
|
||||||
|
guard readPointer + 9 <= bytes.count * 8 else {
|
||||||
|
readPointer = bytes.count * 8
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
var value = 0
|
var value = 0
|
||||||
let signShift = 7 - (readPointer & 7)
|
let signShift = 7 - (readPointer & 7)
|
||||||
let negationBit = Int((bytes[readPointer >> 3] >> signShift) & 1)
|
let negationBit = Int((bytes[readPointer >> 3] >> signShift) & 1)
|
||||||
@@ -171,6 +187,16 @@ final class Stream: @unchecked Sendable {
|
|||||||
|
|
||||||
func readBytes() -> Data {
|
func readBytes() -> Data {
|
||||||
let length = readInt32()
|
let length = readInt32()
|
||||||
|
guard length >= 0 else {
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
let bitsAvailable = bytes.count * 8 - readPointer
|
||||||
|
let bytesAvailable = max(bitsAvailable / 9, 0)
|
||||||
|
guard length <= bytesAvailable else {
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
|
||||||
var result = Data(capacity: length)
|
var result = Data(capacity: length)
|
||||||
for _ in 0..<length {
|
for _ in 0..<length {
|
||||||
result.append(UInt8(truncatingIfNeeded: readInt8()))
|
result.append(UInt8(truncatingIfNeeded: readInt8()))
|
||||||
|
|||||||
58
Rosetta/Core/Services/ParityTestSupportInterfaces.swift
Normal file
58
Rosetta/Core/Services/ParityTestSupportInterfaces.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AttachmentFlowTransporting {
|
||||||
|
func uploadFile(id: String, content: Data) async throws -> String
|
||||||
|
func downloadFile(tag: String) async throws -> Data
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PacketFlowSending {
|
||||||
|
func sendPacket(_ packet: any Packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol SearchResultDispatching {
|
||||||
|
var connectionState: ConnectionState { get }
|
||||||
|
var privateHash: String? { get }
|
||||||
|
func sendSearchPacket(_ packet: PacketSearch)
|
||||||
|
@discardableResult
|
||||||
|
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID
|
||||||
|
func removeSearchResultHandler(_ id: UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveAttachmentFlowTransport: AttachmentFlowTransporting {
|
||||||
|
func uploadFile(id: String, content: Data) async throws -> String {
|
||||||
|
try await TransportManager.shared.uploadFile(id: id, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(tag: String) async throws -> Data {
|
||||||
|
try await TransportManager.shared.downloadFile(tag: tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LivePacketFlowSender: PacketFlowSending {
|
||||||
|
func sendPacket(_ packet: any Packet) {
|
||||||
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveSearchResultDispatcher: SearchResultDispatching {
|
||||||
|
var connectionState: ConnectionState {
|
||||||
|
ProtocolManager.shared.connectionState
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateHash: String? {
|
||||||
|
ProtocolManager.shared.privateHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSearchPacket(_ packet: PacketSearch) {
|
||||||
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
|
||||||
|
ProtocolManager.shared.addSearchResultHandler(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSearchResultHandler(_ id: UUID) {
|
||||||
|
ProtocolManager.shared.removeSearchResultHandler(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,20 @@ final class SessionManager {
|
|||||||
|
|
||||||
nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session")
|
nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session")
|
||||||
|
|
||||||
|
enum StartSessionError: LocalizedError {
|
||||||
|
case invalidCredentials
|
||||||
|
case databaseBootstrapFailed(underlying: Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCredentials:
|
||||||
|
return "Wrong password. Please try again."
|
||||||
|
case .databaseBootstrapFailed:
|
||||||
|
return "Database migration failed. Please restart the app and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private(set) var isAuthenticated = false
|
private(set) var isAuthenticated = false
|
||||||
private(set) var currentPublicKey: String = ""
|
private(set) var currentPublicKey: String = ""
|
||||||
private(set) var displayName: String = ""
|
private(set) var displayName: String = ""
|
||||||
@@ -49,6 +63,8 @@ final class SessionManager {
|
|||||||
private var pendingOutgoingAttempts: [String: Int] = [:]
|
private var pendingOutgoingAttempts: [String: Int] = [:]
|
||||||
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
|
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
|
||||||
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
|
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
|
||||||
|
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
|
||||||
|
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
|
||||||
|
|
||||||
// MARK: - Foreground Detection (Android parity)
|
// MARK: - Foreground Detection (Android parity)
|
||||||
|
|
||||||
@@ -127,7 +143,12 @@ final class SessionManager {
|
|||||||
let crypto = CryptoManager.shared
|
let crypto = CryptoManager.shared
|
||||||
|
|
||||||
// Decrypt private key
|
// Decrypt private key
|
||||||
let privateKeyHex = try await accountManager.decryptPrivateKey(password: password)
|
let privateKeyHex: String
|
||||||
|
do {
|
||||||
|
privateKeyHex = try await accountManager.decryptPrivateKey(password: password)
|
||||||
|
} catch {
|
||||||
|
throw StartSessionError.invalidCredentials
|
||||||
|
}
|
||||||
self.privateKeyHex = privateKeyHex
|
self.privateKeyHex = privateKeyHex
|
||||||
// Android parity: provide private key to caches for encryption at rest
|
// Android parity: provide private key to caches for encryption at rest
|
||||||
AttachmentCache.shared.privateKey = privateKeyHex
|
AttachmentCache.shared.privateKey = privateKeyHex
|
||||||
@@ -137,13 +158,20 @@ final class SessionManager {
|
|||||||
throw CryptoError.decryptionFailed
|
throw CryptoError.decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open SQLite database for this account (must happen before repository bootstrap).
|
||||||
|
do {
|
||||||
|
try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey)
|
||||||
|
} catch {
|
||||||
|
self.privateKeyHex = nil
|
||||||
|
AttachmentCache.shared.privateKey = nil
|
||||||
|
Self.logger.error("Database bootstrap failed: \(error.localizedDescription)")
|
||||||
|
throw StartSessionError.databaseBootstrapFailed(underlying: error)
|
||||||
|
}
|
||||||
|
|
||||||
currentPublicKey = account.publicKey
|
currentPublicKey = account.publicKey
|
||||||
displayName = account.displayName ?? ""
|
displayName = account.displayName ?? ""
|
||||||
username = account.username ?? ""
|
username = account.username ?? ""
|
||||||
|
|
||||||
// Open SQLite database for this account (must happen before repository bootstrap).
|
|
||||||
try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey)
|
|
||||||
|
|
||||||
// Migrate legacy JSON → SQLite on first launch (before repositories read from DB).
|
// Migrate legacy JSON → SQLite on first launch (before repositories read from DB).
|
||||||
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
|
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
|
||||||
accountPublicKey: account.publicKey,
|
accountPublicKey: account.publicKey,
|
||||||
@@ -219,14 +247,27 @@ final class SessionManager {
|
|||||||
privateKeyHex: privKey,
|
privateKeyHex: privKey,
|
||||||
privateKeyHash: hash
|
privateKeyHash: hash
|
||||||
)
|
)
|
||||||
|
let targetDialogKey = packet.toPublicKey
|
||||||
|
|
||||||
// Prefer caller-provided title/username (from ChatDetailView route),
|
// Prefer caller-provided title/username (from ChatDetailView route),
|
||||||
// fall back to existing dialog data, then empty.
|
// fall back to existing dialog data, then empty.
|
||||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
let existingDialog = DialogRepository.shared.dialogs[targetDialogKey]
|
||||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
let groupMetadata = GroupRepository.shared.groupMetadata(
|
||||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
account: currentPublicKey,
|
||||||
|
groupDialogKey: targetDialogKey
|
||||||
|
)
|
||||||
|
let title = !opponentTitle.isEmpty
|
||||||
|
? opponentTitle
|
||||||
|
: (existingDialog?.opponentTitle.isEmpty == false
|
||||||
|
? (existingDialog?.opponentTitle ?? "")
|
||||||
|
: (groupMetadata?.title ?? ""))
|
||||||
|
let username = !opponentUsername.isEmpty
|
||||||
|
? opponentUsername
|
||||||
|
: (existingDialog?.opponentUsername.isEmpty == false
|
||||||
|
? (existingDialog?.opponentUsername ?? "")
|
||||||
|
: (groupMetadata?.description ?? ""))
|
||||||
DialogRepository.shared.ensureDialog(
|
DialogRepository.shared.ensureDialog(
|
||||||
opponentKey: toPublicKey,
|
opponentKey: targetDialogKey,
|
||||||
title: title,
|
title: title,
|
||||||
username: username,
|
username: username,
|
||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
@@ -241,9 +282,10 @@ final class SessionManager {
|
|||||||
packet,
|
packet,
|
||||||
myPublicKey: currentPublicKey,
|
myPublicKey: currentPublicKey,
|
||||||
decryptedText: text,
|
decryptedText: text,
|
||||||
fromSync: false
|
fromSync: false,
|
||||||
|
dialogIdentityOverride: targetDialogKey
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: targetDialogKey)
|
||||||
|
|
||||||
// Android parity: persist IMMEDIATELY after inserting outgoing message.
|
// Android parity: persist IMMEDIATELY after inserting outgoing message.
|
||||||
// Without this, if app is killed within 800ms debounce window,
|
// Without this, if app is killed within 800ms debounce window,
|
||||||
@@ -252,9 +294,9 @@ final class SessionManager {
|
|||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
|
|
||||||
// Saved Messages: local-only, no server send
|
// Saved Messages: local-only, no server send
|
||||||
if toPublicKey == currentPublicKey {
|
if targetDialogKey == 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: targetDialogKey, status: .delivered)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +410,7 @@ final class SessionManager {
|
|||||||
// Upload encrypted blob to transport server in background (desktop: uploadFile)
|
// Upload encrypted blob to transport server in background (desktop: uploadFile)
|
||||||
let tag: String
|
let tag: String
|
||||||
do {
|
do {
|
||||||
tag = try await TransportManager.shared.uploadFile(
|
tag = try await attachmentFlowTransport.uploadFile(
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
content: Data(encryptedBlob.utf8)
|
content: Data(encryptedBlob.utf8)
|
||||||
)
|
)
|
||||||
@@ -398,12 +440,12 @@ final class SessionManager {
|
|||||||
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)
|
// Send to server for multi-device sync (unlike text Saved Messages)
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)")
|
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
||||||
@@ -595,18 +637,32 @@ final class SessionManager {
|
|||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
|
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
|
// Keep self/saved files immediately openable without transport tag/download.
|
||||||
|
for item in encryptedAttachments where item.original.type == .file {
|
||||||
|
let fallback = AttachmentPreviewCodec.parseFilePreview(
|
||||||
|
item.preview,
|
||||||
|
fallbackFileName: item.original.fileName ?? "file",
|
||||||
|
fallbackFileSize: item.original.fileSize ?? item.original.data.count
|
||||||
|
)
|
||||||
|
_ = AttachmentCache.shared.saveFile(
|
||||||
|
item.original.data,
|
||||||
|
forAttachmentId: item.original.id,
|
||||||
|
fileName: fallback.fileName
|
||||||
|
)
|
||||||
|
}
|
||||||
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)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: Upload in background, then send packet ──
|
// ── Phase 2: Upload in background, then send packet ──
|
||||||
|
let flowTransport = attachmentFlowTransport
|
||||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||||
of: (Int, String).self
|
of: (Int, String).self
|
||||||
) { group in
|
) { group in
|
||||||
for (index, item) in encryptedAttachments.enumerated() {
|
for (index, item) in encryptedAttachments.enumerated() {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
let tag = try await TransportManager.shared.uploadFile(
|
let tag = try await flowTransport.uploadFile(
|
||||||
id: item.original.id, content: item.encryptedData
|
id: item.original.id, content: item.encryptedData
|
||||||
)
|
)
|
||||||
return (index, tag)
|
return (index, tag)
|
||||||
@@ -616,7 +672,7 @@ final class SessionManager {
|
|||||||
for try await (index, tag) in group { tags[index] = tag }
|
for try await (index, tag) in group { tags[index] = tag }
|
||||||
return encryptedAttachments.enumerated().map { index, item in
|
return encryptedAttachments.enumerated().map { index, item in
|
||||||
let tag = tags[index] ?? ""
|
let tag = tags[index] ?? ""
|
||||||
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
|
let preview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: item.preview)
|
||||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
|
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)
|
return MessageAttachment(id: item.original.id, preview: preview, blob: "", type: item.original.type)
|
||||||
}
|
}
|
||||||
@@ -629,7 +685,7 @@ final class SessionManager {
|
|||||||
var packet = optimisticPacket
|
var packet = optimisticPacket
|
||||||
packet.attachments = messageAttachments
|
packet.attachments = messageAttachments
|
||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
||||||
@@ -729,7 +785,7 @@ final class SessionManager {
|
|||||||
Self.logger.debug("📤 Forward re-upload: \(originalId) → \(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)")
|
Self.logger.debug("📤 Forward re-upload: \(originalId) → \(newAttId) (\(jpegData.count) bytes JPEG, \(encryptedBlob.count) encrypted)")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let tag = try await TransportManager.shared.uploadFile(
|
let tag = try await attachmentFlowTransport.uploadFile(
|
||||||
id: newAttId,
|
id: newAttId,
|
||||||
content: Data(encryptedBlob.utf8)
|
content: Data(encryptedBlob.utf8)
|
||||||
)
|
)
|
||||||
@@ -738,14 +794,8 @@ final class SessionManager {
|
|||||||
let originalPreview = replyMessages
|
let originalPreview = replyMessages
|
||||||
.flatMap { $0.attachments }
|
.flatMap { $0.attachments }
|
||||||
.first(where: { $0.id == originalId })?.preview ?? ""
|
.first(where: { $0.id == originalId })?.preview ?? ""
|
||||||
let blurhash: String
|
let blurhash = AttachmentPreviewCodec.blurHash(from: originalPreview)
|
||||||
if let range = originalPreview.range(of: "::") {
|
let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: blurhash)
|
||||||
blurhash = String(originalPreview[range.upperBound...])
|
|
||||||
} else {
|
|
||||||
blurhash = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let newPreview = "\(tag)::\(blurhash)"
|
|
||||||
attachmentIdMap[originalId] = (newAttId, newPreview)
|
attachmentIdMap[originalId] = (newAttId, newPreview)
|
||||||
|
|
||||||
// Cache locally under new ID for ForwardedImagePreviewCell
|
// Cache locally under new ID for ForwardedImagePreviewCell
|
||||||
@@ -777,7 +827,7 @@ final class SessionManager {
|
|||||||
Self.logger.debug("📤 Forward file re-upload: \(originalId) → \(newAttId) (\(fileInfo.data.count) bytes, \(fileInfo.fileName))")
|
Self.logger.debug("📤 Forward file re-upload: \(originalId) → \(newAttId) (\(fileInfo.data.count) bytes, \(fileInfo.fileName))")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let tag = try await TransportManager.shared.uploadFile(
|
let tag = try await attachmentFlowTransport.uploadFile(
|
||||||
id: newAttId,
|
id: newAttId,
|
||||||
content: Data(encryptedBlob.utf8)
|
content: Data(encryptedBlob.utf8)
|
||||||
)
|
)
|
||||||
@@ -786,14 +836,13 @@ final class SessionManager {
|
|||||||
let originalPreview = replyMessages
|
let originalPreview = replyMessages
|
||||||
.flatMap { $0.attachments }
|
.flatMap { $0.attachments }
|
||||||
.first(where: { $0.id == originalId })?.preview ?? ""
|
.first(where: { $0.id == originalId })?.preview ?? ""
|
||||||
let fileMeta: String
|
let filePreview = AttachmentPreviewCodec.parseFilePreview(
|
||||||
if let range = originalPreview.range(of: "::") {
|
originalPreview,
|
||||||
fileMeta = String(originalPreview[range.upperBound...])
|
fallbackFileName: fileInfo.fileName,
|
||||||
} else {
|
fallbackFileSize: fileInfo.data.count
|
||||||
fileMeta = "\(fileInfo.data.count)::\(fileInfo.fileName)"
|
)
|
||||||
}
|
let fileMeta = "\(filePreview.fileSize)::\(filePreview.fileName)"
|
||||||
|
let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: fileMeta)
|
||||||
let newPreview = "\(tag)::\(fileMeta)"
|
|
||||||
attachmentIdMap[originalId] = (newAttId, newPreview)
|
attachmentIdMap[originalId] = (newAttId, newPreview)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -927,7 +976,7 @@ final class SessionManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
packetFlowSender.sendPacket(packet)
|
||||||
registerOutgoingRetry(for: packet)
|
registerOutgoingRetry(for: packet)
|
||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos")
|
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos")
|
||||||
@@ -935,30 +984,38 @@ final class SessionManager {
|
|||||||
|
|
||||||
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
||||||
func sendTypingIndicator(toPublicKey: String) {
|
func sendTypingIndicator(toPublicKey: String) {
|
||||||
guard toPublicKey != currentPublicKey,
|
let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = DatabaseManager.isGroupDialogKey(base)
|
||||||
|
? Self.normalizedGroupDialogIdentity(base)
|
||||||
|
: base
|
||||||
|
guard normalized != currentPublicKey,
|
||||||
|
!normalized.isEmpty,
|
||||||
let hash = privateKeyHash,
|
let hash = privateKeyHash,
|
||||||
ProtocolManager.shared.connectionState == .authenticated
|
ProtocolManager.shared.connectionState == .authenticated
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
|
let lastSent = lastTypingSentAt[normalized] ?? 0
|
||||||
if now - lastSent < ProtocolConstants.typingThrottleMs {
|
if now - lastSent < ProtocolConstants.typingThrottleMs {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastTypingSentAt[toPublicKey] = now
|
lastTypingSentAt[normalized] = now
|
||||||
|
|
||||||
var packet = PacketTyping()
|
var packet = PacketTyping()
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.fromPublicKey = currentPublicKey
|
packet.fromPublicKey = currentPublicKey
|
||||||
packet.toPublicKey = toPublicKey
|
packet.toPublicKey = normalized
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android parity: sends read receipt for direct dialog.
|
/// Android parity: sends read receipt for direct/group dialog.
|
||||||
/// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming
|
/// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming
|
||||||
/// message timestamp > last sent read receipt timestamp. Retries once after 2s.
|
/// message timestamp > last sent read receipt timestamp. Retries once after 2s.
|
||||||
func sendReadReceipt(toPublicKey: String) {
|
func sendReadReceipt(toPublicKey: String) {
|
||||||
let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalized = DatabaseManager.isGroupDialogKey(base)
|
||||||
|
? Self.normalizedGroupDialogIdentity(base)
|
||||||
|
: base
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = ProtocolManager.shared.connectionState
|
||||||
guard normalized != currentPublicKey,
|
guard normalized != currentPublicKey,
|
||||||
!normalized.isEmpty,
|
!normalized.isEmpty,
|
||||||
@@ -1070,21 +1127,17 @@ final class SessionManager {
|
|||||||
proto.onReadReceived = { [weak self] packet in
|
proto.onReadReceived = { [weak self] packet in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard Self.isSupportedDirectReadPacket(packet, ownKey: self.currentPublicKey) else {
|
guard let context = Self.resolveReadPacketContext(packet, ownKey: self.currentPublicKey) else {
|
||||||
Self.logger.debug(
|
Self.logger.debug(
|
||||||
"Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)"
|
"Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fromKey = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let toKey = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let ownKey = self.currentPublicKey
|
let ownKey = self.currentPublicKey
|
||||||
let isOwnReadSync = fromKey == ownKey
|
let opponentKey = context.dialogKey
|
||||||
let opponentKey = isOwnReadSync ? toKey : fromKey
|
|
||||||
guard !opponentKey.isEmpty else { return }
|
|
||||||
|
|
||||||
if isOwnReadSync {
|
if context.fromMe {
|
||||||
// Android parity: read sync from another own device means
|
// Android parity: read sync from another own device means
|
||||||
// incoming messages in this dialog should become read locally.
|
// incoming messages in this dialog should become read locally.
|
||||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||||
@@ -1127,8 +1180,11 @@ final class SessionManager {
|
|||||||
proto.onTypingReceived = { [weak self] packet in
|
proto.onTypingReceived = { [weak self] packet in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard packet.toPublicKey == self.currentPublicKey else { return }
|
guard let context = Self.resolveTypingPacketContext(packet, ownKey: self.currentPublicKey) else {
|
||||||
MessageRepository.shared.markTyping(from: packet.fromPublicKey)
|
return
|
||||||
|
}
|
||||||
|
if context.fromMe { return }
|
||||||
|
MessageRepository.shared.markTyping(from: context.dialogKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,6 +1326,67 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proto.onRequestUpdateReceived = { packet in
|
||||||
|
Self.logger.debug("RequestUpdate packet received: server=\(packet.updateServer)")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onCreateGroupReceived = { packet in
|
||||||
|
Self.logger.debug("CreateGroup packet received: groupId=\(packet.groupId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onGroupInfoReceived = { packet in
|
||||||
|
Self.logger.debug("GroupInfo packet received: groupId=\(packet.groupId), members=\(packet.members.count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onGroupInviteInfoReceived = { packet in
|
||||||
|
Self.logger.debug(
|
||||||
|
"GroupInviteInfo packet received: groupId=\(packet.groupId), members=\(packet.membersCount), status=\(packet.status.rawValue)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onGroupJoinReceived = { [weak self] packet in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let privateKeyHex = self.privateKeyHex else { return }
|
||||||
|
guard !self.currentPublicKey.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let dialogKey = GroupRepository.shared.upsertFromGroupJoin(
|
||||||
|
account: self.currentPublicKey,
|
||||||
|
privateKeyHex: privateKeyHex,
|
||||||
|
packet: packet
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = GroupRepository.shared.groupMetadata(
|
||||||
|
account: self.currentPublicKey,
|
||||||
|
groupDialogKey: dialogKey
|
||||||
|
)
|
||||||
|
|
||||||
|
DialogRepository.shared.ensureDialog(
|
||||||
|
opponentKey: dialogKey,
|
||||||
|
title: metadata?.title ?? "",
|
||||||
|
username: metadata?.description ?? "",
|
||||||
|
myPublicKey: self.currentPublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if MessageRepository.shared.lastDecryptedMessage(
|
||||||
|
account: self.currentPublicKey,
|
||||||
|
opponentKey: dialogKey
|
||||||
|
) != nil {
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onGroupLeaveReceived = { packet in
|
||||||
|
Self.logger.debug("GroupLeave packet received: groupId=\(packet.groupId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.onGroupBanReceived = { packet in
|
||||||
|
Self.logger.debug("GroupBan packet received: groupId=\(packet.groupId), publicKey=\(packet.publicKey)")
|
||||||
|
}
|
||||||
|
|
||||||
proto.onDeviceNewReceived = { [weak self] packet in
|
proto.onDeviceNewReceived = { [weak self] packet in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.handleDeviceNewLogin(packet)
|
self?.handleDeviceNewLogin(packet)
|
||||||
@@ -1365,14 +1482,26 @@ final class SessionManager {
|
|||||||
let currentPrivateKeyHex = self.privateKeyHex
|
let currentPrivateKeyHex = self.privateKeyHex
|
||||||
let currentPrivateKeyHash = self.privateKeyHash
|
let currentPrivateKeyHash = self.privateKeyHash
|
||||||
|
|
||||||
let fromMe = packet.fromPublicKey == myKey
|
guard let context = Self.resolveMessagePacketContext(packet, ownKey: myKey) else {
|
||||||
|
|
||||||
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let fromMe = context.fromMe
|
||||||
|
|
||||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
let opponentKey = context.dialogKey
|
||||||
|
let isGroupDialog = context.kind == .group
|
||||||
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
|
let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId)
|
||||||
|
let groupKey: String? = {
|
||||||
|
guard isGroupDialog, let currentPrivateKeyHex else { return nil }
|
||||||
|
return GroupRepository.shared.groupKey(
|
||||||
|
account: myKey,
|
||||||
|
privateKeyHex: currentPrivateKeyHex,
|
||||||
|
groupDialogKey: opponentKey
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
if isGroupDialog, groupKey == nil {
|
||||||
|
Self.logger.warning("processIncoming: group key not found for \(opponentKey)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ── PERF: Offload all crypto to background thread ──
|
// ── PERF: Offload all crypto to background thread ──
|
||||||
// decryptIncomingMessage (ECDH + XChaCha20) and attachment blob
|
// decryptIncomingMessage (ECDH + XChaCha20) and attachment blob
|
||||||
@@ -1382,7 +1511,8 @@ final class SessionManager {
|
|||||||
Self.decryptAndProcessAttachments(
|
Self.decryptAndProcessAttachments(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
myPublicKey: myKey,
|
myPublicKey: myKey,
|
||||||
privateKeyHex: currentPrivateKeyHex
|
privateKeyHex: currentPrivateKeyHex,
|
||||||
|
groupKey: groupKey
|
||||||
)
|
)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
@@ -1409,22 +1539,32 @@ final class SessionManager {
|
|||||||
myPublicKey: myKey,
|
myPublicKey: myKey,
|
||||||
decryptedText: text,
|
decryptedText: text,
|
||||||
attachmentPassword: resolvedAttachmentPassword,
|
attachmentPassword: resolvedAttachmentPassword,
|
||||||
fromSync: effectiveFromSync
|
fromSync: effectiveFromSync,
|
||||||
|
dialogIdentityOverride: opponentKey
|
||||||
)
|
)
|
||||||
// Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey)
|
// Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||||
// Full recalculation of lastMessage, unread, iHaveSent, delivery from DB.
|
// Full recalculation of lastMessage, unread, iHaveSent, delivery from DB.
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
||||||
|
if isGroupDialog,
|
||||||
|
let metadata = GroupRepository.shared.groupMetadata(account: myKey, groupDialogKey: opponentKey) {
|
||||||
|
DialogRepository.shared.ensureDialog(
|
||||||
|
opponentKey: opponentKey,
|
||||||
|
title: metadata.title,
|
||||||
|
username: metadata.description,
|
||||||
|
myPublicKey: myKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Desktop parity: if we received a message from the opponent (not our own),
|
// Desktop parity: if we received a message from the opponent (not our own),
|
||||||
// they are clearly online — update their online status immediately.
|
// they are clearly online — update their online status immediately.
|
||||||
// This supplements PacketOnlineState (0x05) which may arrive with delay.
|
// This supplements PacketOnlineState (0x05) which may arrive with delay.
|
||||||
if !fromMe && !effectiveFromSync {
|
if !fromMe && !effectiveFromSync && !isGroupDialog {
|
||||||
DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
|
DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
let dialog = DialogRepository.shared.dialogs[opponentKey]
|
let dialog = DialogRepository.shared.dialogs[opponentKey]
|
||||||
|
|
||||||
if dialog?.opponentTitle.isEmpty == true {
|
if !isGroupDialog, dialog?.opponentTitle.isEmpty == true {
|
||||||
requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
|
requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,12 +1725,14 @@ final class SessionManager {
|
|||||||
nonisolated private static func decryptAndProcessAttachments(
|
nonisolated private static func decryptAndProcessAttachments(
|
||||||
packet: PacketMessage,
|
packet: PacketMessage,
|
||||||
myPublicKey: String,
|
myPublicKey: String,
|
||||||
privateKeyHex: String?
|
privateKeyHex: String?,
|
||||||
|
groupKey: String?
|
||||||
) -> IncomingCryptoResult? {
|
) -> IncomingCryptoResult? {
|
||||||
guard let result = decryptIncomingMessage(
|
guard let result = decryptIncomingMessage(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
myPublicKey: myPublicKey,
|
myPublicKey: myPublicKey,
|
||||||
privateKeyHex: privateKeyHex
|
privateKeyHex: privateKeyHex,
|
||||||
|
groupKey: groupKey
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
var processedPacket = packet
|
var processedPacket = packet
|
||||||
@@ -1628,6 +1770,15 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let groupKey {
|
||||||
|
for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages {
|
||||||
|
let blob = processedPacket.attachments[i].blob
|
||||||
|
guard !blob.isEmpty else { continue }
|
||||||
|
if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey),
|
||||||
|
let decryptedString = String(data: data, encoding: .utf8) {
|
||||||
|
processedPacket.attachments[i].blob = decryptedString
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return IncomingCryptoResult(
|
return IncomingCryptoResult(
|
||||||
@@ -1641,10 +1792,25 @@ final class SessionManager {
|
|||||||
nonisolated private static func decryptIncomingMessage(
|
nonisolated private static func decryptIncomingMessage(
|
||||||
packet: PacketMessage,
|
packet: PacketMessage,
|
||||||
myPublicKey: String,
|
myPublicKey: String,
|
||||||
privateKeyHex: String?
|
privateKeyHex: String?,
|
||||||
|
groupKey: String?
|
||||||
) -> (text: String, rawKeyData: Data?)? {
|
) -> (text: String, rawKeyData: Data?)? {
|
||||||
let isOwnMessage = packet.fromPublicKey == myPublicKey
|
let isOwnMessage = packet.fromPublicKey == myPublicKey
|
||||||
|
|
||||||
|
if let groupKey {
|
||||||
|
if packet.content.isEmpty {
|
||||||
|
return ("", nil)
|
||||||
|
}
|
||||||
|
if let data = try? CryptoManager.shared.decryptWithPassword(
|
||||||
|
packet.content,
|
||||||
|
password: groupKey
|
||||||
|
), let text = String(data: data, encoding: .utf8) {
|
||||||
|
return (text, nil)
|
||||||
|
}
|
||||||
|
Self.logger.warning("Group decrypt failed for msgId=\(packet.messageId.prefix(8))…")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let privateKeyHex, !packet.content.isEmpty else {
|
guard let privateKeyHex, !packet.content.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1689,12 +1855,36 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum PacketDialogKind {
|
||||||
|
case direct
|
||||||
|
case saved
|
||||||
|
case group
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PacketDialogContext {
|
||||||
|
let kind: PacketDialogKind
|
||||||
|
let dialogKey: String
|
||||||
|
let fromKey: String
|
||||||
|
let toKey: String
|
||||||
|
let fromMe: Bool
|
||||||
|
let toMe: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedGroupDialogIdentity(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
if lower.hasPrefix("group:") {
|
||||||
|
let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return id.isEmpty ? trimmed : "#group:\(id)"
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
private static func isUnsupportedDialogKey(_ value: String) -> Bool {
|
private static func isUnsupportedDialogKey(_ value: String) -> Bool {
|
||||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
if normalized.isEmpty { return true }
|
if normalized.isEmpty { return true }
|
||||||
|
if DatabaseManager.isGroupDialogKey(normalized) { return false }
|
||||||
return normalized.hasPrefix("#")
|
return normalized.hasPrefix("#")
|
||||||
|| normalized.hasPrefix("group:")
|
|
||||||
|| normalized.hasPrefix("conversation:")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool {
|
private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool {
|
||||||
@@ -1705,34 +1895,117 @@ final class SessionManager {
|
|||||||
return !isUnsupportedDialogKey(normalized)
|
return !isUnsupportedDialogKey(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSupportedDirectMessagePacket(_ packet: PacketMessage, ownKey: String) -> Bool {
|
private static func resolveDialogContext(from: String, to: String, ownKey: String) -> PacketDialogContext? {
|
||||||
if ownKey.isEmpty { return false }
|
if ownKey.isEmpty { return nil }
|
||||||
let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if from.isEmpty || to.isEmpty { return false }
|
if fromKey.isEmpty || toKey.isEmpty { return nil }
|
||||||
|
|
||||||
if from == ownKey {
|
if DatabaseManager.isGroupDialogKey(toKey) {
|
||||||
return isSupportedDirectPeerKey(to, ownKey: ownKey)
|
return PacketDialogContext(
|
||||||
|
kind: .group,
|
||||||
|
dialogKey: normalizedGroupDialogIdentity(toKey),
|
||||||
|
fromKey: fromKey,
|
||||||
|
toKey: toKey,
|
||||||
|
fromMe: fromKey == ownKey,
|
||||||
|
toMe: toKey == ownKey
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if to == ownKey {
|
|
||||||
return isSupportedDirectPeerKey(from, ownKey: ownKey)
|
if fromKey == ownKey {
|
||||||
|
guard isSupportedDirectPeerKey(toKey, ownKey: ownKey) else { return nil }
|
||||||
|
return PacketDialogContext(
|
||||||
|
kind: toKey == ownKey ? .saved : .direct,
|
||||||
|
dialogKey: toKey,
|
||||||
|
fromKey: fromKey,
|
||||||
|
toKey: toKey,
|
||||||
|
fromMe: true,
|
||||||
|
toMe: toKey == ownKey
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return false
|
if toKey == ownKey {
|
||||||
|
guard isSupportedDirectPeerKey(fromKey, ownKey: ownKey) else { return nil }
|
||||||
|
return PacketDialogContext(
|
||||||
|
kind: fromKey == ownKey ? .saved : .direct,
|
||||||
|
dialogKey: fromKey,
|
||||||
|
fromKey: fromKey,
|
||||||
|
toKey: toKey,
|
||||||
|
fromMe: false,
|
||||||
|
toMe: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSupportedDirectReadPacket(_ packet: PacketRead, ownKey: String) -> Bool {
|
private static func resolveMessagePacketContext(_ packet: PacketMessage, ownKey: String) -> PacketDialogContext? {
|
||||||
if ownKey.isEmpty { return false }
|
resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
|
||||||
let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
}
|
||||||
let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if from.isEmpty || to.isEmpty { return false }
|
|
||||||
|
|
||||||
if from == ownKey {
|
private static func resolveReadPacketContext(_ packet: PacketRead, ownKey: String) -> PacketDialogContext? {
|
||||||
return isSupportedDirectPeerKey(to, ownKey: ownKey)
|
resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveTypingPacketContext(_ packet: PacketTyping, ownKey: String) -> PacketDialogContext? {
|
||||||
|
resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Support
|
||||||
|
|
||||||
|
static func testResolveMessagePacketContext(
|
||||||
|
_ packet: PacketMessage,
|
||||||
|
ownKey: String
|
||||||
|
) -> (kind: String, dialogKey: String, fromMe: Bool)? {
|
||||||
|
guard let context = resolveMessagePacketContext(packet, ownKey: ownKey) else { return nil }
|
||||||
|
let kind: String
|
||||||
|
switch context.kind {
|
||||||
|
case .direct: kind = "direct"
|
||||||
|
case .saved: kind = "saved"
|
||||||
|
case .group: kind = "group"
|
||||||
}
|
}
|
||||||
if to == ownKey {
|
return (kind, context.dialogKey, context.fromMe)
|
||||||
return isSupportedDirectPeerKey(from, ownKey: ownKey)
|
}
|
||||||
|
|
||||||
|
static func testResolveReadPacketContext(
|
||||||
|
_ packet: PacketRead,
|
||||||
|
ownKey: String
|
||||||
|
) -> (kind: String, dialogKey: String, fromMe: Bool)? {
|
||||||
|
guard let context = resolveReadPacketContext(packet, ownKey: ownKey) else { return nil }
|
||||||
|
let kind: String
|
||||||
|
switch context.kind {
|
||||||
|
case .direct: kind = "direct"
|
||||||
|
case .saved: kind = "saved"
|
||||||
|
case .group: kind = "group"
|
||||||
}
|
}
|
||||||
return false
|
return (kind, context.dialogKey, context.fromMe)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func testResolveTypingPacketContext(
|
||||||
|
_ packet: PacketTyping,
|
||||||
|
ownKey: String
|
||||||
|
) -> (kind: String, dialogKey: String, fromMe: Bool)? {
|
||||||
|
guard let context = resolveTypingPacketContext(packet, ownKey: ownKey) else { return nil }
|
||||||
|
let kind: String
|
||||||
|
switch context.kind {
|
||||||
|
case .direct: kind = "direct"
|
||||||
|
case .saved: kind = "saved"
|
||||||
|
case .group: kind = "group"
|
||||||
|
}
|
||||||
|
return (kind, context.dialogKey, context.fromMe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigureSessionForParityFlows(
|
||||||
|
currentPublicKey: String,
|
||||||
|
privateKeyHex: String,
|
||||||
|
privateKeyHash: String? = nil
|
||||||
|
) {
|
||||||
|
self.currentPublicKey = currentPublicKey
|
||||||
|
self.privateKeyHex = privateKeyHex
|
||||||
|
self.privateKeyHash = privateKeyHash ?? CryptoManager.shared.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResetParityFlowDependencies() {
|
||||||
|
attachmentFlowTransport = LiveAttachmentFlowTransport()
|
||||||
|
packetFlowSender = LivePacketFlowSender()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public convenience for views that need to trigger a user-info fetch.
|
/// Public convenience for views that need to trigger a user-info fetch.
|
||||||
@@ -1744,6 +2017,7 @@ final class SessionManager {
|
|||||||
guard let privateKeyHash else { return }
|
guard let privateKeyHash else { return }
|
||||||
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !normalized.isEmpty else { return }
|
guard !normalized.isEmpty else { return }
|
||||||
|
guard !DatabaseManager.isGroupDialogKey(normalized) else { return }
|
||||||
guard !requestedUserInfoKeys.contains(normalized) else { return }
|
guard !requestedUserInfoKeys.contains(normalized) else { return }
|
||||||
|
|
||||||
requestedUserInfoKeys.insert(normalized)
|
requestedUserInfoKeys.insert(normalized)
|
||||||
@@ -1769,6 +2043,7 @@ final class SessionManager {
|
|||||||
var hasName: [String] = []
|
var hasName: [String] = []
|
||||||
for (key, dialog) in dialogs {
|
for (key, dialog) in dialogs {
|
||||||
guard key != ownKey, !key.isEmpty else { continue }
|
guard key != ownKey, !key.isEmpty else { continue }
|
||||||
|
guard !DatabaseManager.isGroupDialogKey(key) else { continue }
|
||||||
if dialog.opponentTitle.isEmpty {
|
if dialog.opponentTitle.isEmpty {
|
||||||
missingName.append(key)
|
missingName.append(key)
|
||||||
} else {
|
} else {
|
||||||
@@ -1863,9 +2138,37 @@ final class SessionManager {
|
|||||||
privateKeyHex: String,
|
privateKeyHex: String,
|
||||||
privateKeyHash: String
|
privateKeyHash: String
|
||||||
) throws -> PacketMessage {
|
) throws -> PacketMessage {
|
||||||
|
let normalizedTarget = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if DatabaseManager.isGroupDialogKey(normalizedTarget) {
|
||||||
|
let normalizedGroupTarget = Self.normalizedGroupDialogIdentity(normalizedTarget)
|
||||||
|
guard let groupKey = GroupRepository.shared.groupKey(
|
||||||
|
account: currentPublicKey,
|
||||||
|
privateKeyHex: privateKeyHex,
|
||||||
|
groupDialogKey: normalizedGroupTarget
|
||||||
|
) else {
|
||||||
|
throw CryptoError.invalidData("Missing group key for \(normalizedGroupTarget)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
|
||||||
|
Data(text.utf8),
|
||||||
|
password: groupKey
|
||||||
|
)
|
||||||
|
|
||||||
|
var packet = PacketMessage()
|
||||||
|
packet.fromPublicKey = currentPublicKey
|
||||||
|
packet.toPublicKey = normalizedGroupTarget
|
||||||
|
packet.content = encryptedContent
|
||||||
|
packet.chachaKey = ""
|
||||||
|
packet.timestamp = timestamp
|
||||||
|
packet.privateKey = privateKeyHash
|
||||||
|
packet.messageId = messageId
|
||||||
|
packet.aesChachaKey = ""
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||||
plaintext: text,
|
plaintext: text,
|
||||||
recipientPublicKeyHex: toPublicKey
|
recipientPublicKeyHex: normalizedTarget
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||||
@@ -1879,7 +2182,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
var packet = PacketMessage()
|
var packet = PacketMessage()
|
||||||
packet.fromPublicKey = currentPublicKey
|
packet.fromPublicKey = currentPublicKey
|
||||||
packet.toPublicKey = toPublicKey
|
packet.toPublicKey = normalizedTarget
|
||||||
packet.content = encrypted.content
|
packet.content = encrypted.content
|
||||||
packet.chachaKey = encrypted.chachaKey
|
packet.chachaKey = encrypted.chachaKey
|
||||||
packet.timestamp = timestamp
|
packet.timestamp = timestamp
|
||||||
|
|||||||
98
Rosetta/Core/Utils/AttachmentPreviewCodec.swift
Normal file
98
Rosetta/Core/Utils/AttachmentPreviewCodec.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared parser/composer for attachment preview fields.
|
||||||
|
///
|
||||||
|
/// Cross-platform canonical formats:
|
||||||
|
/// - image/avatar: `tag::blurhash` or `::blurhash` (placeholder before upload)
|
||||||
|
/// - file: `tag::size::name` or `size::name`
|
||||||
|
/// - legacy/local-only: raw preview payload without `tag::` prefix
|
||||||
|
enum AttachmentPreviewCodec {
|
||||||
|
|
||||||
|
struct ParsedFilePreview: Equatable {
|
||||||
|
let downloadTag: String
|
||||||
|
let fileSize: Int
|
||||||
|
let fileName: String
|
||||||
|
let payload: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isDownloadTag(_ value: String) -> Bool {
|
||||||
|
UUID(uuidString: value.trimmingCharacters(in: .whitespacesAndNewlines)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func downloadTag(from preview: String) -> String {
|
||||||
|
let firstPart = preview.components(separatedBy: "::").first ?? ""
|
||||||
|
return isDownloadTag(firstPart) ? firstPart : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func payload(from preview: String) -> String {
|
||||||
|
let parts = preview.components(separatedBy: "::")
|
||||||
|
guard parts.isEmpty == false else { return "" }
|
||||||
|
|
||||||
|
if isDownloadTag(parts[0]) {
|
||||||
|
return normalizePayload(parts.dropFirst().joined(separator: "::"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder preview before upload (`::blurhash` / `::size::name`).
|
||||||
|
if parts[0].isEmpty {
|
||||||
|
return normalizePayload(parts.dropFirst().joined(separator: "::"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePayload(preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func blurHash(from preview: String) -> String {
|
||||||
|
payload(from: preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseFilePreview(
|
||||||
|
_ preview: String,
|
||||||
|
fallbackFileName: String = "file",
|
||||||
|
fallbackFileSize: Int = 0
|
||||||
|
) -> ParsedFilePreview {
|
||||||
|
let tag = downloadTag(from: preview)
|
||||||
|
let normalizedPayload = payload(from: preview)
|
||||||
|
let components = normalizedPayload.components(separatedBy: "::")
|
||||||
|
|
||||||
|
var fileSize = max(fallbackFileSize, 0)
|
||||||
|
var fileName = fallbackFileName
|
||||||
|
|
||||||
|
if components.count >= 2, let parsedSize = Int(components[0]) {
|
||||||
|
fileSize = max(parsedSize, 0)
|
||||||
|
let joinedName = components.dropFirst().joined(separator: "::")
|
||||||
|
if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||||
|
fileName = joinedName
|
||||||
|
}
|
||||||
|
} else if components.count >= 2 {
|
||||||
|
// Legacy payload without explicit size.
|
||||||
|
let joinedName = components.joined(separator: "::")
|
||||||
|
if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||||
|
fileName = joinedName
|
||||||
|
}
|
||||||
|
} else if let onlyComponent = components.first,
|
||||||
|
onlyComponent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||||
|
fileName = onlyComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedFilePreview(
|
||||||
|
downloadTag: tag,
|
||||||
|
fileSize: fileSize,
|
||||||
|
fileName: fileName,
|
||||||
|
payload: normalizedPayload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func compose(downloadTag: String, payload: String) -> String {
|
||||||
|
let tag = downloadTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedPayload = normalizePayload(payload)
|
||||||
|
if tag.isEmpty { return normalizedPayload }
|
||||||
|
return "\(tag)::\(normalizedPayload)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizePayload(_ payload: String) -> String {
|
||||||
|
var value = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while value.hasPrefix("::") {
|
||||||
|
value.removeFirst(2)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
120
Rosetta/Core/Utils/SearchParityPolicy.swift
Normal file
120
Rosetta/Core/Utils/SearchParityPolicy.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared normalization and merge policy for user search flows.
|
||||||
|
///
|
||||||
|
/// Canonical semantics:
|
||||||
|
/// - server query: `username contains` + `publicKey exact`
|
||||||
|
/// - local augmentation: only Saved Messages aliases + exact known key fallback
|
||||||
|
enum SearchParityPolicy {
|
||||||
|
|
||||||
|
private static let savedAliases: Set<String> = [
|
||||||
|
"saved",
|
||||||
|
"saved messages",
|
||||||
|
"savedmessages",
|
||||||
|
"notes",
|
||||||
|
"self",
|
||||||
|
"me",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func sanitizeInput(_ query: String) -> String {
|
||||||
|
query.replacingOccurrences(of: "@", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizedQuery(_ query: String) -> String {
|
||||||
|
sanitizeInput(query).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizedPublicKey(_ key: String) -> String {
|
||||||
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if trimmed.hasPrefix("0x") {
|
||||||
|
return String(trimmed.dropFirst(2))
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isExactPublicKeyQuery(_ query: String) -> Bool {
|
||||||
|
let normalized = normalizedPublicKey(query)
|
||||||
|
guard normalized.count == 66 else { return false }
|
||||||
|
return normalized.allSatisfy(\.isHexDigit)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSavedMessagesAlias(_ query: String) -> Bool {
|
||||||
|
savedAliases.contains(normalizedQuery(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func localAugmentedUsers(
|
||||||
|
query: String,
|
||||||
|
currentPublicKey: String,
|
||||||
|
dialogs: [Dialog]
|
||||||
|
) -> [SearchUser] {
|
||||||
|
let normalized = normalizedQuery(query)
|
||||||
|
guard !normalized.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var results: [SearchUser] = []
|
||||||
|
|
||||||
|
if isSavedMessagesAlias(normalized) || matchesExactPublicKey(query: normalized, publicKey: currentPublicKey) {
|
||||||
|
results.append(SearchUser(
|
||||||
|
username: "",
|
||||||
|
title: "Saved Messages",
|
||||||
|
publicKey: currentPublicKey,
|
||||||
|
verified: 0,
|
||||||
|
online: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isExactPublicKeyQuery(normalized) else {
|
||||||
|
return dedupByPublicKey(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
for dialog in dialogs where matchesExactPublicKey(query: normalized, publicKey: dialog.opponentKey) {
|
||||||
|
guard dialog.opponentKey != currentPublicKey else { continue }
|
||||||
|
results.append(SearchUser(
|
||||||
|
username: dialog.opponentUsername,
|
||||||
|
title: dialog.opponentTitle,
|
||||||
|
publicKey: dialog.opponentKey,
|
||||||
|
verified: dialog.verified,
|
||||||
|
online: dialog.isOnline ? 0 : 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupByPublicKey(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mergeServerAndLocal(server: [SearchUser], local: [SearchUser]) -> [SearchUser] {
|
||||||
|
var merged: [SearchUser] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
for user in server {
|
||||||
|
let key = normalizedPublicKey(user.publicKey)
|
||||||
|
guard !seen.contains(key) else { continue }
|
||||||
|
seen.insert(key)
|
||||||
|
merged.append(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in local {
|
||||||
|
let key = normalizedPublicKey(user.publicKey)
|
||||||
|
guard !seen.contains(key) else { continue }
|
||||||
|
seen.insert(key)
|
||||||
|
merged.append(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchesExactPublicKey(query: String, publicKey: String) -> Bool {
|
||||||
|
normalizedPublicKey(query) == normalizedPublicKey(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dedupByPublicKey(_ users: [SearchUser]) -> [SearchUser] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var deduped: [SearchUser] = []
|
||||||
|
for user in users {
|
||||||
|
let key = normalizedPublicKey(user.publicKey)
|
||||||
|
guard !seen.contains(key) else { continue }
|
||||||
|
seen.insert(key)
|
||||||
|
deduped.append(user)
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,9 +310,14 @@ private extension UnlockView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUnlocked()
|
onUnlocked()
|
||||||
|
} catch let sessionError as SessionManager.StartSessionError {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
errorMessage = unlockMessage(for: sessionError)
|
||||||
|
}
|
||||||
|
isUnlocking = false
|
||||||
} catch {
|
} catch {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
errorMessage = "Wrong password. Please try again."
|
errorMessage = unlockMessage(for: error)
|
||||||
}
|
}
|
||||||
isUnlocking = false
|
isUnlocking = false
|
||||||
}
|
}
|
||||||
@@ -398,11 +403,23 @@ private extension UnlockView {
|
|||||||
} catch {
|
} catch {
|
||||||
// SessionManager.startSession failed — stored password might be wrong
|
// SessionManager.startSession failed — stored password might be wrong
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
errorMessage = "Wrong password. Please try again."
|
errorMessage = unlockMessage(for: error)
|
||||||
}
|
}
|
||||||
isUnlocking = false
|
isUnlocking = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unlockMessage(for error: Error) -> String {
|
||||||
|
if let sessionError = error as? SessionManager.StartSessionError {
|
||||||
|
switch sessionError {
|
||||||
|
case .invalidCredentials:
|
||||||
|
return "Wrong password. Please try again."
|
||||||
|
case .databaseBootstrapFailed:
|
||||||
|
return "Database migration failed. Please restart the app and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Wrong password. Please try again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UIKit Password Field
|
// MARK: - UIKit Password Field
|
||||||
|
|||||||
@@ -1,224 +1,30 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Bubble Position
|
|
||||||
|
|
||||||
enum BubblePosition: Sendable, Equatable {
|
|
||||||
case single, top, mid, bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Bubble Shape
|
// MARK: - Message Bubble Shape
|
||||||
|
|
||||||
/// Unified message bubble shape: rounded-rect body + tail drawn as a **single fill**.
|
|
||||||
///
|
|
||||||
/// The body and tail are two closed subpaths inside one `Path`.
|
|
||||||
/// Non-zero winding rule fills the overlap area seamlessly —
|
|
||||||
/// no anti-aliasing seam between body and tail.
|
|
||||||
///
|
|
||||||
/// For positions without a tail (`.top`, `.mid`), only the body subpath is drawn.
|
|
||||||
/// For positions with a tail (`.single`, `.bottom`), both subpaths are drawn.
|
|
||||||
///
|
|
||||||
/// The shape's `rect` includes space for the tail protrusion on the near side.
|
|
||||||
/// The body is inset from that side; the tail fills the protrusion area.
|
|
||||||
struct MessageBubbleShape: Shape {
|
struct MessageBubbleShape: Shape {
|
||||||
let position: BubblePosition
|
let position: BubblePosition
|
||||||
let outgoing: Bool
|
let outgoing: Bool
|
||||||
let hasTail: Bool
|
private let mergeType: BubbleMergeType
|
||||||
|
private let metrics: BubbleMetrics
|
||||||
/// How far the tail protrudes beyond the bubble body edge (points).
|
|
||||||
static let tailProtrusion: CGFloat = 6
|
|
||||||
|
|
||||||
init(position: BubblePosition, outgoing: Bool) {
|
init(position: BubblePosition, outgoing: Bool) {
|
||||||
self.position = position
|
self.position = position
|
||||||
self.outgoing = outgoing
|
self.outgoing = outgoing
|
||||||
switch position {
|
self.mergeType = BubbleGeometryEngine.mergeType(for: position)
|
||||||
case .single, .bottom: self.hasTail = true
|
self.metrics = .telegram()
|
||||||
case .top, .mid: self.hasTail = false
|
}
|
||||||
}
|
|
||||||
|
static var tailProtrusion: CGFloat {
|
||||||
|
BubbleMetrics.telegram().tailProtrusion
|
||||||
}
|
}
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
var p = Path()
|
BubbleGeometryEngine.makeSwiftUIPath(
|
||||||
|
in: rect,
|
||||||
// Body rect: inset on the near side when tail is present
|
mergeType: mergeType,
|
||||||
let bodyRect: CGRect
|
outgoing: outgoing,
|
||||||
if hasTail {
|
metrics: metrics
|
||||||
if outgoing {
|
)
|
||||||
bodyRect = CGRect(x: rect.minX, y: rect.minY,
|
|
||||||
width: rect.width - Self.tailProtrusion, height: rect.height)
|
|
||||||
} else {
|
|
||||||
bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY,
|
|
||||||
width: rect.width - Self.tailProtrusion, height: rect.height)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bodyRect = rect
|
|
||||||
}
|
|
||||||
|
|
||||||
addBody(to: &p, rect: bodyRect)
|
|
||||||
|
|
||||||
if hasTail {
|
|
||||||
addTail(to: &p, bodyRect: bodyRect)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Body (Rounded Rect with Per-Corner Radii)
|
|
||||||
|
|
||||||
private func addBody(to p: inout Path, rect: CGRect) {
|
|
||||||
let r: CGFloat = 16
|
|
||||||
let s: CGFloat = 5
|
|
||||||
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
|
|
||||||
|
|
||||||
// Clamp to half the smallest dimension
|
|
||||||
let maxR = min(rect.width, rect.height) / 2
|
|
||||||
let cTL = min(tl, maxR)
|
|
||||||
let cTR = min(tr, maxR)
|
|
||||||
let cBL = min(bl, maxR)
|
|
||||||
let cBR = min(br, maxR)
|
|
||||||
|
|
||||||
p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
|
|
||||||
|
|
||||||
// Top edge → top-right corner
|
|
||||||
p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
|
|
||||||
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
|
|
||||||
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR),
|
|
||||||
radius: cTR)
|
|
||||||
|
|
||||||
// Right edge → bottom-right corner
|
|
||||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
|
|
||||||
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
|
|
||||||
tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY),
|
|
||||||
radius: cBR)
|
|
||||||
|
|
||||||
// Bottom edge → bottom-left corner
|
|
||||||
p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
|
|
||||||
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
|
|
||||||
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL),
|
|
||||||
radius: cBL)
|
|
||||||
|
|
||||||
// Left edge → top-left corner
|
|
||||||
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
|
|
||||||
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
|
|
||||||
tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY),
|
|
||||||
radius: cTL)
|
|
||||||
|
|
||||||
p.closeSubpath()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Figma corner radii: 8px on "connecting" side, 18px elsewhere.
|
|
||||||
private func cornerRadii(r: CGFloat, s: CGFloat)
|
|
||||||
-> (topLeading: CGFloat, topTrailing: CGFloat,
|
|
||||||
bottomLeading: CGFloat, bottomTrailing: CGFloat) {
|
|
||||||
switch position {
|
|
||||||
case .single:
|
|
||||||
return (r, r, r, r)
|
|
||||||
case .top:
|
|
||||||
return outgoing
|
|
||||||
? (r, r, r, s)
|
|
||||||
: (r, r, s, r)
|
|
||||||
case .mid:
|
|
||||||
return outgoing
|
|
||||||
? (r, s, r, s)
|
|
||||||
: (s, r, s, r)
|
|
||||||
case .bottom:
|
|
||||||
return outgoing
|
|
||||||
? (r, s, r, r)
|
|
||||||
: (s, r, r, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Tail (Figma SVG — separate subpath)
|
|
||||||
|
|
||||||
/// Draws the tail as a second closed subpath that overlaps the body at the
|
|
||||||
/// bottom-near corner. Both subpaths are filled together in one `.fill()` call,
|
|
||||||
/// so the overlapping area has no visible seam.
|
|
||||||
///
|
|
||||||
/// Uses the exact Figma SVG path (viewBox 0 0 13.6216 33.3).
|
|
||||||
/// Raw SVG: straight edge at x≈5.6, tip protrudes LEFT to x=0.
|
|
||||||
/// The `dir` multiplier flips the protrusion direction for outgoing.
|
|
||||||
private func addTail(to p: inout Path, bodyRect: CGRect) {
|
|
||||||
// Figma SVG straight edge X — defines the body attachment line
|
|
||||||
let svgStraightX: CGFloat = 5.59961
|
|
||||||
let svgMaxY: CGFloat = 33.2305
|
|
||||||
|
|
||||||
// Uniform scale: maps SVG protrusion (5.6 units) to screen protrusion
|
|
||||||
let sc = Self.tailProtrusion / svgStraightX
|
|
||||||
|
|
||||||
// Tail height in points
|
|
||||||
let tailH = svgMaxY * sc
|
|
||||||
|
|
||||||
let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX
|
|
||||||
let bottom = bodyRect.maxY
|
|
||||||
let top = bottom - tailH
|
|
||||||
|
|
||||||
// +1 = protrude RIGHT (outgoing), −1 = protrude LEFT (incoming)
|
|
||||||
let dir: CGFloat = outgoing ? 1 : -1
|
|
||||||
|
|
||||||
// Map raw Figma SVG coord → screen coord
|
|
||||||
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
|
|
||||||
let dx = (svgStraightX - svgX) * sc * dir
|
|
||||||
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Exact Figma SVG path (from Figma API, viewBox 0 0 13.6216 33.3) --
|
|
||||||
// M5.59961 24.2305
|
|
||||||
// C5.42042 28.0524 3.19779 31.339 0 33.0244
|
|
||||||
// C0.851596 33.1596 1.72394 33.2305 2.6123 33.2305
|
|
||||||
// C6.53776 33.2305 10.1517 31.8599 13.0293 29.5596
|
|
||||||
// C10.7434 27.898 8.86922 25.7134 7.57422 23.1719
|
|
||||||
// C5.61235 19.3215 5.6123 14.281 5.6123 4.2002
|
|
||||||
// V0 H5.59961 V24.2305 Z
|
|
||||||
|
|
||||||
if outgoing {
|
|
||||||
// Forward order — clockwise winding (matches body)
|
|
||||||
p.move(to: tp(5.59961, 24.2305))
|
|
||||||
p.addCurve(to: tp(0, 33.0244),
|
|
||||||
control1: tp(5.42042, 28.0524),
|
|
||||||
control2: tp(3.19779, 31.339))
|
|
||||||
p.addCurve(to: tp(2.6123, 33.2305),
|
|
||||||
control1: tp(0.851596, 33.1596),
|
|
||||||
control2: tp(1.72394, 33.2305))
|
|
||||||
p.addCurve(to: tp(13.0293, 29.5596),
|
|
||||||
control1: tp(6.53776, 33.2305),
|
|
||||||
control2: tp(10.1517, 31.8599))
|
|
||||||
p.addCurve(to: tp(7.57422, 23.1719),
|
|
||||||
control1: tp(10.7434, 27.898),
|
|
||||||
control2: tp(8.86922, 25.7134))
|
|
||||||
p.addCurve(to: tp(5.6123, 4.2002),
|
|
||||||
control1: tp(5.61235, 19.3215),
|
|
||||||
control2: tp(5.6123, 14.281))
|
|
||||||
p.addLine(to: tp(5.6123, 0))
|
|
||||||
p.addLine(to: tp(5.59961, 0))
|
|
||||||
p.addLine(to: tp(5.59961, 24.2305))
|
|
||||||
p.closeSubpath()
|
|
||||||
} else {
|
|
||||||
// Reversed order — clockwise winding for incoming
|
|
||||||
// (mirroring X flips winding; reversing path order restores it)
|
|
||||||
p.move(to: tp(5.59961, 24.2305))
|
|
||||||
p.addLine(to: tp(5.59961, 0))
|
|
||||||
p.addLine(to: tp(5.6123, 0))
|
|
||||||
p.addLine(to: tp(5.6123, 4.2002))
|
|
||||||
// Curve 5 reversed (swap control points)
|
|
||||||
p.addCurve(to: tp(7.57422, 23.1719),
|
|
||||||
control1: tp(5.6123, 14.281),
|
|
||||||
control2: tp(5.61235, 19.3215))
|
|
||||||
// Curve 4 reversed
|
|
||||||
p.addCurve(to: tp(13.0293, 29.5596),
|
|
||||||
control1: tp(8.86922, 25.7134),
|
|
||||||
control2: tp(10.7434, 27.898))
|
|
||||||
// Curve 3 reversed
|
|
||||||
p.addCurve(to: tp(2.6123, 33.2305),
|
|
||||||
control1: tp(10.1517, 31.8599),
|
|
||||||
control2: tp(6.53776, 33.2305))
|
|
||||||
// Curve 2 reversed
|
|
||||||
p.addCurve(to: tp(0, 33.0244),
|
|
||||||
control1: tp(1.72394, 33.2305),
|
|
||||||
control2: tp(0.851596, 33.1596))
|
|
||||||
// Curve 1 reversed
|
|
||||||
p.addCurve(to: tp(5.59961, 24.2305),
|
|
||||||
control1: tp(3.19779, 31.339),
|
|
||||||
control2: tp(5.42042, 28.0524))
|
|
||||||
p.closeSubpath()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,11 +127,21 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var maxBubbleWidth: CGFloat {
|
private var maxBubbleWidth: CGFloat {
|
||||||
let w = UIScreen.main.bounds.width
|
let screenWidth = UIScreen.main.bounds.width
|
||||||
if w <= 500 {
|
let listHorizontalInsets: CGFloat = 20 // NativeMessageList section insets: leading/trailing 10
|
||||||
return max(224, min(w * 0.72, w - 104))
|
let bubbleHorizontalMargins: CGFloat = 16 // 8pt left + 8pt right bubble lane reserves
|
||||||
}
|
let availableWidth = max(40, screenWidth - listHorizontalInsets - bubbleHorizontalMargins)
|
||||||
return min(w * 0.66, 460)
|
|
||||||
|
// Telegram ChatMessageItemWidthFill:
|
||||||
|
// compactInset = 36, compactWidthBoundary = 500, freeMaximumFillFactor = 0.85/0.65.
|
||||||
|
let compactInset: CGFloat = 36
|
||||||
|
let freeFillFactor: CGFloat = screenWidth > 680 ? 0.65 : 0.85
|
||||||
|
|
||||||
|
let widthByInset = availableWidth - compactInset
|
||||||
|
let widthByFactor = availableWidth * freeFillFactor
|
||||||
|
let width = min(widthByInset, widthByFactor)
|
||||||
|
|
||||||
|
return max(40, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visual chat content: messages list + gradient overlays + background.
|
/// Visual chat content: messages list + gradient overlays + background.
|
||||||
@@ -1096,8 +1106,8 @@ private extension ChatDetailView {
|
|||||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !caption.isEmpty { return caption }
|
if !caption.isEmpty { return caption }
|
||||||
let parts = file.preview.components(separatedBy: "::")
|
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||||
if parts.count >= 3 { return parts[2] }
|
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||||
return file.id.isEmpty ? "File" : file.id
|
return file.id.isEmpty ? "File" : file.id
|
||||||
}
|
}
|
||||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||||
@@ -1121,9 +1131,8 @@ private extension ChatDetailView {
|
|||||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !caption.isEmpty { return caption }
|
if !caption.isEmpty { return caption }
|
||||||
// Parse filename from preview (tag::fileSize::fileName)
|
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||||
let parts = file.preview.components(separatedBy: "::")
|
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||||
if parts.count >= 3 { return parts[2] }
|
|
||||||
return file.id.isEmpty ? "File" : file.id
|
return file.id.isEmpty ? "File" : file.id
|
||||||
}
|
}
|
||||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||||
@@ -1261,7 +1270,7 @@ private extension ChatDetailView {
|
|||||||
for att in replyData.attachments {
|
for att in replyData.attachments {
|
||||||
if att.type == AttachmentType.image.rawValue {
|
if att.type == AttachmentType.image.rawValue {
|
||||||
// ── Image re-upload ──
|
// ── Image re-upload ──
|
||||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) {
|
if let image = AttachmentCache.shared.cachedImage(forAttachmentId: att.id) {
|
||||||
// JPEG encoding (10-50ms) off main thread
|
// JPEG encoding (10-50ms) off main thread
|
||||||
let jpegData = await Task.detached(priority: .userInitiated) {
|
let jpegData = await Task.detached(priority: .userInitiated) {
|
||||||
image.jpegData(compressionQuality: 0.85)
|
image.jpegData(compressionQuality: 0.85)
|
||||||
@@ -1269,14 +1278,35 @@ private extension ChatDetailView {
|
|||||||
if let jpegData {
|
if let jpegData {
|
||||||
forwardedImages[att.id] = jpegData
|
forwardedImages[att.id] = jpegData
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
|
print("📤 Image \(att.id.prefix(16)): loaded from memory cache (\(jpegData.count) bytes)")
|
||||||
|
#endif
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: disk I/O + decrypt off main thread.
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let image = await Task.detached(priority: .userInitiated) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: att.id)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
|
||||||
|
if let image {
|
||||||
|
// JPEG encoding (10-50ms) off main thread
|
||||||
|
let jpegData = await Task.detached(priority: .userInitiated) {
|
||||||
|
image.jpegData(compressionQuality: 0.85)
|
||||||
|
}.value
|
||||||
|
if let jpegData {
|
||||||
|
forwardedImages[att.id] = jpegData
|
||||||
|
#if DEBUG
|
||||||
|
print("📤 Image \(att.id.prefix(16)): loaded from disk cache (\(jpegData.count) bytes)")
|
||||||
#endif
|
#endif
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not in cache — download from CDN, decrypt, then include.
|
// Not in cache — download from CDN, decrypt, then include.
|
||||||
let cdnTag = att.preview.components(separatedBy: "::").first ?? ""
|
let cdnTag = AttachmentPreviewCodec.downloadTag(from: att.preview)
|
||||||
guard !cdnTag.isEmpty else {
|
guard !cdnTag.isEmpty else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
|
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
|
||||||
@@ -1333,8 +1363,11 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
} else if att.type == AttachmentType.file.rawValue {
|
} else if att.type == AttachmentType.file.rawValue {
|
||||||
// ── File re-upload (Desktop parity: prepareAttachmentsToSend) ──
|
// ── File re-upload (Desktop parity: prepareAttachmentsToSend) ──
|
||||||
let parts = att.preview.components(separatedBy: "::")
|
let parsedFile = AttachmentPreviewCodec.parseFilePreview(
|
||||||
let fileName = parts.count > 2 ? parts[2] : "file"
|
att.preview,
|
||||||
|
fallbackFileName: "file"
|
||||||
|
)
|
||||||
|
let fileName = parsedFile.fileName
|
||||||
|
|
||||||
// Try local cache first
|
// Try local cache first
|
||||||
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
|
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
|
||||||
@@ -1346,7 +1379,7 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not in cache — download from CDN, decrypt
|
// Not in cache — download from CDN, decrypt
|
||||||
let cdnTag = parts.first ?? ""
|
let cdnTag = parsedFile.downloadTag
|
||||||
guard !cdnTag.isEmpty else {
|
guard !cdnTag.isEmpty else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
|
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
|
||||||
@@ -1947,18 +1980,28 @@ struct ForwardedImagePreviewCell: View {
|
|||||||
blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64)
|
blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache immediately — image may already be there.
|
// Fast path: memory cache only (no disk/crypto on UI path).
|
||||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
cachedImage = img
|
cachedImage = img
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry: the original MessageImageView may still be downloading.
|
// Slow path: one background disk/decrypt attempt.
|
||||||
// Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations.
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .utility) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
if let loaded, !Task.isCancelled {
|
||||||
|
cachedImage = loaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry memory cache only: original MessageImageView may still be downloading.
|
||||||
for _ in 0..<5 {
|
for _ in 0..<5 {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { return }
|
||||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
cachedImage = img
|
cachedImage = img
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1968,8 +2011,7 @@ struct ForwardedImagePreviewCell: View {
|
|||||||
|
|
||||||
private func extractBlurHash() -> String? {
|
private func extractBlurHash() -> String? {
|
||||||
guard !attachment.preview.isEmpty else { return nil }
|
guard !attachment.preview.isEmpty else { return nil }
|
||||||
let parts = attachment.preview.components(separatedBy: "::")
|
let hash = AttachmentPreviewCodec.blurHash(from: attachment.preview)
|
||||||
let hash = parts.count > 1 ? parts[1] : attachment.preview
|
|
||||||
return hash.isEmpty ? nil : hash
|
return hash.isEmpty ? nil : hash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2003,16 +2045,28 @@ struct ReplyQuoteThumbnail: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Check AttachmentCache for the actual downloaded photo.
|
// Fast path: memory cache only.
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
cachedImage = cached
|
cachedImage = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Retry — image may be downloading in MessageImageView.
|
|
||||||
|
// Slow path: one background disk/decrypt attempt.
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .utility) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
if let loaded, !Task.isCancelled {
|
||||||
|
cachedImage = loaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry memory cache only — image may still be downloading in MessageImageView.
|
||||||
for _ in 0..<5 {
|
for _ in 0..<5 {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { return }
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
cachedImage = cached
|
cachedImage = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,21 +169,33 @@ struct FullScreenImageViewer: View {
|
|||||||
struct FullScreenImageFromCache: View {
|
struct FullScreenImageFromCache: View {
|
||||||
let attachmentId: String
|
let attachmentId: String
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
@State private var image: UIImage?
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) {
|
if let image {
|
||||||
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
||||||
} else {
|
} else {
|
||||||
// Cache miss — show error with close button
|
// Cache miss/loading state — show placeholder with close button.
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
VStack(spacing: 16) {
|
if isLoading {
|
||||||
Image(systemName: "photo")
|
VStack(spacing: 16) {
|
||||||
.font(.system(size: 48))
|
ProgressView()
|
||||||
.foregroundStyle(.white.opacity(0.3))
|
.tint(.white)
|
||||||
Text("Image not available")
|
Text("Loading...")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.white.opacity(0.3))
|
||||||
|
Text("Image not available")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -204,6 +216,21 @@ struct FullScreenImageFromCache: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: attachmentId) {
|
||||||
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||||||
|
image = cached
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
image = loaded
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,7 +325,13 @@ struct ImageGalleryViewer: View {
|
|||||||
for offset in [-2, -1, 1, 2] {
|
for offset in [-2, -1, 1, 2] {
|
||||||
let i = index + offset
|
let i = index + offset
|
||||||
guard i >= 0, i < state.images.count else { continue }
|
guard i >= 0, i < state.images.count else { continue }
|
||||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
|
let attachmentId = state.images[i].attachmentId
|
||||||
|
guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue }
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
_ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum MediaBubbleCornerMaskFactory {
|
||||||
|
private static let mainRadius: CGFloat = 16
|
||||||
|
private static let mergedRadius: CGFloat = 8
|
||||||
|
private static let inset: CGFloat = 2
|
||||||
|
|
||||||
|
static func containerMask(
|
||||||
|
bounds: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool
|
||||||
|
) -> CAShapeLayer {
|
||||||
|
let radii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing)
|
||||||
|
return makeMaskLayer(
|
||||||
|
in: bounds,
|
||||||
|
topLeft: radii.topLeft,
|
||||||
|
topRight: radii.topRight,
|
||||||
|
bottomLeft: radii.bottomLeft,
|
||||||
|
bottomRight: radii.bottomRight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tileMask(
|
||||||
|
tileFrame: CGRect,
|
||||||
|
containerBounds: CGRect,
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool
|
||||||
|
) -> CAShapeLayer? {
|
||||||
|
let eps: CGFloat = 0.5
|
||||||
|
let touchesLeft = abs(tileFrame.minX - containerBounds.minX) <= eps
|
||||||
|
let touchesRight = abs(tileFrame.maxX - containerBounds.maxX) <= eps
|
||||||
|
let touchesTop = abs(tileFrame.minY - containerBounds.minY) <= eps
|
||||||
|
let touchesBottom = abs(tileFrame.maxY - containerBounds.maxY) <= eps
|
||||||
|
guard touchesLeft || touchesRight || touchesTop || touchesBottom else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let containerRadii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing)
|
||||||
|
let tl = (touchesTop && touchesLeft) ? containerRadii.topLeft : 0
|
||||||
|
let tr = (touchesTop && touchesRight) ? containerRadii.topRight : 0
|
||||||
|
let bl = (touchesBottom && touchesLeft) ? containerRadii.bottomLeft : 0
|
||||||
|
let br = (touchesBottom && touchesRight) ? containerRadii.bottomRight : 0
|
||||||
|
|
||||||
|
let local = CGRect(origin: .zero, size: tileFrame.size)
|
||||||
|
return makeMaskLayer(
|
||||||
|
in: local,
|
||||||
|
topLeft: tl,
|
||||||
|
topRight: tr,
|
||||||
|
bottomLeft: bl,
|
||||||
|
bottomRight: br
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mediaCornerRadii(
|
||||||
|
mergeType: BubbleMergeType,
|
||||||
|
outgoing: Bool
|
||||||
|
) -> (topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
|
||||||
|
let metrics = BubbleMetrics(
|
||||||
|
mainRadius: mainRadius,
|
||||||
|
auxiliaryRadius: mergedRadius,
|
||||||
|
tailProtrusion: 6,
|
||||||
|
defaultSpacing: 0,
|
||||||
|
mergedSpacing: 0,
|
||||||
|
textInsets: .zero,
|
||||||
|
mediaStatusInsets: .zero
|
||||||
|
)
|
||||||
|
let base = BubbleGeometryEngine.cornerRadii(
|
||||||
|
mergeType: mergeType,
|
||||||
|
outgoing: outgoing,
|
||||||
|
metrics: metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
var adjusted = base
|
||||||
|
if BubbleGeometryEngine.hasTail(for: mergeType) {
|
||||||
|
if outgoing {
|
||||||
|
adjusted.bottomRight = min(adjusted.bottomRight, mergedRadius)
|
||||||
|
} else {
|
||||||
|
adjusted.bottomLeft = min(adjusted.bottomLeft, mergedRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
topLeft: max(adjusted.topLeft - inset, 0),
|
||||||
|
topRight: max(adjusted.topRight - inset, 0),
|
||||||
|
bottomLeft: max(adjusted.bottomLeft - inset, 0),
|
||||||
|
bottomRight: max(adjusted.bottomRight - inset, 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeMaskLayer(
|
||||||
|
in rect: CGRect,
|
||||||
|
topLeft: CGFloat,
|
||||||
|
topRight: CGFloat,
|
||||||
|
bottomLeft: CGFloat,
|
||||||
|
bottomRight: CGFloat
|
||||||
|
) -> CAShapeLayer {
|
||||||
|
let path = roundedPath(
|
||||||
|
in: rect,
|
||||||
|
topLeft: topLeft,
|
||||||
|
topRight: topRight,
|
||||||
|
bottomLeft: bottomLeft,
|
||||||
|
bottomRight: bottomRight
|
||||||
|
)
|
||||||
|
let mask = CAShapeLayer()
|
||||||
|
mask.frame = rect
|
||||||
|
mask.path = path.cgPath
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func roundedPath(
|
||||||
|
in rect: CGRect,
|
||||||
|
topLeft: CGFloat,
|
||||||
|
topRight: CGFloat,
|
||||||
|
bottomLeft: CGFloat,
|
||||||
|
bottomRight: CGFloat
|
||||||
|
) -> UIBezierPath {
|
||||||
|
let maxRadius = min(rect.width, rect.height) / 2
|
||||||
|
let cTL = min(topLeft, maxRadius)
|
||||||
|
let cTR = min(topRight, maxRadius)
|
||||||
|
let cBL = min(bottomLeft, maxRadius)
|
||||||
|
let cBR = min(bottomRight, maxRadius)
|
||||||
|
|
||||||
|
let path = UIBezierPath()
|
||||||
|
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
|
||||||
|
radius: cTR,
|
||||||
|
startAngle: -.pi / 2,
|
||||||
|
endAngle: 0,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
|
||||||
|
radius: cBR,
|
||||||
|
startAngle: 0,
|
||||||
|
endAngle: .pi / 2,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
|
||||||
|
radius: cBL,
|
||||||
|
startAngle: .pi / 2,
|
||||||
|
endAngle: .pi,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
|
||||||
|
path.addArc(
|
||||||
|
withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
|
||||||
|
radius: cTL,
|
||||||
|
startAngle: .pi,
|
||||||
|
endAngle: -.pi / 2,
|
||||||
|
clockwise: true
|
||||||
|
)
|
||||||
|
path.close()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,15 +193,14 @@ struct MessageAvatarView: View {
|
|||||||
/// Extracts the blurhash from preview string.
|
/// Extracts the blurhash from preview string.
|
||||||
/// Format: "tag::blurhash" → returns "blurhash".
|
/// Format: "tag::blurhash" → returns "blurhash".
|
||||||
private func extractBlurHash(from preview: String) -> String {
|
private func extractBlurHash(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.blurHash(from: preview)
|
||||||
return parts.count > 1 ? parts[1] : ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download
|
// MARK: - Download
|
||||||
|
|
||||||
private func loadFromCache() async {
|
private func loadFromCache() async {
|
||||||
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
avatarImage = cached
|
avatarImage = cached
|
||||||
showAvatar = true // No animation for cached — show immediately
|
showAvatar = true // No animation for cached — show immediately
|
||||||
return
|
return
|
||||||
@@ -322,7 +321,6 @@ struct MessageAvatarView: View {
|
|||||||
/// Extracts the server tag from preview string.
|
/// Extracts the server tag from preview string.
|
||||||
/// Format: "tag::blurhash" → returns "tag".
|
/// Format: "tag::blurhash" → returns "tag".
|
||||||
private func extractTag(from preview: String) -> String {
|
private func extractTag(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||||
return parts.first ?? preview
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ struct MessageCellView: View, Equatable {
|
|||||||
if hasCaption { return reply.message }
|
if hasCaption { return reply.message }
|
||||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||||
if let file = fileAttachments.first {
|
if let file = fileAttachments.first {
|
||||||
let parts = file.preview.components(separatedBy: "::")
|
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||||
if parts.count > 2 { return parts[2] }
|
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||||
return file.id.isEmpty ? "File" : file.id
|
return file.id.isEmpty ? "File" : file.id
|
||||||
}
|
}
|
||||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||||
@@ -581,8 +581,7 @@ struct MessageCellView: View, Equatable {
|
|||||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||||
let blurHash: String? = {
|
let blurHash: String? = {
|
||||||
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
|
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
|
||||||
let parts = att.preview.components(separatedBy: "::")
|
let hash = AttachmentPreviewCodec.blurHash(from: att.preview)
|
||||||
let hash = parts.count > 1 ? parts[1] : att.preview
|
|
||||||
return hash.isEmpty ? nil : hash
|
return hash.isEmpty ? nil : hash
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -628,8 +627,8 @@ struct MessageCellView: View, Equatable {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||||
let filename: String = {
|
let filename: String = {
|
||||||
let parts = attachment.preview.components(separatedBy: "::")
|
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
|
||||||
if parts.count > 2 { return parts[2] }
|
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||||
return attachment.id.isEmpty ? "File" : attachment.id
|
return attachment.id.isEmpty ? "File" : attachment.id
|
||||||
}()
|
}()
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
|||||||
@@ -93,11 +93,8 @@ struct MessageFileView: View {
|
|||||||
|
|
||||||
/// Parses "tag::filesize::filename" preview format.
|
/// Parses "tag::filesize::filename" preview format.
|
||||||
private var fileMetadata: (tag: String, size: Int, name: String) {
|
private var fileMetadata: (tag: String, size: Int, name: String) {
|
||||||
let parts = attachment.preview.components(separatedBy: "::")
|
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
|
||||||
let tag = parts.first ?? ""
|
return (parsed.downloadTag, parsed.fileSize, parsed.fileName)
|
||||||
let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
|
|
||||||
let name = parts.count > 2 ? parts[2] : "file"
|
|
||||||
return (tag, size, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fileName: String { fileMetadata.name }
|
private var fileName: String { fileMetadata.name }
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ struct MessageImageView: View {
|
|||||||
|
|
||||||
private func loadFromCache() async {
|
private func loadFromCache() async {
|
||||||
PerformanceLogger.shared.track("image.cacheLoad")
|
PerformanceLogger.shared.track("image.cacheLoad")
|
||||||
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
image = cached
|
image = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -331,12 +331,10 @@ struct MessageImageView: View {
|
|||||||
// MARK: - Preview Parsing
|
// MARK: - Preview Parsing
|
||||||
|
|
||||||
private func extractTag(from preview: String) -> String {
|
private func extractTag(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||||
return parts.first ?? preview
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractBlurHash(from preview: String) -> String {
|
private func extractBlurHash(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.blurHash(from: preview)
|
||||||
return parts.count > 1 ? parts[1] : ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,110 +23,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||||
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
private static let statusBubbleInsets = UIEdgeInsets(top: 3, left: 7, bottom: 3, right: 7)
|
private static let bubbleMetrics = BubbleMetrics.telegram()
|
||||||
|
private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets
|
||||||
private static let sendingClockAnimationKey = "clockFrameAnimation"
|
private static let sendingClockAnimationKey = "clockFrameAnimation"
|
||||||
|
|
||||||
// MARK: - Telegram Check Images (CGContext — ported from PresentationThemeEssentialGraphics.swift)
|
|
||||||
|
|
||||||
/// Telegram-exact checkmark image via CGContext stroke.
|
|
||||||
/// `partial: true` → single arm (/), `partial: false` → full V (✓).
|
|
||||||
/// Canvas: 11-unit coordinate space scaled to `width` pt.
|
|
||||||
private static func generateTelegramCheck(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? {
|
|
||||||
let height = floor(width * 9.0 / 11.0)
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
|
|
||||||
return renderer.image { ctx in
|
|
||||||
let gc = ctx.cgContext
|
|
||||||
// Keep UIKit default Y-down coordinates; Telegram check path points
|
|
||||||
// are already authored for this orientation in our renderer.
|
|
||||||
gc.clear(CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
|
||||||
gc.translateBy(x: 1.0, y: 1.0)
|
|
||||||
gc.setStrokeColor(color.cgColor)
|
|
||||||
gc.setLineWidth(0.99)
|
|
||||||
gc.setLineCap(.round)
|
|
||||||
gc.setLineJoin(.round)
|
|
||||||
if partial {
|
|
||||||
// Single arm: bottom-left → top-right diagonal
|
|
||||||
gc.move(to: CGPoint(x: 0.5, y: 7))
|
|
||||||
gc.addLine(to: CGPoint(x: 7, y: 0))
|
|
||||||
} else {
|
|
||||||
// Full V: left → bottom-center (rounded tip) → top-right
|
|
||||||
gc.move(to: CGPoint(x: 0, y: 4))
|
|
||||||
gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047))
|
|
||||||
gc.addCurve(to: CGPoint(x: 3.04490857, y: 6.95157047),
|
|
||||||
control1: CGPoint(x: 2.97734507, y: 6.97734507),
|
|
||||||
control2: CGPoint(x: 3.01913396, y: 6.97734507))
|
|
||||||
gc.addCurve(to: CGPoint(x: 3.04660389, y: 6.9498112),
|
|
||||||
control1: CGPoint(x: 3.04548448, y: 6.95099456),
|
|
||||||
control2: CGPoint(x: 3.04604969, y: 6.95040803))
|
|
||||||
gc.addLine(to: CGPoint(x: 9.5, y: 0))
|
|
||||||
}
|
|
||||||
gc.strokePath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Telegram-exact clock frame image.
|
|
||||||
private static func generateTelegramClockFrame(color: UIColor) -> UIImage? {
|
|
||||||
let size = CGSize(width: 11, height: 11)
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
|
||||||
return renderer.image { ctx in
|
|
||||||
let gc = ctx.cgContext
|
|
||||||
// Telegram uses `generateImage(contextGenerator:)` (non-rotated context).
|
|
||||||
// Flip UIKit context to the same Y-up coordinate space.
|
|
||||||
gc.translateBy(x: 0, y: size.height)
|
|
||||||
gc.scaleBy(x: 1, y: -1)
|
|
||||||
gc.clear(CGRect(origin: .zero, size: size))
|
|
||||||
gc.setStrokeColor(color.cgColor)
|
|
||||||
gc.setFillColor(color.cgColor)
|
|
||||||
gc.setLineWidth(1.0)
|
|
||||||
gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10))
|
|
||||||
gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Telegram-exact clock minute/hour image.
|
|
||||||
private static func generateTelegramClockMin(color: UIColor) -> UIImage? {
|
|
||||||
let size = CGSize(width: 11, height: 11)
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
|
||||||
return renderer.image { ctx in
|
|
||||||
let gc = ctx.cgContext
|
|
||||||
// Match Telegram's non-rotated drawing context coordinates.
|
|
||||||
gc.translateBy(x: 0, y: size.height)
|
|
||||||
gc.scaleBy(x: 1, y: -1)
|
|
||||||
gc.clear(CGRect(origin: .zero, size: size))
|
|
||||||
gc.setFillColor(color.cgColor)
|
|
||||||
gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error indicator (circle with exclamation mark).
|
|
||||||
private static func generateErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? {
|
|
||||||
let size = CGSize(width: width, height: width)
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: size)
|
|
||||||
return renderer.image { ctx in
|
|
||||||
let gc = ctx.cgContext
|
|
||||||
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
|
||||||
gc.setFillColor(color.cgColor)
|
|
||||||
gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0))
|
|
||||||
gc.setFillColor(UIColor.white.cgColor)
|
|
||||||
gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25))
|
|
||||||
gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics)
|
// Pre-rendered images (cached at class load — Telegram caches in PrincipalThemeEssentialGraphics)
|
||||||
private static let outgoingCheckColor = UIColor.white
|
private static let outgoingCheckColor = UIColor.white
|
||||||
private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
|
private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
|
||||||
private static let mediaMetaColor = UIColor.white
|
private static let mediaMetaColor = UIColor.white
|
||||||
private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor)
|
private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
|
||||||
private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor)
|
private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
|
||||||
private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor)
|
private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor)
|
||||||
private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor)
|
private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor)
|
||||||
private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor)
|
private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor)
|
||||||
private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor)
|
private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor)
|
||||||
private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
|
private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor)
|
||||||
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
|
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
|
||||||
private static let errorIcon = generateErrorIcon(color: .systemRed)
|
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
|
||||||
private static let maxVisiblePhotoTiles = 5
|
private static let maxVisiblePhotoTiles = 5
|
||||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||||
let cache = NSCache<NSString, UIImage>()
|
let cache = NSCache<NSString, UIImage>()
|
||||||
@@ -197,6 +110,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
private var totalPhotoAttachmentCount = 0
|
private var totalPhotoAttachmentCount = 0
|
||||||
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
|
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
|
||||||
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
|
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
|
||||||
|
private var photoBlurHashTasks: [String: Task<Void, Never>] = [:]
|
||||||
private var downloadingAttachmentIds: Set<String> = []
|
private var downloadingAttachmentIds: Set<String> = []
|
||||||
private var failedAttachmentIds: Set<String> = []
|
private var failedAttachmentIds: Set<String> = []
|
||||||
|
|
||||||
@@ -394,7 +308,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
self.actions = actions
|
self.actions = actions
|
||||||
|
|
||||||
let isOutgoing = currentLayout?.isOutgoing ?? false
|
let isOutgoing = currentLayout?.isOutgoing ?? false
|
||||||
let isMediaStatus = currentLayout?.messageType == .photo
|
let isMediaStatus: Bool = {
|
||||||
|
guard let type = currentLayout?.messageType else { return false }
|
||||||
|
return type == .photo || type == .photoWithCaption
|
||||||
|
}()
|
||||||
|
|
||||||
// Text — use cached CoreTextTextLayout from measurement phase.
|
// Text — use cached CoreTextTextLayout from measurement phase.
|
||||||
// Same CTTypesetter pipeline → identical line breaks, zero recomputation.
|
// Same CTTypesetter pipeline → identical line breaks, zero recomputation.
|
||||||
@@ -482,7 +399,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
if let layout = currentLayout, layout.hasFile {
|
if let layout = currentLayout, layout.hasFile {
|
||||||
fileContainer.isHidden = false
|
fileContainer.isHidden = false
|
||||||
let fileAtt = message.attachments.first { $0.type == .file }
|
let fileAtt = message.attachments.first { $0.type == .file }
|
||||||
fileNameLabel.text = fileAtt?.preview.components(separatedBy: "::").last ?? "File"
|
if let fileAtt {
|
||||||
|
fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName
|
||||||
|
} else {
|
||||||
|
fileNameLabel.text = "File"
|
||||||
|
}
|
||||||
fileSizeLabel.text = ""
|
fileSizeLabel.text = ""
|
||||||
} else {
|
} else {
|
||||||
fileContainer.isHidden = true
|
fileContainer.isHidden = true
|
||||||
@@ -502,14 +423,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
guard let layout = currentLayout else { return }
|
guard let layout = currentLayout else { return }
|
||||||
|
|
||||||
let cellW = contentView.bounds.width
|
let cellW = contentView.bounds.width
|
||||||
let tailW: CGFloat = layout.hasTail ? 6 : 0
|
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
|
||||||
|
let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0
|
||||||
|
|
||||||
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
||||||
let bubbleX: CGFloat
|
let bubbleX: CGFloat
|
||||||
if layout.isOutgoing {
|
if layout.isOutgoing {
|
||||||
bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset
|
bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
|
||||||
} else {
|
} else {
|
||||||
bubbleX = 6 + 2
|
bubbleX = tailProtrusion + 2
|
||||||
}
|
}
|
||||||
|
|
||||||
bubbleView.frame = CGRect(
|
bubbleView.frame = CGRect(
|
||||||
@@ -522,17 +444,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
if layout.hasTail {
|
if layout.hasTail {
|
||||||
if layout.isOutgoing {
|
if layout.isOutgoing {
|
||||||
shapeRect = CGRect(x: 0, y: 0,
|
shapeRect = CGRect(x: 0, y: 0,
|
||||||
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
|
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
|
||||||
} else {
|
} else {
|
||||||
shapeRect = CGRect(x: -6, y: 0,
|
shapeRect = CGRect(x: -tailProtrusion, y: 0,
|
||||||
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
|
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
|
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
|
||||||
}
|
}
|
||||||
bubbleLayer.path = BubblePathCache.shared.path(
|
bubbleLayer.path = BubblePathCache.shared.path(
|
||||||
size: shapeRect.size, origin: shapeRect.origin,
|
size: shapeRect.size, origin: shapeRect.origin,
|
||||||
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail
|
mergeType: layout.mergeType,
|
||||||
|
isOutgoing: layout.isOutgoing,
|
||||||
|
metrics: Self.bubbleMetrics
|
||||||
)
|
)
|
||||||
bubbleLayer.shadowPath = bubbleLayer.path
|
bubbleLayer.shadowPath = bubbleLayer.path
|
||||||
bubbleOutlineLayer.frame = bubbleView.bounds
|
bubbleOutlineLayer.frame = bubbleView.bounds
|
||||||
@@ -569,6 +493,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
checkReadView.frame = layout.checkReadFrame
|
checkReadView.frame = layout.checkReadFrame
|
||||||
clockFrameView.frame = layout.clockFrame
|
clockFrameView.frame = layout.clockFrame
|
||||||
clockMinView.frame = layout.clockFrame
|
clockMinView.frame = layout.clockFrame
|
||||||
|
#if DEBUG
|
||||||
|
assertStatusLaneFramesValid(layout: layout)
|
||||||
|
#endif
|
||||||
|
|
||||||
// Telegram-style date/status pill on media-only bubbles.
|
// Telegram-style date/status pill on media-only bubbles.
|
||||||
updateStatusBackgroundFrame()
|
updateStatusBackgroundFrame()
|
||||||
@@ -734,12 +661,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
let attachment = photoAttachments[sender.tag]
|
let attachment = photoAttachments[sender.tag]
|
||||||
if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil {
|
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
|
||||||
actions.onImageTap(attachment.id)
|
actions.onImageTap(attachment.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadPhotoAttachment(attachment: attachment, message: message)
|
Task { [weak self] in
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self,
|
||||||
|
self.message?.id == message.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if loaded != nil {
|
||||||
|
actions.onImageTap(attachment.id)
|
||||||
|
} else {
|
||||||
|
self.downloadPhotoAttachment(attachment: attachment, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configurePhoto(for message: ChatMessage) {
|
private func configurePhoto(for message: ChatMessage) {
|
||||||
@@ -767,6 +712,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
downloadingAttachmentIds.remove(attachmentId)
|
downloadingAttachmentIds.remove(attachmentId)
|
||||||
failedAttachmentIds.remove(attachmentId)
|
failedAttachmentIds.remove(attachmentId)
|
||||||
}
|
}
|
||||||
|
for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) {
|
||||||
|
task.cancel()
|
||||||
|
photoBlurHashTasks.removeValue(forKey: attachmentId)
|
||||||
|
}
|
||||||
|
|
||||||
for index in 0..<photoTileImageViews.count {
|
for index in 0..<photoTileImageViews.count {
|
||||||
let isActiveTile = index < photoAttachments.count
|
let isActiveTile = index < photoAttachments.count
|
||||||
@@ -789,7 +738,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
let attachment = photoAttachments[index]
|
let attachment = photoAttachments[index]
|
||||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||||
failedAttachmentIds.remove(attachment.id)
|
failedAttachmentIds.remove(attachment.id)
|
||||||
setPhotoTileImage(cached, at: index, animated: false)
|
setPhotoTileImage(cached, at: index, animated: false)
|
||||||
placeholderView.isHidden = true
|
placeholderView.isHidden = true
|
||||||
@@ -797,7 +746,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
indicator.isHidden = true
|
indicator.isHidden = true
|
||||||
errorView.isHidden = true
|
errorView.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
|
if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
|
||||||
|
setPhotoTileImage(blur, at: index, animated: false)
|
||||||
|
} else {
|
||||||
|
setPhotoTileImage(nil, at: index, animated: false)
|
||||||
|
startPhotoBlurHashTask(attachment: attachment)
|
||||||
|
}
|
||||||
placeholderView.isHidden = imageView.image != nil
|
placeholderView.isHidden = imageView.image != nil
|
||||||
let hasFailed = failedAttachmentIds.contains(attachment.id)
|
let hasFailed = failedAttachmentIds.contains(attachment.id)
|
||||||
if hasFailed {
|
if hasFailed {
|
||||||
@@ -823,8 +777,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func layoutPhotoTiles() {
|
private func layoutPhotoTiles() {
|
||||||
guard !photoAttachments.isEmpty else { return }
|
guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
|
||||||
updatePhotoContainerMask()
|
updatePhotoContainerMask(layout: layout)
|
||||||
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
|
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
|
||||||
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
|
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
|
||||||
photoTileImageViews[index].frame = frame
|
photoTileImageViews[index].frame = frame
|
||||||
@@ -845,58 +799,61 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
photoContainer.bringSubviewToFront(photoUploadingIndicator)
|
photoContainer.bringSubviewToFront(photoUploadingIndicator)
|
||||||
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
|
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
|
||||||
layoutPhotoOverflowOverlay(frames: frames)
|
layoutPhotoOverflowOverlay(frames: frames)
|
||||||
|
applyPhotoLastTileMask(frames: frames, layout: layout)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePhotoContainerMask() {
|
private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
|
||||||
guard let layout = currentLayout else {
|
guard let layout = layout ?? currentLayout else {
|
||||||
photoContainer.layer.mask = nil
|
photoContainer.layer.mask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
|
||||||
|
bounds: photoContainer.bounds,
|
||||||
|
mergeType: layout.mergeType,
|
||||||
|
outgoing: layout.isOutgoing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let inset: CGFloat = 2
|
private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
|
||||||
let r: CGFloat = max(16 - inset, 0)
|
guard !frames.isEmpty else { return }
|
||||||
let s: CGFloat = max(5 - inset, 0)
|
|
||||||
let tailJoin: CGFloat = max(10 - inset, 0)
|
|
||||||
let rect = photoContainer.bounds
|
|
||||||
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
|
||||||
switch layout.position {
|
|
||||||
case .single:
|
|
||||||
return layout.isOutgoing
|
|
||||||
? (r, r, r, tailJoin)
|
|
||||||
: (r, r, tailJoin, r)
|
|
||||||
case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r)
|
|
||||||
case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
|
|
||||||
case .bottom:
|
|
||||||
return layout.isOutgoing
|
|
||||||
? (r, s, r, tailJoin)
|
|
||||||
: (s, r, tailJoin, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
let maxR = min(rect.width, rect.height) / 2
|
// Reset per-tile masks first.
|
||||||
let cTL = min(tl, maxR), cTR = min(tr, maxR)
|
for index in 0..<photoTileImageViews.count {
|
||||||
let cBL = min(bl, maxR), cBR = min(br, maxR)
|
photoTileImageViews[index].layer.mask = nil
|
||||||
|
photoTilePlaceholderViews[index].layer.mask = nil
|
||||||
|
photoTileButtons[index].layer.mask = nil
|
||||||
|
}
|
||||||
|
photoOverflowOverlayView.layer.mask = nil
|
||||||
|
|
||||||
let path = UIBezierPath()
|
let lastVisibleIndex = photoAttachments.count - 1
|
||||||
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
|
guard lastVisibleIndex >= 0, lastVisibleIndex < frames.count else { return }
|
||||||
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
|
let tileFrame = frames[lastVisibleIndex]
|
||||||
path.addArc(withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
|
guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask(
|
||||||
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
|
tileFrame: tileFrame,
|
||||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
|
containerBounds: photoContainer.bounds,
|
||||||
path.addArc(withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
|
mergeType: layout.mergeType,
|
||||||
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
|
outgoing: layout.isOutgoing
|
||||||
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
|
) else {
|
||||||
path.addArc(withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
|
return
|
||||||
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
|
}
|
||||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
|
|
||||||
path.addArc(withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
|
|
||||||
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
|
|
||||||
path.close()
|
|
||||||
|
|
||||||
|
applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex])
|
||||||
|
applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex])
|
||||||
|
applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex])
|
||||||
|
|
||||||
|
// Keep overflow badge clipping aligned with the same rounded corner.
|
||||||
|
applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) {
|
||||||
|
guard let path = prototype.path else {
|
||||||
|
view.layer.mask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
let mask = CAShapeLayer()
|
let mask = CAShapeLayer()
|
||||||
mask.frame = rect
|
mask.frame = CGRect(origin: .zero, size: view.bounds.size)
|
||||||
mask.path = path.cgPath
|
mask.path = path
|
||||||
photoContainer.layer.mask = mask
|
view.layer.mask = mask
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
|
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
|
||||||
@@ -1032,6 +989,41 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
photoAttachments.firstIndex(where: { $0.id == attachmentId })
|
photoAttachments.firstIndex(where: { $0.id == attachmentId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startPhotoBlurHashTask(attachment: MessageAttachment) {
|
||||||
|
let attachmentId = attachment.id
|
||||||
|
guard photoBlurHashTasks[attachmentId] == nil else { return }
|
||||||
|
let hash = Self.extractBlurHash(from: attachment.preview)
|
||||||
|
guard !hash.isEmpty else { return }
|
||||||
|
if let cached = Self.blurHashCache.object(forKey: hash as NSString) {
|
||||||
|
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count {
|
||||||
|
setPhotoTileImage(cached, at: tileIndex, animated: false)
|
||||||
|
photoTilePlaceholderViews[tileIndex].isHidden = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photoBlurHashTasks[attachmentId] = Task { [weak self] in
|
||||||
|
let decoded = await Task.detached(priority: .utility) {
|
||||||
|
UIImage.fromBlurHash(hash, width: 48, height: 48)
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.photoBlurHashTasks.removeValue(forKey: attachmentId)
|
||||||
|
guard let decoded,
|
||||||
|
let tileIndex = self.tileIndex(for: attachmentId),
|
||||||
|
tileIndex < self.photoTileImageViews.count else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
|
||||||
|
// Do not override already loaded real image.
|
||||||
|
guard self.photoTileImageViews[tileIndex].image == nil else { return }
|
||||||
|
self.setPhotoTileImage(decoded, at: tileIndex, animated: false)
|
||||||
|
self.photoTilePlaceholderViews[tileIndex].isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func startPhotoLoadTask(attachment: MessageAttachment) {
|
private func startPhotoLoadTask(attachment: MessageAttachment) {
|
||||||
if photoLoadTasks[attachment.id] != nil { return }
|
if photoLoadTasks[attachment.id] != nil { return }
|
||||||
let attachmentId = attachment.id
|
let attachmentId = attachment.id
|
||||||
@@ -1140,6 +1132,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
}
|
}
|
||||||
photoDownloadTasks.removeAll()
|
photoDownloadTasks.removeAll()
|
||||||
|
for task in photoBlurHashTasks.values {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
photoBlurHashTasks.removeAll()
|
||||||
downloadingAttachmentIds.removeAll()
|
downloadingAttachmentIds.removeAll()
|
||||||
failedAttachmentIds.removeAll()
|
failedAttachmentIds.removeAll()
|
||||||
photoContainer.layer.mask = nil
|
photoContainer.layer.mask = nil
|
||||||
@@ -1151,35 +1147,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
for index in 0..<photoTileImageViews.count {
|
for index in 0..<photoTileImageViews.count {
|
||||||
photoTileImageViews[index].image = nil
|
photoTileImageViews[index].image = nil
|
||||||
photoTileImageViews[index].isHidden = true
|
photoTileImageViews[index].isHidden = true
|
||||||
|
photoTileImageViews[index].layer.mask = nil
|
||||||
photoTilePlaceholderViews[index].isHidden = true
|
photoTilePlaceholderViews[index].isHidden = true
|
||||||
|
photoTilePlaceholderViews[index].layer.mask = nil
|
||||||
photoTileActivityIndicators[index].stopAnimating()
|
photoTileActivityIndicators[index].stopAnimating()
|
||||||
photoTileActivityIndicators[index].isHidden = true
|
photoTileActivityIndicators[index].isHidden = true
|
||||||
photoTileErrorViews[index].isHidden = true
|
photoTileErrorViews[index].isHidden = true
|
||||||
photoTileButtons[index].isHidden = true
|
photoTileButtons[index].isHidden = true
|
||||||
|
photoTileButtons[index].layer.mask = nil
|
||||||
}
|
}
|
||||||
|
photoOverflowOverlayView.layer.mask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractTag(from preview: String) -> String {
|
private static func extractTag(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||||
return parts.first ?? preview
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractBlurHash(from preview: String) -> String {
|
private static func extractBlurHash(from preview: String) -> String {
|
||||||
let parts = preview.components(separatedBy: "::")
|
AttachmentPreviewCodec.blurHash(from: preview)
|
||||||
return parts.count > 1 ? parts[1] : ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func blurHashImage(from preview: String) -> UIImage? {
|
private static func cachedBlurHashImage(from preview: String) -> UIImage? {
|
||||||
let hash = extractBlurHash(from: preview)
|
let hash = extractBlurHash(from: preview)
|
||||||
guard !hash.isEmpty else { return nil }
|
guard !hash.isEmpty else { return nil }
|
||||||
if let cached = blurHashCache.object(forKey: hash as NSString) {
|
return blurHashCache.object(forKey: hash as NSString)
|
||||||
return cached
|
|
||||||
}
|
|
||||||
guard let image = UIImage.fromBlurHash(hash, width: 48, height: 48) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
blurHashCache.setObject(image, forKey: hash as NSString)
|
|
||||||
return image
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
|
||||||
@@ -1243,14 +1234,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
|
|
||||||
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
|
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
|
||||||
if isSentVisible && !wasSentCheckVisible {
|
if isSentVisible && !wasSentCheckVisible {
|
||||||
checkSentView.alpha = 0
|
checkSentView.alpha = 1
|
||||||
checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
|
checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
|
||||||
UIView.animate(
|
UIView.animate(
|
||||||
withDuration: 0.16,
|
withDuration: 0.1,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
options: [.curveEaseOut, .beginFromCurrentState]
|
options: [.curveEaseOut, .beginFromCurrentState]
|
||||||
) {
|
) {
|
||||||
self.checkSentView.alpha = 1
|
|
||||||
self.checkSentView.transform = .identity
|
self.checkSentView.transform = .identity
|
||||||
}
|
}
|
||||||
} else if !isSentVisible {
|
} else if !isSentVisible {
|
||||||
@@ -1259,14 +1249,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isReadVisible && !wasReadCheckVisible {
|
if isReadVisible && !wasReadCheckVisible {
|
||||||
checkReadView.alpha = 0
|
checkReadView.alpha = 1
|
||||||
checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
|
checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
|
||||||
UIView.animate(
|
UIView.animate(
|
||||||
withDuration: 0.16,
|
withDuration: 0.1,
|
||||||
delay: 0.02,
|
delay: 0,
|
||||||
options: [.curveEaseOut, .beginFromCurrentState]
|
options: [.curveEaseOut, .beginFromCurrentState]
|
||||||
) {
|
) {
|
||||||
self.checkReadView.alpha = 1
|
|
||||||
self.checkReadView.transform = .identity
|
self.checkReadView.transform = .identity
|
||||||
}
|
}
|
||||||
} else if !isReadVisible {
|
} else if !isReadVisible {
|
||||||
@@ -1312,6 +1301,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
bubbleView.bringSubviewToFront(clockMinView)
|
bubbleView.bringSubviewToFront(clockMinView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func assertStatusLaneFramesValid(layout: MessageCellLayout) {
|
||||||
|
let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize)
|
||||||
|
let frames = [
|
||||||
|
("timestamp", layout.timestampFrame),
|
||||||
|
("checkSent", layout.checkSentFrame),
|
||||||
|
("checkRead", layout.checkReadFrame),
|
||||||
|
("clock", layout.clockFrame)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (name, frame) in frames {
|
||||||
|
assert(frame.origin.x.isFinite && frame.origin.y.isFinite
|
||||||
|
&& frame.size.width.isFinite && frame.size.height.isFinite,
|
||||||
|
"Status frame \(name) has non-finite values: \(frame)")
|
||||||
|
assert(frame.width >= 0 && frame.height >= 0,
|
||||||
|
"Status frame \(name) has negative size: \(frame)")
|
||||||
|
guard !frame.isEmpty else { continue }
|
||||||
|
let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0)
|
||||||
|
assert(insetBounds.contains(frame),
|
||||||
|
"Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Reuse
|
// MARK: - Reuse
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
@@ -1369,18 +1382,35 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
|
|||||||
final class BubblePathCache {
|
final class BubblePathCache {
|
||||||
static let shared = BubblePathCache()
|
static let shared = BubblePathCache()
|
||||||
|
|
||||||
private let pathVersion = 8
|
private let pathVersion = 9
|
||||||
private var cache: [String: CGPath] = [:]
|
private var cache: [String: CGPath] = [:]
|
||||||
|
|
||||||
func path(
|
func path(
|
||||||
size: CGSize, origin: CGPoint,
|
size: CGSize, origin: CGPoint,
|
||||||
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
mergeType: BubbleMergeType,
|
||||||
|
isOutgoing: Bool,
|
||||||
|
metrics: BubbleMetrics
|
||||||
) -> CGPath {
|
) -> CGPath {
|
||||||
let key = "v\(pathVersion)_\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
|
let key = [
|
||||||
|
"v\(pathVersion)",
|
||||||
|
"\(Int(size.width))x\(Int(size.height))",
|
||||||
|
"ox\(Int(origin.x))",
|
||||||
|
"oy\(Int(origin.y))",
|
||||||
|
"\(mergeType)",
|
||||||
|
"\(isOutgoing)",
|
||||||
|
"r\(Int(metrics.mainRadius))",
|
||||||
|
"m\(Int(metrics.auxiliaryRadius))",
|
||||||
|
"t\(Int(metrics.tailProtrusion))",
|
||||||
|
].joined(separator: "_")
|
||||||
if let cached = cache[key] { return cached }
|
if let cached = cache[key] { return cached }
|
||||||
|
|
||||||
let rect = CGRect(origin: origin, size: size)
|
let rect = CGRect(origin: origin, size: size)
|
||||||
let path = makeBubblePath(in: rect, position: position, isOutgoing: isOutgoing, hasTail: hasTail)
|
let path = BubbleGeometryEngine.makeCGPath(
|
||||||
|
in: rect,
|
||||||
|
mergeType: mergeType,
|
||||||
|
outgoing: isOutgoing,
|
||||||
|
metrics: metrics
|
||||||
|
)
|
||||||
cache[key] = path
|
cache[key] = path
|
||||||
|
|
||||||
// Evict if cache grows too large
|
// Evict if cache grows too large
|
||||||
@@ -1390,101 +1420,4 @@ final class BubblePathCache {
|
|||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeBubblePath(
|
|
||||||
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
|
||||||
) -> CGPath {
|
|
||||||
let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6
|
|
||||||
|
|
||||||
// Body rect
|
|
||||||
let bodyRect: CGRect
|
|
||||||
if hasTail {
|
|
||||||
bodyRect = isOutgoing
|
|
||||||
? CGRect(x: rect.minX, y: rect.minY, width: rect.width - tailW, height: rect.height)
|
|
||||||
: CGRect(x: rect.minX + tailW, y: rect.minY, width: rect.width - tailW, height: rect.height)
|
|
||||||
} else {
|
|
||||||
bodyRect = rect
|
|
||||||
}
|
|
||||||
|
|
||||||
// Corner radii
|
|
||||||
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
|
||||||
switch position {
|
|
||||||
case .single: return (r, r, r, r)
|
|
||||||
case .top: return isOutgoing ? (r, r, r, s) : (r, r, s, r)
|
|
||||||
case .mid: return isOutgoing ? (r, s, r, s) : (s, r, s, r)
|
|
||||||
case .bottom: return isOutgoing ? (r, s, r, r) : (s, r, r, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
let maxR = min(bodyRect.width, bodyRect.height) / 2
|
|
||||||
let cTL = min(tl, maxR), cTR = min(tr, maxR)
|
|
||||||
let cBL = min(bl, maxR), cBR = min(br, maxR)
|
|
||||||
|
|
||||||
let path = CGMutablePath()
|
|
||||||
|
|
||||||
// Rounded rect body
|
|
||||||
path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY))
|
|
||||||
path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY))
|
|
||||||
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY),
|
|
||||||
tangent2End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY + cTR), radius: cTR)
|
|
||||||
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
|
|
||||||
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY),
|
|
||||||
tangent2End: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY), radius: cBR)
|
|
||||||
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
|
|
||||||
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY),
|
|
||||||
tangent2End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - cBL), radius: cBL)
|
|
||||||
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
|
|
||||||
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.minY),
|
|
||||||
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
|
|
||||||
path.closeSubpath()
|
|
||||||
|
|
||||||
// Stable Figma tail (previous behavior)
|
|
||||||
if hasTail {
|
|
||||||
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Figma SVG tail path (stable shape used before recent experiments).
|
|
||||||
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
|
|
||||||
let svgStraightX: CGFloat = 5.59961
|
|
||||||
let svgMaxY: CGFloat = 33.2305
|
|
||||||
let scale: CGFloat = 6.0 / svgStraightX
|
|
||||||
let tailH = svgMaxY * scale
|
|
||||||
|
|
||||||
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
|
|
||||||
let bottom = bodyRect.maxY
|
|
||||||
let top = bottom - tailH
|
|
||||||
let dir: CGFloat = isOutgoing ? 1 : -1
|
|
||||||
|
|
||||||
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
|
|
||||||
let dx = (svgStraightX - svgX) * scale * dir
|
|
||||||
return CGPoint(x: bodyEdge + dx, y: top + svgY * scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isOutgoing {
|
|
||||||
path.move(to: tp(5.59961, 24.2305))
|
|
||||||
path.addCurve(to: tp(0, 33.0244), control1: tp(5.42042, 28.0524), control2: tp(3.19779, 31.339))
|
|
||||||
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(0.851596, 33.1596), control2: tp(1.72394, 33.2305))
|
|
||||||
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(6.53776, 33.2305), control2: tp(10.1517, 31.8599))
|
|
||||||
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(10.7434, 27.898), control2: tp(8.86922, 25.7134))
|
|
||||||
path.addCurve(to: tp(5.6123, 4.2002), control1: tp(5.61235, 19.3215), control2: tp(5.6123, 14.281))
|
|
||||||
path.addLine(to: tp(5.6123, 0))
|
|
||||||
path.addLine(to: tp(5.59961, 0))
|
|
||||||
path.addLine(to: tp(5.59961, 24.2305))
|
|
||||||
path.closeSubpath()
|
|
||||||
} else {
|
|
||||||
path.move(to: tp(5.59961, 24.2305))
|
|
||||||
path.addLine(to: tp(5.59961, 0))
|
|
||||||
path.addLine(to: tp(5.6123, 0))
|
|
||||||
path.addLine(to: tp(5.6123, 4.2002))
|
|
||||||
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(5.6123, 14.281), control2: tp(5.61235, 19.3215))
|
|
||||||
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(8.86922, 25.7134), control2: tp(10.7434, 27.898))
|
|
||||||
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(10.1517, 31.8599), control2: tp(6.53776, 33.2305))
|
|
||||||
path.addCurve(to: tp(0, 33.0244), control1: tp(1.72394, 33.2305), control2: tp(0.851596, 33.1596))
|
|
||||||
path.addCurve(to: tp(5.59961, 24.2305), control1: tp(3.19779, 31.339), control2: tp(5.42042, 28.0524))
|
|
||||||
path.closeSubpath()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NativeMessageListController: UIViewController {
|
final class NativeMessageListController: UIViewController {
|
||||||
|
|
||||||
|
private enum UIConstants {
|
||||||
|
static let messageToComposerGap: CGFloat = 16
|
||||||
|
static let scrollButtonSize: CGFloat = 40
|
||||||
|
static let scrollButtonIconCanvas: CGFloat = 38
|
||||||
|
static let scrollButtonBaseTrailing: CGFloat = 8
|
||||||
|
static let scrollButtonCompactExtraTrailing: CGFloat = 18
|
||||||
|
static let scrollButtonBottomOffset: CGFloat = 20
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
@@ -73,6 +82,11 @@ final class NativeMessageListController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Scroll-to-Bottom Button
|
// MARK: - Scroll-to-Bottom Button
|
||||||
private var scrollToBottomButton: UIButton?
|
private var scrollToBottomButton: UIButton?
|
||||||
|
private var scrollToBottomButtonContainer: UIView?
|
||||||
|
private var scrollToBottomTrailingConstraint: NSLayoutConstraint?
|
||||||
|
private var scrollToBottomBottomConstraint: NSLayoutConstraint?
|
||||||
|
private var scrollToBottomBadgeView: UIView?
|
||||||
|
private var scrollToBottomBadgeLabel: UILabel?
|
||||||
/// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
|
/// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
|
||||||
private var lastReportedAtBottom: Bool = true
|
private var lastReportedAtBottom: Bool = true
|
||||||
|
|
||||||
@@ -142,6 +156,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
override func viewSafeAreaInsetsDidChange() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
super.viewSafeAreaInsetsDidChange()
|
super.viewSafeAreaInsetsDidChange()
|
||||||
applyInsets()
|
applyInsets()
|
||||||
|
updateScrollToBottomButtonConstraints()
|
||||||
// Update composer bottom when keyboard is hidden
|
// Update composer bottom when keyboard is hidden
|
||||||
if currentKeyboardHeight == 0 {
|
if currentKeyboardHeight == 0 {
|
||||||
composerBottomConstraint?.constant = -view.safeAreaInsets.bottom
|
composerBottomConstraint?.constant = -view.safeAreaInsets.bottom
|
||||||
@@ -327,7 +342,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer)
|
// MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer)
|
||||||
|
|
||||||
private func setupScrollToBottomButton(above composer: UIView) {
|
private func setupScrollToBottomButton(above composer: UIView) {
|
||||||
let size: CGFloat = 42
|
let size = UIConstants.scrollButtonSize
|
||||||
let rect = CGRect(x: 0, y: 0, width: size, height: size)
|
let rect = CGRect(x: 0, y: 0, width: size, height: size)
|
||||||
|
|
||||||
// Container: Auto Layout positions it, clipsToBounds prevents overflow.
|
// Container: Auto Layout positions it, clipsToBounds prevents overflow.
|
||||||
@@ -339,41 +354,66 @@ final class NativeMessageListController: UIViewController {
|
|||||||
container.isUserInteractionEnabled = true
|
container.isUserInteractionEnabled = true
|
||||||
view.addSubview(container)
|
view.addSubview(container)
|
||||||
|
|
||||||
|
let trailing = container.trailingAnchor.constraint(
|
||||||
|
equalTo: view.safeAreaLayoutGuide.trailingAnchor,
|
||||||
|
constant: -UIConstants.scrollButtonBaseTrailing
|
||||||
|
)
|
||||||
|
let bottom = container.bottomAnchor.constraint(
|
||||||
|
equalTo: view.keyboardLayoutGuide.topAnchor,
|
||||||
|
constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
||||||
|
)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
container.widthAnchor.constraint(equalToConstant: size),
|
container.widthAnchor.constraint(equalToConstant: size),
|
||||||
container.heightAnchor.constraint(equalToConstant: size),
|
container.heightAnchor.constraint(equalToConstant: size),
|
||||||
container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
trailing,
|
||||||
container.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -76),
|
bottom,
|
||||||
])
|
])
|
||||||
|
scrollToBottomTrailingConstraint = trailing
|
||||||
|
scrollToBottomBottomConstraint = bottom
|
||||||
|
scrollToBottomButtonContainer = container
|
||||||
|
|
||||||
// Button: hardcoded 42×42 frame. NO UIView.transform — scale is done
|
// Button: hardcoded 40×40 frame. NO UIView.transform — scale is done
|
||||||
// at CALayer level so UIKit never recalculates bounds through the
|
// at CALayer level so UIKit never recalculates bounds through the
|
||||||
// transform matrix during interactive keyboard dismiss.
|
// transform matrix during interactive keyboard dismiss.
|
||||||
let button = UIButton(type: .custom)
|
let button = UIButton(type: .custom)
|
||||||
button.frame = rect
|
button.frame = rect
|
||||||
button.clipsToBounds = true
|
button.clipsToBounds = true
|
||||||
button.alpha = 0
|
button.alpha = 0
|
||||||
button.layer.transform = CATransform3DMakeScale(0.01, 0.01, 1.0)
|
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
|
||||||
button.layer.allowsEdgeAntialiasing = true
|
button.layer.allowsEdgeAntialiasing = true
|
||||||
container.addSubview(button)
|
container.addSubview(button)
|
||||||
|
|
||||||
// Glass circle background: hardcoded 42×42 frame, no autoresizingMask.
|
// Glass circle background: hardcoded 40×40 frame, no autoresizingMask.
|
||||||
let glass = TelegramGlassUIView(frame: rect)
|
let glass = TelegramGlassUIView(frame: rect)
|
||||||
glass.isCircle = true
|
glass.isCircle = true
|
||||||
glass.isUserInteractionEnabled = false
|
glass.isUserInteractionEnabled = false
|
||||||
button.addSubview(glass)
|
button.addSubview(glass)
|
||||||
|
|
||||||
// Chevron down icon: hardcoded 42×42 frame, centered contentMode.
|
// Telegram-style down icon (canvas 38×38, line width 1.5).
|
||||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
|
||||||
let chevron = UIImage(systemName: "chevron.down", withConfiguration: config)
|
|
||||||
let imageView = UIImageView(image: chevron)
|
|
||||||
imageView.tintColor = .white
|
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
imageView.frame = rect
|
imageView.frame = rect
|
||||||
button.addSubview(imageView)
|
button.addSubview(imageView)
|
||||||
|
|
||||||
|
let badgeView = UIView(frame: .zero)
|
||||||
|
badgeView.backgroundColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1)
|
||||||
|
badgeView.layer.cornerCurve = .continuous
|
||||||
|
badgeView.layer.cornerRadius = 10
|
||||||
|
badgeView.isHidden = true
|
||||||
|
button.addSubview(badgeView)
|
||||||
|
scrollToBottomBadgeView = badgeView
|
||||||
|
|
||||||
|
let badgeLabel = UILabel(frame: .zero)
|
||||||
|
badgeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||||||
|
badgeLabel.textColor = .white
|
||||||
|
badgeLabel.textAlignment = .center
|
||||||
|
badgeView.addSubview(badgeLabel)
|
||||||
|
scrollToBottomBadgeLabel = badgeLabel
|
||||||
|
|
||||||
button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside)
|
button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside)
|
||||||
scrollToBottomButton = button
|
scrollToBottomButton = button
|
||||||
|
updateScrollToBottomButtonConstraints()
|
||||||
|
updateScrollToBottomBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func scrollToBottomTapped() {
|
@objc private func scrollToBottomTapped() {
|
||||||
@@ -382,7 +422,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Show/hide the scroll-to-bottom button with CALayer-level scaling.
|
/// Show/hide the scroll-to-bottom button with CALayer-level scaling.
|
||||||
/// UIView.bounds stays 42×42 at ALL times — only rendered pixels scale.
|
/// UIView.bounds stays 40×40 at ALL times — only rendered pixels scale.
|
||||||
/// No UIView.transform, no layoutIfNeeded — completely bypasses the
|
/// No UIView.transform, no layoutIfNeeded — completely bypasses the
|
||||||
/// Auto Layout ↔ transform race condition during interactive dismiss.
|
/// Auto Layout ↔ transform race condition during interactive dismiss.
|
||||||
func setScrollToBottomVisible(_ visible: Bool) {
|
func setScrollToBottomVisible(_ visible: Bool) {
|
||||||
@@ -390,13 +430,79 @@ final class NativeMessageListController: UIViewController {
|
|||||||
let isCurrentlyVisible = button.alpha > 0.5
|
let isCurrentlyVisible = button.alpha > 0.5
|
||||||
guard visible != isCurrentlyVisible else { return }
|
guard visible != isCurrentlyVisible else { return }
|
||||||
|
|
||||||
UIView.animate(withDuration: visible ? 0.25 : 0.2, delay: 0,
|
UIView.animate(withDuration: 0.3, delay: 0,
|
||||||
usingSpringWithDamping: 0.8, initialSpringVelocity: 0,
|
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
|
||||||
options: .beginFromCurrentState) {
|
options: .beginFromCurrentState) {
|
||||||
button.alpha = visible ? 1 : 0
|
button.alpha = visible ? 1 : 0
|
||||||
button.layer.transform = visible
|
button.layer.transform = visible
|
||||||
? CATransform3DIdentity
|
? CATransform3DIdentity
|
||||||
: CATransform3DMakeScale(0.01, 0.01, 1.0)
|
: CATransform3DMakeScale(0.2, 0.2, 1.0)
|
||||||
|
}
|
||||||
|
updateScrollToBottomBadge()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateScrollToBottomButtonConstraints() {
|
||||||
|
let safeBottom = view.safeAreaInsets.bottom
|
||||||
|
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
|
||||||
|
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
|
||||||
|
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateScrollToBottomBadge() {
|
||||||
|
guard let badgeView = scrollToBottomBadgeView,
|
||||||
|
let badgeLabel = scrollToBottomBadgeLabel else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let unreadCount = DialogRepository.shared.dialogs[config.opponentPublicKey]?.unreadCount ?? 0
|
||||||
|
guard unreadCount > 0 else {
|
||||||
|
badgeView.isHidden = true
|
||||||
|
badgeLabel.text = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let badgeText = Self.compactUnreadCountString(unreadCount)
|
||||||
|
badgeLabel.text = badgeText
|
||||||
|
let badgeFont = badgeLabel.font ?? UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||||||
|
let textWidth = ceil((badgeText as NSString).size(withAttributes: [.font: badgeFont]).width)
|
||||||
|
let badgeWidth = max(20, textWidth + 11)
|
||||||
|
let badgeFrame = CGRect(
|
||||||
|
x: floor((UIConstants.scrollButtonSize - badgeWidth) / 2),
|
||||||
|
y: -7,
|
||||||
|
width: badgeWidth,
|
||||||
|
height: 20
|
||||||
|
)
|
||||||
|
badgeView.frame = badgeFrame
|
||||||
|
badgeView.layer.cornerRadius = badgeFrame.height * 0.5
|
||||||
|
badgeLabel.frame = badgeView.bounds
|
||||||
|
badgeView.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeTelegramDownButtonImage() -> UIImage? {
|
||||||
|
let size = CGSize(width: UIConstants.scrollButtonIconCanvas, height: UIConstants.scrollButtonIconCanvas)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
let gc = ctx.cgContext
|
||||||
|
gc.clear(CGRect(origin: .zero, size: size))
|
||||||
|
gc.setStrokeColor(UIColor.white.cgColor)
|
||||||
|
gc.setLineWidth(1.5)
|
||||||
|
gc.setLineCap(.round)
|
||||||
|
gc.setLineJoin(.round)
|
||||||
|
|
||||||
|
let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
|
||||||
|
gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
|
||||||
|
gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
|
||||||
|
gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
|
||||||
|
gc.strokePath()
|
||||||
|
}.withRenderingMode(.alwaysOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func compactUnreadCountString(_ count: Int) -> String {
|
||||||
|
if count >= 1_000_000 {
|
||||||
|
return "\(count / 1_000_000)M"
|
||||||
|
} else if count >= 1_000 {
|
||||||
|
return "\(count / 1_000)K"
|
||||||
|
} else {
|
||||||
|
return "\(count)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +531,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||||
|
updateScrollToBottomBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout Calculation (Telegram asyncLayout pattern)
|
// MARK: - Layout Calculation (Telegram asyncLayout pattern)
|
||||||
@@ -473,10 +580,11 @@ final class NativeMessageListController: UIViewController {
|
|||||||
/// would double the adjustment → content teleports upward.
|
/// would double the adjustment → content teleports upward.
|
||||||
private func applyInsets() {
|
private func applyInsets() {
|
||||||
guard collectionView != nil else { return }
|
guard collectionView != nil else { return }
|
||||||
|
updateScrollToBottomButtonConstraints()
|
||||||
|
|
||||||
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
||||||
let composerHeight = lastComposerHeight
|
let composerHeight = lastComposerHeight
|
||||||
let newInsetTop = composerHeight + composerBottom
|
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
|
||||||
let topInset = view.safeAreaInsets.top + 6
|
let topInset = view.safeAreaInsets.top + 6
|
||||||
|
|
||||||
let oldInsetTop = collectionView.contentInset.top
|
let oldInsetTop = collectionView.contentInset.top
|
||||||
@@ -496,6 +604,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
if shouldCompensate {
|
if shouldCompensate {
|
||||||
collectionView.contentOffset.y = oldOffset - delta
|
collectionView.contentOffset.y = oldOffset - delta
|
||||||
}
|
}
|
||||||
|
updateScrollToBottomBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
|
/// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
|
||||||
@@ -588,7 +697,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// Telegram pattern: animate composer position + content insets in ONE block.
|
// Telegram pattern: animate composer position + content insets in ONE block.
|
||||||
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
||||||
let composerH = lastComposerHeight
|
let composerH = lastComposerHeight
|
||||||
let newInsetTop = composerH + composerBottom
|
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
|
||||||
let topInset = view.safeAreaInsets.top + 6
|
let topInset = view.safeAreaInsets.top + 6
|
||||||
let oldInsetTop = collectionView.contentInset.top
|
let oldInsetTop = collectionView.contentInset.top
|
||||||
let delta = newInsetTop - oldInsetTop
|
let delta = newInsetTop - oldInsetTop
|
||||||
@@ -655,6 +764,7 @@ extension NativeMessageListController: UICollectionViewDelegate {
|
|||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||||
let isAtBottom = offsetFromBottom < 50
|
let isAtBottom = offsetFromBottom < 50
|
||||||
|
updateScrollToBottomBadge()
|
||||||
|
|
||||||
// Dedup — only fire when value actually changes.
|
// Dedup — only fire when value actually changes.
|
||||||
// Without this, callback fires 60fps during keyboard animation
|
// Without this, callback fires 60fps during keyboard animation
|
||||||
|
|||||||
81
Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift
Normal file
81
Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum StatusIconRenderer {
|
||||||
|
|
||||||
|
static func makeCheckImage(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? {
|
||||||
|
let height = floor(width * 9.0 / 11.0)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
|
||||||
|
return renderer.image { ctx in
|
||||||
|
let gc = ctx.cgContext
|
||||||
|
gc.clear(CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
||||||
|
gc.translateBy(x: 1.0, y: 1.0)
|
||||||
|
gc.setStrokeColor(color.cgColor)
|
||||||
|
gc.setLineWidth(0.99)
|
||||||
|
gc.setLineCap(.round)
|
||||||
|
gc.setLineJoin(.round)
|
||||||
|
if partial {
|
||||||
|
gc.move(to: CGPoint(x: 0.5, y: 7))
|
||||||
|
gc.addLine(to: CGPoint(x: 7, y: 0))
|
||||||
|
} else {
|
||||||
|
gc.move(to: CGPoint(x: 0, y: 4))
|
||||||
|
gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047))
|
||||||
|
gc.addCurve(
|
||||||
|
to: CGPoint(x: 3.04490857, y: 6.95157047),
|
||||||
|
control1: CGPoint(x: 2.97734507, y: 6.97734507),
|
||||||
|
control2: CGPoint(x: 3.01913396, y: 6.97734507)
|
||||||
|
)
|
||||||
|
gc.addCurve(
|
||||||
|
to: CGPoint(x: 3.04660389, y: 6.9498112),
|
||||||
|
control1: CGPoint(x: 3.04548448, y: 6.95099456),
|
||||||
|
control2: CGPoint(x: 3.04604969, y: 6.95040803)
|
||||||
|
)
|
||||||
|
gc.addLine(to: CGPoint(x: 9.5, y: 0))
|
||||||
|
}
|
||||||
|
gc.strokePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeClockFrameImage(color: UIColor) -> UIImage? {
|
||||||
|
let size = CGSize(width: 11, height: 11)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
let gc = ctx.cgContext
|
||||||
|
gc.translateBy(x: 0, y: size.height)
|
||||||
|
gc.scaleBy(x: 1, y: -1)
|
||||||
|
gc.clear(CGRect(origin: .zero, size: size))
|
||||||
|
gc.setStrokeColor(color.cgColor)
|
||||||
|
gc.setFillColor(color.cgColor)
|
||||||
|
gc.setLineWidth(1.0)
|
||||||
|
gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10))
|
||||||
|
gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeClockMinImage(color: UIColor) -> UIImage? {
|
||||||
|
let size = CGSize(width: 11, height: 11)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
let gc = ctx.cgContext
|
||||||
|
gc.translateBy(x: 0, y: size.height)
|
||||||
|
gc.scaleBy(x: 1, y: -1)
|
||||||
|
gc.clear(CGRect(origin: .zero, size: size))
|
||||||
|
gc.setFillColor(color.cgColor)
|
||||||
|
gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? {
|
||||||
|
let size = CGSize(width: width, height: width)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
let gc = ctx.cgContext
|
||||||
|
gc.scaleBy(x: width / 11.0, y: width / 11.0)
|
||||||
|
gc.setFillColor(color.cgColor)
|
||||||
|
gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0))
|
||||||
|
gc.setFillColor(UIColor.white.cgColor)
|
||||||
|
gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25))
|
||||||
|
gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,18 @@ struct ZoomableImagePage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||||||
|
image = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ImageLoadLimiter.shared.acquire()
|
||||||
|
let loaded = await Task.detached(priority: .utility) {
|
||||||
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
|
}.value
|
||||||
|
await ImageLoadLimiter.shared.release()
|
||||||
|
if !Task.isCancelled {
|
||||||
|
image = loaded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,27 +24,17 @@ struct ChatListSearchContent: View {
|
|||||||
|
|
||||||
private extension ChatListSearchContent {
|
private extension ChatListSearchContent {
|
||||||
/// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time.
|
/// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time.
|
||||||
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
|
/// Uses unified search policy from ChatListViewModel callback merge.
|
||||||
/// to avoid @Published re-render cascade through ChatListView.
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var activeSearchContent: some View {
|
var activeSearchContent: some View {
|
||||||
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
|
let hasAnyResult = !viewModel.serverSearchResults.isEmpty
|
||||||
// Local results: match by username ONLY (desktop parity — server matches usernames)
|
|
||||||
let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in
|
|
||||||
!query.isEmpty && dialog.opponentUsername.lowercased().contains(query)
|
|
||||||
}
|
|
||||||
let localKeys = Set(localResults.map(\.opponentKey))
|
|
||||||
let serverOnly = viewModel.serverSearchResults.filter {
|
|
||||||
!localKeys.contains($0.publicKey)
|
|
||||||
}
|
|
||||||
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
|
|
||||||
|
|
||||||
if viewModel.isServerSearching && !hasAnyResult {
|
if viewModel.isServerSearching && !hasAnyResult {
|
||||||
SearchSkeletonView()
|
SearchSkeletonView()
|
||||||
} else if !viewModel.isServerSearching && !hasAnyResult {
|
} else if !viewModel.isServerSearching && !hasAnyResult {
|
||||||
noResultsState
|
noResultsState
|
||||||
} else {
|
} else {
|
||||||
resultsList(localResults: localResults, serverOnly: serverOnly)
|
resultsList(results: viewModel.serverSearchResults)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,23 +58,14 @@ private extension ChatListSearchContent {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scrollable list of local dialogs + server results.
|
/// Scrollable list of merged search results.
|
||||||
/// Shows skeleton rows at the bottom while server is still searching.
|
/// Shows skeleton rows at the bottom while server is still searching.
|
||||||
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
|
func resultsList(results: [SearchUser]) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(localResults) { dialog in
|
ForEach(results, id: \.publicKey) { user in
|
||||||
Button {
|
|
||||||
onOpenDialog(ChatRoute(dialog: dialog))
|
|
||||||
} label: {
|
|
||||||
ChatRowView(dialog: dialog)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(serverOnly, id: \.publicKey) { user in
|
|
||||||
serverUserRow(user)
|
serverUserRow(user)
|
||||||
if user.publicKey != serverOnly.last?.publicKey {
|
if user.publicKey != results.last?.publicKey {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, 76)
|
.padding(.leading, 76)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
private var searchHandlerToken: UUID?
|
private var searchHandlerToken: UUID?
|
||||||
private var recentSearchesCancellable: AnyCancellable?
|
private var recentSearchesCancellable: AnyCancellable?
|
||||||
private let recentRepository = RecentSearchesRepository.shared
|
private let recentRepository = RecentSearchesRepository.shared
|
||||||
|
private let searchDispatcher: SearchResultDispatching
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||||
|
self.searchDispatcher = searchDispatcher
|
||||||
configureRecentSearches()
|
configureRecentSearches()
|
||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
searchQuery = normalizeSearchInput(query)
|
searchQuery = SearchParityPolicy.sanitizeInput(query)
|
||||||
triggerServerSearch()
|
triggerServerSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +134,11 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func setupSearchCallback() {
|
private func setupSearchCallback() {
|
||||||
if let token = searchHandlerToken {
|
if let token = searchHandlerToken {
|
||||||
ProtocolManager.shared.removeSearchResultHandler(token)
|
searchDispatcher.removeSearchResultHandler(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
Self.logger.debug("Setting up search callback")
|
Self.logger.debug("Setting up search callback")
|
||||||
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
Self.logger.debug("Search callback: self is nil")
|
Self.logger.debug("Search callback: self is nil")
|
||||||
@@ -147,7 +149,16 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
|
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
|
||||||
self.serverSearchResults = packet.users
|
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||||
|
let localMatches = SearchParityPolicy.localAugmentedUsers(
|
||||||
|
query: query,
|
||||||
|
currentPublicKey: SessionManager.shared.currentPublicKey,
|
||||||
|
dialogs: Array(DialogRepository.shared.dialogs.values)
|
||||||
|
)
|
||||||
|
self.serverSearchResults = SearchParityPolicy.mergeServerAndLocal(
|
||||||
|
server: packet.users,
|
||||||
|
local: localMatches
|
||||||
|
)
|
||||||
self.isServerSearching = false
|
self.isServerSearching = false
|
||||||
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
|
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
|
||||||
for user in packet.users {
|
for user in packet.users {
|
||||||
@@ -169,7 +180,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
searchRetryTask?.cancel()
|
searchRetryTask?.cancel()
|
||||||
searchRetryTask = nil
|
searchRetryTask = nil
|
||||||
|
|
||||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
let trimmed = SearchParityPolicy.normalizedQuery(searchQuery)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
// Guard: only publish if value actually changes (avoids extra re-renders)
|
// Guard: only publish if value actually changes (avoids extra re-renders)
|
||||||
if !serverSearchResults.isEmpty { serverSearchResults = [] }
|
if !serverSearchResults.isEmpty { serverSearchResults = [] }
|
||||||
@@ -184,7 +195,7 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
guard let self, !Task.isCancelled else { return }
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
|
||||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||||
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
||||||
|
|
||||||
self.sendSearchPacket(query: currentQuery)
|
self.sendSearchPacket(query: currentQuery)
|
||||||
@@ -193,8 +204,8 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
|
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
|
||||||
private func sendSearchPacket(query: String) {
|
private func sendSearchPacket(query: String) {
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = searchDispatcher.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
// Not authenticated — wait for reconnect then send
|
// Not authenticated — wait for reconnect then send
|
||||||
@@ -205,9 +216,9 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
for _ in 0..<20 {
|
for _ in 0..<20 {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
guard let self, !Task.isCancelled else { return }
|
guard let self, !Task.isCancelled else { return }
|
||||||
let current = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let current = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||||
guard current == query else { return } // Query changed, abort
|
guard current == query else { return } // Query changed, abort
|
||||||
if ProtocolManager.shared.connectionState == .authenticated {
|
if self.searchDispatcher.connectionState == .authenticated {
|
||||||
Self.logger.debug("Connection restored — sending pending search")
|
Self.logger.debug("Connection restored — sending pending search")
|
||||||
self.sendSearchPacket(query: query)
|
self.sendSearchPacket(query: query)
|
||||||
return
|
return
|
||||||
@@ -223,14 +234,9 @@ final class ChatListViewModel: ObservableObject {
|
|||||||
|
|
||||||
var packet = PacketSearch()
|
var packet = PacketSearch()
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.search = query.lowercased()
|
packet.search = query
|
||||||
Self.logger.debug("📤 Sending search packet for '\(query)'")
|
Self.logger.debug("📤 Sending search packet for '\(query)'")
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
searchDispatcher.sendSearchPacket(packet)
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeSearchInput(_ input: String) -> String {
|
|
||||||
input.replacingOccurrences(of: "@", with: "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recent Searches
|
// MARK: - Recent Searches
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ final class SearchViewModel: ObservableObject {
|
|||||||
private var searchHandlerToken: UUID?
|
private var searchHandlerToken: UUID?
|
||||||
private var recentSearchesCancellable: AnyCancellable?
|
private var recentSearchesCancellable: AnyCancellable?
|
||||||
private let recentRepository = RecentSearchesRepository.shared
|
private let recentRepository = RecentSearchesRepository.shared
|
||||||
|
private let searchDispatcher: SearchResultDispatching
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
init() {
|
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||||
|
self.searchDispatcher = searchDispatcher
|
||||||
configureRecentSearches()
|
configureRecentSearches()
|
||||||
setupSearchCallback()
|
setupSearchCallback()
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ final class SearchViewModel: ObservableObject {
|
|||||||
// MARK: - Search Logic
|
// MARK: - Search Logic
|
||||||
|
|
||||||
func setSearchQuery(_ query: String) {
|
func setSearchQuery(_ query: String) {
|
||||||
searchQuery = normalizeSearchInput(query)
|
searchQuery = SearchParityPolicy.sanitizeInput(query)
|
||||||
onSearchQueryChanged()
|
onSearchQueryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,15 +51,15 @@ final class SearchViewModel: ObservableObject {
|
|||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
searchTask = nil
|
searchTask = nil
|
||||||
|
|
||||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
let normalized = SearchParityPolicy.normalizedQuery(searchQuery)
|
||||||
if trimmed.isEmpty {
|
if normalized.isEmpty {
|
||||||
searchResults = []
|
searchResults = []
|
||||||
isSearching = false
|
isSearching = false
|
||||||
lastSearchedText = ""
|
lastSearchedText = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed == lastSearchedText {
|
if normalized == lastSearchedText {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +73,13 @@ final class SearchViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||||
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
guard !currentQuery.isEmpty, currentQuery == normalized else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let connState = ProtocolManager.shared.connectionState
|
let connState = self.searchDispatcher.connectionState
|
||||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
let hash = SessionManager.shared.privateKeyHash ?? self.searchDispatcher.privateHash
|
||||||
|
|
||||||
guard connState == .authenticated, let hash else {
|
guard connState == .authenticated, let hash else {
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
@@ -88,9 +90,9 @@ final class SearchViewModel: ObservableObject {
|
|||||||
|
|
||||||
var packet = PacketSearch()
|
var packet = PacketSearch()
|
||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
packet.search = currentQuery.lowercased()
|
packet.search = currentQuery
|
||||||
|
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
self.searchDispatcher.sendSearchPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,30 +109,27 @@ final class SearchViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func setupSearchCallback() {
|
private func setupSearchCallback() {
|
||||||
if let token = searchHandlerToken {
|
if let token = searchHandlerToken {
|
||||||
ProtocolManager.shared.removeSearchResultHandler(token)
|
searchDispatcher.removeSearchResultHandler(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||||
guard !query.isEmpty else {
|
guard !query.isEmpty else {
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge server results with client-side public key matches.
|
let localMatches = SearchParityPolicy.localAugmentedUsers(
|
||||||
// Server only matches by username; public key matching is local
|
query: query,
|
||||||
// (same approach as Android).
|
currentPublicKey: SessionManager.shared.currentPublicKey,
|
||||||
var merged = packet.users
|
dialogs: Array(DialogRepository.shared.dialogs.values)
|
||||||
let serverKeys = Set(merged.map(\.publicKey))
|
)
|
||||||
|
self.searchResults = SearchParityPolicy.mergeServerAndLocal(
|
||||||
let localMatches = self.findLocalPublicKeyMatches(query: query)
|
server: packet.users,
|
||||||
for match in localMatches where !serverKeys.contains(match.publicKey) {
|
local: localMatches
|
||||||
merged.append(match)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
self.searchResults = merged
|
|
||||||
self.isSearching = false
|
self.isSearching = false
|
||||||
|
|
||||||
// Update dialog info from server results
|
// Update dialog info from server results
|
||||||
@@ -147,57 +146,6 @@ final class SearchViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Client-Side Public Key Matching
|
|
||||||
|
|
||||||
/// Matches the query against local dialogs' public keys and the user's own
|
|
||||||
/// key (Saved Messages). The server only searches by username, so public
|
|
||||||
/// key look-ups must happen on the client (matches Android behaviour).
|
|
||||||
private func findLocalPublicKeyMatches(query: String) -> [SearchUser] {
|
|
||||||
let normalized = query.lowercased().replacingOccurrences(of: "0x", with: "")
|
|
||||||
|
|
||||||
// Only treat as a public key search when every character is hex
|
|
||||||
guard !normalized.isEmpty, normalized.allSatisfy(\.isHexDigit) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var results: [SearchUser] = []
|
|
||||||
|
|
||||||
// Check own public key → Saved Messages
|
|
||||||
let ownKey = SessionManager.shared.currentPublicKey.lowercased().replacingOccurrences(of: "0x", with: "")
|
|
||||||
if ownKey.hasPrefix(normalized) || ownKey == normalized {
|
|
||||||
results.append(SearchUser(
|
|
||||||
username: "",
|
|
||||||
title: "Saved Messages",
|
|
||||||
publicKey: SessionManager.shared.currentPublicKey,
|
|
||||||
verified: 0,
|
|
||||||
online: 0
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check local dialogs
|
|
||||||
for dialog in DialogRepository.shared.dialogs.values {
|
|
||||||
let dialogKey = dialog.opponentKey.lowercased().replacingOccurrences(of: "0x", with: "")
|
|
||||||
guard dialogKey.hasPrefix(normalized) || dialogKey == normalized else { continue }
|
|
||||||
// Skip if it's our own key (already handled as Saved Messages)
|
|
||||||
guard dialog.opponentKey != SessionManager.shared.currentPublicKey else { continue }
|
|
||||||
|
|
||||||
results.append(SearchUser(
|
|
||||||
username: dialog.opponentUsername,
|
|
||||||
title: dialog.opponentTitle,
|
|
||||||
publicKey: dialog.opponentKey,
|
|
||||||
verified: dialog.verified,
|
|
||||||
online: dialog.isOnline ? 0 : 1
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeSearchInput(_ input: String) -> String {
|
|
||||||
input.replacingOccurrences(of: "@", with: "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Recent Searches
|
// MARK: - Recent Searches
|
||||||
|
|
||||||
func addToRecent(_ user: SearchUser) {
|
func addToRecent(_ user: SearchUser) {
|
||||||
|
|||||||
181
RosettaTests/AttachmentParityTests.swift
Normal file
181
RosettaTests/AttachmentParityTests.swift
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AttachmentParityTests: XCTestCase {
|
||||||
|
private var ctx: DBTestContext!
|
||||||
|
private var transportMock: MockAttachmentFlowTransport!
|
||||||
|
private var senderMock: MockPacketFlowSender!
|
||||||
|
|
||||||
|
private var ownPrivateKeyHex: String = ""
|
||||||
|
private var ownPublicKey: String = ""
|
||||||
|
private var peerPublicKey: String = ""
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
let ownPair = try Self.makeKeyPair()
|
||||||
|
let peerPair = try Self.makeKeyPair()
|
||||||
|
|
||||||
|
ownPrivateKeyHex = ownPair.privateKeyHex
|
||||||
|
ownPublicKey = ownPair.publicKeyHex
|
||||||
|
peerPublicKey = peerPair.publicKeyHex
|
||||||
|
|
||||||
|
ctx = DBTestContext(account: ownPublicKey)
|
||||||
|
transportMock = MockAttachmentFlowTransport()
|
||||||
|
senderMock = MockPacketFlowSender()
|
||||||
|
|
||||||
|
SessionManager.shared.testConfigureSessionForParityFlows(
|
||||||
|
currentPublicKey: ownPublicKey,
|
||||||
|
privateKeyHex: ownPrivateKeyHex
|
||||||
|
)
|
||||||
|
SessionManager.shared.attachmentFlowTransport = transportMock
|
||||||
|
SessionManager.shared.packetFlowSender = senderMock
|
||||||
|
AttachmentCache.shared.privateKey = ownPrivateKeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
ctx?.teardown()
|
||||||
|
ctx = nil
|
||||||
|
transportMock = nil
|
||||||
|
senderMock = nil
|
||||||
|
AttachmentCache.shared.privateKey = nil
|
||||||
|
SessionManager.shared.testResetParityFlowDependencies()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAttachmentPreviewParserMatrix() {
|
||||||
|
let tag = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"
|
||||||
|
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "\(tag)::LKO2"), tag)
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "\(tag)::LKO2"), "LKO2")
|
||||||
|
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: "::LKO2"), "")
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: "::LKO2"), "LKO2")
|
||||||
|
|
||||||
|
let taggedFile = AttachmentPreviewCodec.parseFilePreview("\(tag)::2048::report.pdf")
|
||||||
|
XCTAssertEqual(taggedFile.downloadTag, tag)
|
||||||
|
XCTAssertEqual(taggedFile.fileSize, 2048)
|
||||||
|
XCTAssertEqual(taggedFile.fileName, "report.pdf")
|
||||||
|
|
||||||
|
let localFile = AttachmentPreviewCodec.parseFilePreview("512::notes.txt")
|
||||||
|
XCTAssertEqual(localFile.downloadTag, "")
|
||||||
|
XCTAssertEqual(localFile.fileSize, 512)
|
||||||
|
XCTAssertEqual(localFile.fileName, "notes.txt")
|
||||||
|
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.payload(from: "legacy_preview"), "legacy_preview")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutgoingAttachmentPacketShapeClearsBlobAndUsesTaggedPreview() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
let image = Self.makeSolidImage(color: .systemBlue)
|
||||||
|
let imageAttachment = PendingAttachment.fromImage(image)
|
||||||
|
let fileData = Data("hello parity".utf8)
|
||||||
|
let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "notes.txt")
|
||||||
|
|
||||||
|
let imageTag = "11111111-1111-1111-1111-111111111111"
|
||||||
|
let fileTag = "22222222-2222-2222-2222-222222222222"
|
||||||
|
transportMock.tagsById[imageAttachment.id] = imageTag
|
||||||
|
transportMock.tagsById[fileAttachment.id] = fileTag
|
||||||
|
|
||||||
|
try await SessionManager.shared.sendMessageWithAttachments(
|
||||||
|
text: "",
|
||||||
|
attachments: [imageAttachment, fileAttachment],
|
||||||
|
toPublicKey: peerPublicKey,
|
||||||
|
opponentTitle: "Peer",
|
||||||
|
opponentUsername: "peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(transportMock.uploadedIds.count, 2)
|
||||||
|
XCTAssertEqual(senderMock.sentMessages.count, 1)
|
||||||
|
|
||||||
|
guard let sent = senderMock.sentMessages.first else {
|
||||||
|
XCTFail("No outgoing packet captured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(sent.attachments.count, 2)
|
||||||
|
XCTAssertTrue(sent.attachments.allSatisfy { $0.blob.isEmpty })
|
||||||
|
|
||||||
|
guard let sentImage = sent.attachments.first(where: { $0.id == imageAttachment.id }) else {
|
||||||
|
XCTFail("Missing image attachment in packet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), imageTag)
|
||||||
|
|
||||||
|
guard let sentFile = sent.attachments.first(where: { $0.id == fileAttachment.id }) else {
|
||||||
|
XCTFail("Missing file attachment in packet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let parsedFile = AttachmentPreviewCodec.parseFilePreview(sentFile.preview)
|
||||||
|
XCTAssertEqual(parsedFile.downloadTag, fileTag)
|
||||||
|
XCTAssertEqual(parsedFile.fileSize, fileData.count)
|
||||||
|
XCTAssertEqual(parsedFile.fileName, "notes.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavedSelfFileFlowKeepsLocalFileOpenableWithoutUpload() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
let fileData = Data("self file payload".utf8)
|
||||||
|
let fileAttachment = PendingAttachment.fromFile(data: fileData, fileName: "local.txt")
|
||||||
|
|
||||||
|
try await SessionManager.shared.sendMessageWithAttachments(
|
||||||
|
text: "",
|
||||||
|
attachments: [fileAttachment],
|
||||||
|
toPublicKey: ownPublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(transportMock.uploadedIds.isEmpty)
|
||||||
|
XCTAssertTrue(senderMock.sentMessages.isEmpty)
|
||||||
|
|
||||||
|
let cachedURL = AttachmentCache.shared.fileURL(
|
||||||
|
forAttachmentId: fileAttachment.id,
|
||||||
|
fileName: "local.txt"
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(cachedURL)
|
||||||
|
|
||||||
|
let loaded = AttachmentCache.shared.loadFileData(
|
||||||
|
forAttachmentId: fileAttachment.id,
|
||||||
|
fileName: "local.txt"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(loaded, fileData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AttachmentParityTests {
|
||||||
|
static func makeSolidImage(color: UIColor) -> UIImage {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32))
|
||||||
|
return renderer.image { ctx in
|
||||||
|
color.setFill()
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: 32, height: 32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) {
|
||||||
|
let mnemonic = try CryptoManager.shared.generateMnemonic()
|
||||||
|
let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic)
|
||||||
|
return (pair.privateKey.hexString, pair.publicKey.hexString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockAttachmentFlowTransport: AttachmentFlowTransporting {
|
||||||
|
var tagsById: [String: String] = [:]
|
||||||
|
private(set) var uploadedIds: [String] = []
|
||||||
|
|
||||||
|
func uploadFile(id: String, content: Data) async throws -> String {
|
||||||
|
uploadedIds.append(id)
|
||||||
|
return tagsById[id] ?? UUID().uuidString.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(tag: String) async throws -> Data {
|
||||||
|
Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockPacketFlowSender: PacketFlowSending {
|
||||||
|
private(set) var sentMessages: [PacketMessage] = []
|
||||||
|
|
||||||
|
func sendPacket(_ packet: any Packet) {
|
||||||
|
if let message = packet as? PacketMessage {
|
||||||
|
sentMessages.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
RosettaTests/BehaviorParityFixtureTests.swift
Normal file
194
RosettaTests/BehaviorParityFixtureTests.swift
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BehaviorParityFixtureTests: XCTestCase {
|
||||||
|
private var ctx: DBTestContext!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
ctx = DBTestContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
ctx.teardown()
|
||||||
|
ctx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIncomingDirectFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "incoming direct", events: [
|
||||||
|
.incoming(opponent: "02peer_direct", messageId: "in-1", timestamp: 100, text: "hello"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.count, 1)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.messageId, "in-1")
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.read, false)
|
||||||
|
|
||||||
|
XCTAssertEqual(snapshot.dialogs.count, 1)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.opponentKey, "02peer_direct")
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.iHaveSent, false)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.isRequest, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutgoingDeliveredReadFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "outgoing delivered read", events: [
|
||||||
|
.outgoing(opponent: "02peer_ack", messageId: "out-1", timestamp: 200, text: "yo"),
|
||||||
|
.markDelivered(opponent: "02peer_ack", messageId: "out-1"),
|
||||||
|
.markOutgoingRead(opponent: "02peer_ack"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.count, 1)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.fromMe, true)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.delivered, DeliveryStatus.delivered.rawValue)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.read, true)
|
||||||
|
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.lastMessageFromMe, true)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.lastMessageDelivered, DeliveryStatus.delivered.rawValue)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.lastMessageRead, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncBatchDedupFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "sync dedup", events: [
|
||||||
|
.incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"),
|
||||||
|
.incoming(opponent: "02peer_dedup", messageId: "dup-1", timestamp: 300, text: "a"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.count, 1)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.messageId, "dup-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavedMessagesFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "saved", events: [
|
||||||
|
.outgoing(opponent: ctx.account, messageId: "self-1", timestamp: 400, text: "note"),
|
||||||
|
.markDelivered(opponent: ctx.account, messageId: "self-1"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.count, 1)
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.dialogKey, ctx.account)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.opponentKey, ctx.account)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGroupConversationDbFlowSafeFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "group safe", events: [
|
||||||
|
.incomingPacket(
|
||||||
|
from: "02group_member_a",
|
||||||
|
to: "#group:alpha",
|
||||||
|
messageId: "g-1",
|
||||||
|
timestamp: 500,
|
||||||
|
text: "group hi"
|
||||||
|
),
|
||||||
|
.incomingPacket(
|
||||||
|
from: "02conversation_member",
|
||||||
|
to: "conversation:room42",
|
||||||
|
messageId: "c-1",
|
||||||
|
timestamp: 501,
|
||||||
|
text: "conv hi"
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
let groupMessage = snapshot.messages.first { $0.messageId == "g-1" }
|
||||||
|
let conversationMessage = snapshot.messages.first { $0.messageId == "c-1" }
|
||||||
|
|
||||||
|
XCTAssertEqual(groupMessage?.dialogKey, "#group:alpha")
|
||||||
|
XCTAssertEqual(conversationMessage?.dialogKey, "conversation:room42")
|
||||||
|
|
||||||
|
let groupDialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" }
|
||||||
|
let conversationDialog = snapshot.dialogs.first { $0.opponentKey == "conversation:room42" }
|
||||||
|
XCTAssertEqual(groupDialog?.iHaveSent, true)
|
||||||
|
XCTAssertEqual(groupDialog?.isRequest, false)
|
||||||
|
XCTAssertEqual(conversationDialog?.iHaveSent, true)
|
||||||
|
XCTAssertEqual(conversationDialog?.isRequest, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAttachmentsOnlyLastMessageFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
let imageAttachment = MessageAttachment(id: "att-1", preview: "", blob: "", type: .image)
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "attachments only", events: [
|
||||||
|
.incoming(opponent: "02peer_media", messageId: "media-1", timestamp: 600, text: "", attachments: [imageAttachment]),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.hasAttachments, true)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGroupReadPacketMarksOutgoingAsReadFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "group read", events: [
|
||||||
|
.outgoing(opponent: "#group:alpha", messageId: "g-out-1", timestamp: 610, text: "hello group"),
|
||||||
|
.markDelivered(opponent: "#group:alpha", messageId: "g-out-1"),
|
||||||
|
.applyReadPacket(from: "02group_member_b", to: "#group:alpha"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
let message = snapshot.messages.first { $0.messageId == "g-out-1" }
|
||||||
|
let dialog = snapshot.dialogs.first { $0.opponentKey == "#group:alpha" }
|
||||||
|
|
||||||
|
XCTAssertEqual(message?.fromMe, true)
|
||||||
|
XCTAssertEqual(message?.read, true)
|
||||||
|
XCTAssertEqual(dialog?.lastMessageRead, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCallAttachmentDecodeAndStorageFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
let callAttachment = MessageAttachment(
|
||||||
|
id: "call-1",
|
||||||
|
preview: "",
|
||||||
|
blob: "",
|
||||||
|
type: .call
|
||||||
|
)
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "call attachment", events: [
|
||||||
|
.incomingPacket(
|
||||||
|
from: "02peer_call",
|
||||||
|
to: ctx.account,
|
||||||
|
messageId: "call-msg-1",
|
||||||
|
timestamp: 620,
|
||||||
|
text: "",
|
||||||
|
attachments: [callAttachment]
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
XCTAssertEqual(snapshot.messages.first?.hasAttachments, true)
|
||||||
|
XCTAssertEqual(snapshot.dialogs.first?.lastMessage, "Call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequestToChatPromotionAndCursorMonotonicityFixture() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "request to chat", events: [
|
||||||
|
.incoming(opponent: "02peer_promote", messageId: "rq-1", timestamp: 700, text: "ping"),
|
||||||
|
.outgoing(opponent: "02peer_promote", messageId: "rq-2", timestamp: 701, text: "pong"),
|
||||||
|
.saveSyncCursor(1_700_000_001_000),
|
||||||
|
.saveSyncCursor(1_700_000_000_900),
|
||||||
|
.saveSyncCursor(1_700_000_001_200),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let snapshot = try ctx.normalizedSnapshot()
|
||||||
|
let dialog = snapshot.dialogs.first { $0.opponentKey == "02peer_promote" }
|
||||||
|
XCTAssertEqual(dialog?.iHaveSent, true)
|
||||||
|
XCTAssertEqual(dialog?.isRequest, false)
|
||||||
|
XCTAssertEqual(snapshot.syncCursor, 1_700_000_001_200)
|
||||||
|
}
|
||||||
|
}
|
||||||
334
RosettaTests/DBTestSupport.swift
Normal file
334
RosettaTests/DBTestSupport.swift
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
struct SchemaSnapshot {
|
||||||
|
let tables: Set<String>
|
||||||
|
let columnsByTable: [String: Set<String>]
|
||||||
|
let indexes: Set<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NormalizedDbSnapshot: Equatable {
|
||||||
|
struct Message: Equatable {
|
||||||
|
let account: String
|
||||||
|
let dialogKey: String
|
||||||
|
let messageId: String
|
||||||
|
let fromMe: Bool
|
||||||
|
let read: Bool
|
||||||
|
let delivered: Int
|
||||||
|
let plainMessage: String
|
||||||
|
let timestamp: Int64
|
||||||
|
let hasAttachments: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Dialog: Equatable {
|
||||||
|
let opponentKey: String
|
||||||
|
let lastMessage: String
|
||||||
|
let lastMessageTimestamp: Int64
|
||||||
|
let unreadCount: Int
|
||||||
|
let iHaveSent: Bool
|
||||||
|
let isRequest: Bool
|
||||||
|
let lastMessageFromMe: Bool
|
||||||
|
let lastMessageDelivered: Int
|
||||||
|
let lastMessageRead: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages: [Message]
|
||||||
|
let dialogs: [Dialog]
|
||||||
|
let syncCursor: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FixtureEvent {
|
||||||
|
case incoming(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = [])
|
||||||
|
case incomingPacket(from: String, to: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = [])
|
||||||
|
case outgoing(opponent: String, messageId: String, timestamp: Int64, text: String, attachments: [MessageAttachment] = [])
|
||||||
|
case markDelivered(opponent: String, messageId: String)
|
||||||
|
case markOutgoingRead(opponent: String)
|
||||||
|
case applyReadPacket(from: String, to: String)
|
||||||
|
case saveSyncCursor(Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureScenario {
|
||||||
|
let name: String
|
||||||
|
let events: [FixtureEvent]
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SQLiteTestDB {
|
||||||
|
private var handle: OpaquePointer?
|
||||||
|
|
||||||
|
init(path: String) throws {
|
||||||
|
if sqlite3_open(path, &handle) != SQLITE_OK {
|
||||||
|
let message = String(cString: sqlite3_errmsg(handle))
|
||||||
|
sqlite3_close(handle)
|
||||||
|
throw NSError(domain: "SQLiteTestDB", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
sqlite3_close(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(_ sql: String) throws {
|
||||||
|
var errorMessage: UnsafeMutablePointer<Int8>?
|
||||||
|
if sqlite3_exec(handle, sql, nil, nil, &errorMessage) != SQLITE_OK {
|
||||||
|
let message = errorMessage.map { String(cString: $0) } ?? "Unknown sqlite error"
|
||||||
|
sqlite3_free(errorMessage)
|
||||||
|
throw NSError(domain: "SQLiteTestDB", code: 2, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func query(_ sql: String, _ bindings: [Binding] = []) throws -> [[String: String]] {
|
||||||
|
var statement: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||||
|
let message = String(cString: sqlite3_errmsg(handle))
|
||||||
|
throw NSError(domain: "SQLiteTestDB", code: 3, userInfo: [NSLocalizedDescriptionKey: message, "sql": sql])
|
||||||
|
}
|
||||||
|
defer { sqlite3_finalize(statement) }
|
||||||
|
|
||||||
|
for (index, binding) in bindings.enumerated() {
|
||||||
|
let idx = Int32(index + 1)
|
||||||
|
switch binding {
|
||||||
|
case .text(let value):
|
||||||
|
sqlite3_bind_text(statement, idx, value, -1, SQLITE_TRANSIENT)
|
||||||
|
case .int64(let value):
|
||||||
|
sqlite3_bind_int64(statement, idx, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows: [[String: String]] = []
|
||||||
|
while sqlite3_step(statement) == SQLITE_ROW {
|
||||||
|
let columnCount = sqlite3_column_count(statement)
|
||||||
|
var row: [String: String] = [:]
|
||||||
|
for column in 0..<columnCount {
|
||||||
|
let name = String(cString: sqlite3_column_name(statement, column))
|
||||||
|
if let valuePtr = sqlite3_column_text(statement, column) {
|
||||||
|
row[name] = String(cString: valuePtr)
|
||||||
|
} else {
|
||||||
|
row[name] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.append(row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Binding {
|
||||||
|
case text(String)
|
||||||
|
case int64(Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DBTestContext {
|
||||||
|
let account: String
|
||||||
|
let storagePassword = "test-storage-password"
|
||||||
|
|
||||||
|
init(account: String = "02_ios_sql_test_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))") {
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
var databaseURL: URL {
|
||||||
|
DatabaseManager.databaseURLForTesting(accountPublicKey: account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrap() async throws {
|
||||||
|
try DatabaseManager.shared.bootstrap(accountPublicKey: account)
|
||||||
|
await MessageRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
|
||||||
|
await DialogRepository.shared.bootstrap(accountPublicKey: account, storagePassword: storagePassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardown() {
|
||||||
|
MessageRepository.shared.reset()
|
||||||
|
DialogRepository.shared.reset()
|
||||||
|
DatabaseManager.shared.close()
|
||||||
|
DatabaseManager.shared.deleteDatabase(for: account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSQLite() throws -> SQLiteTestDB {
|
||||||
|
try SQLiteTestDB(path: databaseURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaSnapshot() throws -> SchemaSnapshot {
|
||||||
|
let sqlite = try openSQLite()
|
||||||
|
let tableRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
let tables = Set(tableRows.compactMap { $0["name"] })
|
||||||
|
|
||||||
|
var columnsByTable: [String: Set<String>] = [:]
|
||||||
|
for table in tables {
|
||||||
|
let rows = try sqlite.query("PRAGMA table_info('\(table)')")
|
||||||
|
columnsByTable[table] = Set(rows.compactMap { $0["name"] })
|
||||||
|
}
|
||||||
|
|
||||||
|
let indexRows = try sqlite.query("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
let indexes = Set(indexRows.compactMap { $0["name"] })
|
||||||
|
|
||||||
|
return SchemaSnapshot(tables: tables, columnsByTable: columnsByTable, indexes: indexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedSnapshot() throws -> NormalizedDbSnapshot {
|
||||||
|
let sqlite = try openSQLite()
|
||||||
|
|
||||||
|
let messageRows = try sqlite.query(
|
||||||
|
"""
|
||||||
|
SELECT account, dialog_key, message_id, from_me, is_read, delivery_status,
|
||||||
|
COALESCE(NULLIF(plain_message, ''), text) AS plain_message,
|
||||||
|
timestamp, attachments
|
||||||
|
FROM messages
|
||||||
|
WHERE account = ?
|
||||||
|
ORDER BY dialog_key, timestamp, message_id
|
||||||
|
""",
|
||||||
|
[.text(account)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let messages = messageRows.map { row in
|
||||||
|
NormalizedDbSnapshot.Message(
|
||||||
|
account: row["account", default: ""],
|
||||||
|
dialogKey: row["dialog_key", default: ""],
|
||||||
|
messageId: row["message_id", default: ""],
|
||||||
|
fromMe: row["from_me"] == "1",
|
||||||
|
read: row["is_read"] == "1",
|
||||||
|
delivered: Int(row["delivery_status", default: "0"]) ?? 0,
|
||||||
|
plainMessage: row["plain_message", default: ""],
|
||||||
|
timestamp: Int64(row["timestamp", default: "0"]) ?? 0,
|
||||||
|
hasAttachments: row["attachments", default: "[]"] != "[]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialogRows = try sqlite.query(
|
||||||
|
"""
|
||||||
|
SELECT opponent_key, last_message, last_message_timestamp, unread_count,
|
||||||
|
i_have_sent, is_request, last_message_from_me,
|
||||||
|
last_message_delivered, last_message_read
|
||||||
|
FROM dialogs
|
||||||
|
WHERE account = ?
|
||||||
|
ORDER BY opponent_key
|
||||||
|
""",
|
||||||
|
[.text(account)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let dialogs = dialogRows.map { row in
|
||||||
|
NormalizedDbSnapshot.Dialog(
|
||||||
|
opponentKey: row["opponent_key", default: ""],
|
||||||
|
lastMessage: row["last_message", default: ""],
|
||||||
|
lastMessageTimestamp: Int64(row["last_message_timestamp", default: "0"]) ?? 0,
|
||||||
|
unreadCount: Int(row["unread_count", default: "0"]) ?? 0,
|
||||||
|
iHaveSent: row["i_have_sent"] == "1",
|
||||||
|
isRequest: row["is_request"] == "1",
|
||||||
|
lastMessageFromMe: row["last_message_from_me"] == "1",
|
||||||
|
lastMessageDelivered: Int(row["last_message_delivered", default: "0"]) ?? 0,
|
||||||
|
lastMessageRead: row["last_message_read"] == "1"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursorRow = try sqlite.query(
|
||||||
|
"SELECT last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1",
|
||||||
|
[.text(account)]
|
||||||
|
).first
|
||||||
|
let syncCursor = Int64(cursorRow?["last_sync"] ?? "0") ?? 0
|
||||||
|
|
||||||
|
return NormalizedDbSnapshot(messages: messages, dialogs: dialogs, syncCursor: syncCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runScenario(_ scenario: FixtureScenario) async throws {
|
||||||
|
func normalizeGroupDialogKey(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
if lower.hasPrefix("group:") {
|
||||||
|
let id = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return id.isEmpty ? trimmed : "#group:\(id)"
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDialogIdentity(from: String, to: String) -> String? {
|
||||||
|
let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !fromKey.isEmpty, !toKey.isEmpty else { return nil }
|
||||||
|
if DatabaseManager.isGroupDialogKey(toKey) {
|
||||||
|
return normalizeGroupDialogKey(toKey)
|
||||||
|
}
|
||||||
|
if fromKey == account { return toKey }
|
||||||
|
if toKey == account { return fromKey }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in scenario.events {
|
||||||
|
switch event {
|
||||||
|
case .incoming(let opponent, let messageId, let timestamp, let text, let attachments):
|
||||||
|
var packet = PacketMessage()
|
||||||
|
packet.fromPublicKey = opponent
|
||||||
|
packet.toPublicKey = account
|
||||||
|
packet.messageId = messageId
|
||||||
|
packet.timestamp = timestamp
|
||||||
|
packet.attachments = attachments
|
||||||
|
MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text)
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent)
|
||||||
|
|
||||||
|
case .incomingPacket(let from, let to, let messageId, let timestamp, let text, let attachments):
|
||||||
|
guard let dialogIdentity = resolveDialogIdentity(from: from, to: to) else { continue }
|
||||||
|
var packet = PacketMessage()
|
||||||
|
packet.fromPublicKey = from
|
||||||
|
packet.toPublicKey = to
|
||||||
|
packet.messageId = messageId
|
||||||
|
packet.timestamp = timestamp
|
||||||
|
packet.attachments = attachments
|
||||||
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
|
packet,
|
||||||
|
myPublicKey: account,
|
||||||
|
decryptedText: text,
|
||||||
|
dialogIdentityOverride: dialogIdentity
|
||||||
|
)
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogIdentity)
|
||||||
|
|
||||||
|
case .outgoing(let opponent, let messageId, let timestamp, let text, let attachments):
|
||||||
|
var packet = PacketMessage()
|
||||||
|
packet.fromPublicKey = account
|
||||||
|
packet.toPublicKey = opponent
|
||||||
|
packet.messageId = messageId
|
||||||
|
packet.timestamp = timestamp
|
||||||
|
packet.attachments = attachments
|
||||||
|
MessageRepository.shared.upsertFromMessagePacket(packet, myPublicKey: account, decryptedText: text)
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent)
|
||||||
|
|
||||||
|
case .markDelivered(let opponent, let messageId):
|
||||||
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent)
|
||||||
|
|
||||||
|
case .markOutgoingRead(let opponent):
|
||||||
|
MessageRepository.shared.markOutgoingAsRead(opponentKey: opponent, myPublicKey: account)
|
||||||
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponent)
|
||||||
|
|
||||||
|
case .applyReadPacket(let from, let to):
|
||||||
|
let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !fromKey.isEmpty, !toKey.isEmpty else { continue }
|
||||||
|
|
||||||
|
if DatabaseManager.isGroupDialogKey(toKey) {
|
||||||
|
let dialogIdentity = normalizeGroupDialogKey(toKey)
|
||||||
|
if fromKey == account {
|
||||||
|
MessageRepository.shared.markIncomingAsRead(opponentKey: dialogIdentity, myPublicKey: account)
|
||||||
|
DialogRepository.shared.markAsRead(opponentKey: dialogIdentity)
|
||||||
|
} else {
|
||||||
|
MessageRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity, myPublicKey: account)
|
||||||
|
DialogRepository.shared.markOutgoingAsRead(opponentKey: dialogIdentity)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if fromKey == account {
|
||||||
|
MessageRepository.shared.markIncomingAsRead(opponentKey: toKey, myPublicKey: account)
|
||||||
|
DialogRepository.shared.markAsRead(opponentKey: toKey)
|
||||||
|
} else if toKey == account {
|
||||||
|
MessageRepository.shared.markOutgoingAsRead(opponentKey: fromKey, myPublicKey: account)
|
||||||
|
DialogRepository.shared.markOutgoingAsRead(opponentKey: fromKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .saveSyncCursor(let timestamp):
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: account, timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
RosettaTests/MigrationHarnessTests.swift
Normal file
85
RosettaTests/MigrationHarnessTests.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MigrationHarnessTests: XCTestCase {
|
||||||
|
private var ctx: DBTestContext!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
ctx = DBTestContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
ctx.teardown()
|
||||||
|
ctx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacySyncOnlyMigrationReconcilesWithoutSQLiteUpsertSyntaxFailure() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
DatabaseManager.shared.close()
|
||||||
|
let sqlite = try ctx.openSQLite()
|
||||||
|
let rerunMigrations = DatabaseManager.migrationIdentifiers.dropFirst(3)
|
||||||
|
let deleteList = rerunMigrations.map { "'\($0)'" }.joined(separator: ",")
|
||||||
|
try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier IN (\(deleteList))")
|
||||||
|
try sqlite.execute("DROP TABLE IF EXISTS accounts_sync_times")
|
||||||
|
try sqlite.execute("DELETE FROM sync_cursors")
|
||||||
|
try sqlite.execute("INSERT INTO sync_cursors(account, timestamp) VALUES ('\(ctx.account)', 1234567890123)")
|
||||||
|
|
||||||
|
try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account)
|
||||||
|
let cursor = DatabaseManager.shared.loadSyncCursor(account: ctx.account)
|
||||||
|
XCTAssertEqual(cursor, 1_234_567_890_123)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPartialReconcileBackfillsNullIds() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 9_001)
|
||||||
|
|
||||||
|
DatabaseManager.shared.close()
|
||||||
|
let sqlite = try ctx.openSQLite()
|
||||||
|
try sqlite.execute("UPDATE accounts_sync_times SET id = NULL WHERE account = '\(ctx.account)'")
|
||||||
|
try sqlite.execute("DELETE FROM grdb_migrations WHERE identifier = '\(DatabaseManager.migrationV7SyncCursorReconcile)'")
|
||||||
|
|
||||||
|
try DatabaseManager.shared.bootstrap(accountPublicKey: ctx.account)
|
||||||
|
|
||||||
|
let check = try ctx.openSQLite()
|
||||||
|
let rows = try check.query(
|
||||||
|
"SELECT id, last_sync FROM accounts_sync_times WHERE account = ? LIMIT 1",
|
||||||
|
[.text(ctx.account)]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(rows.count, 1)
|
||||||
|
XCTAssertNotEqual(rows.first?["id"], "")
|
||||||
|
XCTAssertNotEqual(rows.first?["id"], "0")
|
||||||
|
XCTAssertEqual(rows.first?["last_sync"], "9001")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMonotonicSyncCursorNeverDecreases() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_005_000)
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_004_999)
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 1_700_000_006_500)
|
||||||
|
|
||||||
|
XCTAssertEqual(DatabaseManager.shared.loadSyncCursor(account: ctx.account), 1_700_000_006_500)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompatibilityMirrorWritesAccountsSyncTimesAndSyncCursors() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
DatabaseManager.shared.saveSyncCursor(account: ctx.account, timestamp: 77_777)
|
||||||
|
|
||||||
|
let sqlite = try ctx.openSQLite()
|
||||||
|
let accountsRows = try sqlite.query(
|
||||||
|
"SELECT last_sync, id FROM accounts_sync_times WHERE account = ? LIMIT 1",
|
||||||
|
[.text(ctx.account)]
|
||||||
|
)
|
||||||
|
let legacyRows = try sqlite.query(
|
||||||
|
"SELECT timestamp FROM sync_cursors WHERE account = ? LIMIT 1",
|
||||||
|
[.text(ctx.account)]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(accountsRows.first?["last_sync"], "77777")
|
||||||
|
XCTAssertNotEqual(accountsRows.first?["id"], "")
|
||||||
|
XCTAssertNotEqual(accountsRows.first?["id"], "0")
|
||||||
|
XCTAssertEqual(legacyRows.first?["timestamp"], "77777")
|
||||||
|
}
|
||||||
|
}
|
||||||
191
RosettaTests/SchemaParityTests.swift
Normal file
191
RosettaTests/SchemaParityTests.swift
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SchemaParityTests: XCTestCase {
|
||||||
|
private var ctx: DBTestContext!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
ctx = DBTestContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
ctx.teardown()
|
||||||
|
ctx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSchemaContainsRequiredTablesColumnsAndIndexes() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
let schema = try ctx.schemaSnapshot()
|
||||||
|
|
||||||
|
let requiredTables: Set<String> = [
|
||||||
|
"messages",
|
||||||
|
"dialogs",
|
||||||
|
"accounts_sync_times",
|
||||||
|
"sync_cursors",
|
||||||
|
"groups",
|
||||||
|
"pinned_messages",
|
||||||
|
"avatar_cache",
|
||||||
|
"blacklist",
|
||||||
|
]
|
||||||
|
XCTAssertTrue(requiredTables.isSubset(of: schema.tables), "Missing required tables")
|
||||||
|
|
||||||
|
let messagesColumns = schema.columnsByTable["messages"] ?? []
|
||||||
|
let requiredMessageColumns: Set<String> = [
|
||||||
|
"account", "from_public_key", "to_public_key", "message_id", "dialog_key",
|
||||||
|
"timestamp", "is_read", "read", "delivery_status", "delivered", "text", "plain_message",
|
||||||
|
"attachments", "reply_to_message_id",
|
||||||
|
]
|
||||||
|
XCTAssertTrue(requiredMessageColumns.isSubset(of: messagesColumns), "Missing messages columns")
|
||||||
|
|
||||||
|
let dialogsColumns = schema.columnsByTable["dialogs"] ?? []
|
||||||
|
let requiredDialogColumns: Set<String> = [
|
||||||
|
"account", "opponent_key", "dialog_id", "is_request", "last_timestamp", "last_message_id",
|
||||||
|
"last_message_timestamp", "i_have_sent", "unread_count",
|
||||||
|
]
|
||||||
|
XCTAssertTrue(requiredDialogColumns.isSubset(of: dialogsColumns), "Missing dialogs columns")
|
||||||
|
|
||||||
|
let requiredIndexes: Set<String> = [
|
||||||
|
"idx_messages_account_message_id",
|
||||||
|
"idx_messages_account_dialog_key_timestamp",
|
||||||
|
"idx_messages_account_dialog_fromme_isread",
|
||||||
|
"idx_messages_account_dialog_fromme_timestamp",
|
||||||
|
"idx_dialogs_account_opponent_key",
|
||||||
|
]
|
||||||
|
XCTAssertTrue(requiredIndexes.isSubset(of: schema.indexes), "Missing required indexes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnreadAndSentQueriesUseParityIndexes() async throws {
|
||||||
|
try await ctx.bootstrap()
|
||||||
|
try await ctx.runScenario(FixtureScenario(name: "seed", events: [
|
||||||
|
.incoming(opponent: "02peer_a", messageId: "m1", timestamp: 1, text: "hello"),
|
||||||
|
.outgoing(opponent: "02peer_a", messageId: "m2", timestamp: 2, text: "yo"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let sqlite = try ctx.openSQLite()
|
||||||
|
let unreadPlanRows = try sqlite.query(
|
||||||
|
"""
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT COUNT(*) FROM messages
|
||||||
|
WHERE account = ? AND dialog_key = ? AND from_me = 0 AND is_read = 0
|
||||||
|
""",
|
||||||
|
[.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))]
|
||||||
|
)
|
||||||
|
let unreadPlan = unreadPlanRows.compactMap { $0["detail"] }.joined(separator: " | ")
|
||||||
|
XCTAssertTrue(
|
||||||
|
unreadPlan.contains("idx_messages_account_dialog_fromme_isread"),
|
||||||
|
"Unread query plan did not use idx_messages_account_dialog_fromme_isread: \(unreadPlan)"
|
||||||
|
)
|
||||||
|
|
||||||
|
let sentPlanRows = try sqlite.query(
|
||||||
|
"""
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT message_id FROM messages
|
||||||
|
WHERE account = ? AND dialog_key = ? AND from_me = 1
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
[.text(ctx.account), .text(DatabaseManager.dialogKey(account: ctx.account, opponentKey: "02peer_a"))]
|
||||||
|
)
|
||||||
|
let sentPlan = sentPlanRows.compactMap { $0["detail"] }.joined(separator: " | ")
|
||||||
|
XCTAssertTrue(
|
||||||
|
sentPlan.contains("idx_messages_account_dialog_fromme_timestamp"),
|
||||||
|
"Sent query plan did not use idx_messages_account_dialog_fromme_timestamp: \(sentPlan)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPacketRegistrySupportsMessagingAndGroupsCoreIds() throws {
|
||||||
|
let packets: [(Int, any Packet)] = [
|
||||||
|
(0x00, PacketHandshake()),
|
||||||
|
(0x01, PacketUserInfo()),
|
||||||
|
(0x02, PacketResult()),
|
||||||
|
(0x03, PacketSearch()),
|
||||||
|
(0x04, PacketOnlineSubscribe()),
|
||||||
|
(0x05, PacketOnlineState()),
|
||||||
|
(0x06, PacketMessage()),
|
||||||
|
(0x07, PacketRead()),
|
||||||
|
(0x08, PacketDelivery()),
|
||||||
|
(0x09, PacketDeviceNew()),
|
||||||
|
(0x0A, PacketRequestUpdate()),
|
||||||
|
(0x0B, PacketTyping()),
|
||||||
|
(0x0F, PacketRequestTransport()),
|
||||||
|
(0x10, PacketPushNotification()),
|
||||||
|
(0x11, PacketCreateGroup()),
|
||||||
|
(0x12, PacketGroupInfo()),
|
||||||
|
(0x13, PacketGroupInviteInfo()),
|
||||||
|
(0x14, PacketGroupJoin()),
|
||||||
|
(0x15, PacketGroupLeave()),
|
||||||
|
(0x16, PacketGroupBan()),
|
||||||
|
(0x17, PacketDeviceList()),
|
||||||
|
(0x18, PacketDeviceResolve()),
|
||||||
|
(0x19, PacketSync()),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (expectedId, packet) in packets {
|
||||||
|
let encoded = PacketRegistry.encode(packet)
|
||||||
|
guard let decoded = PacketRegistry.decode(from: encoded) else {
|
||||||
|
XCTFail("Failed to decode packet 0x\(String(expectedId, radix: 16))")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded.packetId, expectedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAttachmentTypeCallRoundTripDecoding() throws {
|
||||||
|
var packet = PacketMessage()
|
||||||
|
packet.fromPublicKey = "02from"
|
||||||
|
packet.toPublicKey = "02to"
|
||||||
|
packet.content = ""
|
||||||
|
packet.chachaKey = ""
|
||||||
|
packet.timestamp = 123
|
||||||
|
packet.privateKey = "hash"
|
||||||
|
packet.messageId = "msg-call"
|
||||||
|
packet.attachments = [MessageAttachment(id: "call-1", preview: "", blob: "", type: .call)]
|
||||||
|
packet.aesChachaKey = ""
|
||||||
|
|
||||||
|
let encoded = PacketRegistry.encode(packet)
|
||||||
|
guard let decoded = PacketRegistry.decode(from: encoded),
|
||||||
|
let decodedMessage = decoded.packet as? PacketMessage
|
||||||
|
else {
|
||||||
|
XCTFail("Failed to decode call attachment packet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.packetId, 0x06)
|
||||||
|
XCTAssertEqual(decodedMessage.attachments.first?.type, .call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSessionPacketContextResolverAcceptsGroupWireShape() throws {
|
||||||
|
let own = "02my_account"
|
||||||
|
|
||||||
|
var groupMessage = PacketMessage()
|
||||||
|
groupMessage.fromPublicKey = "02group_member"
|
||||||
|
groupMessage.toPublicKey = "#group:alpha"
|
||||||
|
let messageContext = SessionManager.testResolveMessagePacketContext(groupMessage, ownKey: own)
|
||||||
|
XCTAssertEqual(messageContext?.kind, "group")
|
||||||
|
XCTAssertEqual(messageContext?.dialogKey, "#group:alpha")
|
||||||
|
XCTAssertEqual(messageContext?.fromMe, false)
|
||||||
|
|
||||||
|
var groupRead = PacketRead()
|
||||||
|
groupRead.fromPublicKey = "02group_member"
|
||||||
|
groupRead.toPublicKey = "#group:alpha"
|
||||||
|
let readContext = SessionManager.testResolveReadPacketContext(groupRead, ownKey: own)
|
||||||
|
XCTAssertEqual(readContext?.kind, "group")
|
||||||
|
XCTAssertEqual(readContext?.dialogKey, "#group:alpha")
|
||||||
|
XCTAssertEqual(readContext?.fromMe, false)
|
||||||
|
|
||||||
|
var groupTyping = PacketTyping()
|
||||||
|
groupTyping.fromPublicKey = "02group_member"
|
||||||
|
groupTyping.toPublicKey = "#group:alpha"
|
||||||
|
let typingContext = SessionManager.testResolveTypingPacketContext(groupTyping, ownKey: own)
|
||||||
|
XCTAssertEqual(typingContext?.kind, "group")
|
||||||
|
XCTAssertEqual(typingContext?.dialogKey, "#group:alpha")
|
||||||
|
XCTAssertEqual(typingContext?.fromMe, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStreamEncodingSmoke() {
|
||||||
|
let stream = Rosetta.Stream()
|
||||||
|
_ = stream
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
RosettaTests/SearchParityTests.swift
Normal file
126
RosettaTests/SearchParityTests.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Rosetta
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SearchParityTests: XCTestCase {
|
||||||
|
func testSearchViewModelAndChatListUseSameQueryNormalization() async {
|
||||||
|
let searchDispatcher = MockSearchDispatcher()
|
||||||
|
let chatDispatcher = MockSearchDispatcher()
|
||||||
|
let searchVM = SearchViewModel(searchDispatcher: searchDispatcher)
|
||||||
|
let chatVM = ChatListViewModel(searchDispatcher: chatDispatcher)
|
||||||
|
|
||||||
|
searchVM.setSearchQuery(" @Alice ")
|
||||||
|
chatVM.setSearchQuery(" @Alice ")
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .milliseconds(1200))
|
||||||
|
|
||||||
|
XCTAssertEqual(searchDispatcher.sentQueries, ["alice"])
|
||||||
|
XCTAssertEqual(chatDispatcher.sentQueries, ["alice"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavedAliasesAndExactPublicKeyFallback() throws {
|
||||||
|
let ownPair = try Self.makeKeyPair()
|
||||||
|
let peerPair = try Self.makeKeyPair()
|
||||||
|
|
||||||
|
let dialog = Self.makeDialog(
|
||||||
|
account: ownPair.publicKeyHex,
|
||||||
|
opponentKey: peerPair.publicKeyHex,
|
||||||
|
username: "peer_user",
|
||||||
|
title: "Peer User"
|
||||||
|
)
|
||||||
|
|
||||||
|
let saved = SearchParityPolicy.localAugmentedUsers(
|
||||||
|
query: "Saved Messages",
|
||||||
|
currentPublicKey: ownPair.publicKeyHex,
|
||||||
|
dialogs: [dialog]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(saved.count, 1)
|
||||||
|
XCTAssertEqual(saved.first?.publicKey, ownPair.publicKeyHex)
|
||||||
|
XCTAssertEqual(saved.first?.title, "Saved Messages")
|
||||||
|
|
||||||
|
let exactPeer = SearchParityPolicy.localAugmentedUsers(
|
||||||
|
query: "0x" + peerPair.publicKeyHex,
|
||||||
|
currentPublicKey: ownPair.publicKeyHex,
|
||||||
|
dialogs: [dialog]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(exactPeer.count, 1)
|
||||||
|
XCTAssertEqual(exactPeer.first?.publicKey, peerPair.publicKeyHex)
|
||||||
|
XCTAssertEqual(exactPeer.first?.username, "peer_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerAndLocalMergeDedupesByPublicKeyWithServerPriority() {
|
||||||
|
let key = "021111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
let localOnlyKey = "022222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
|
||||||
|
let server = [
|
||||||
|
SearchUser(username: "server_u", title: "Server Name", publicKey: key, verified: 2, online: 0),
|
||||||
|
]
|
||||||
|
let local = [
|
||||||
|
SearchUser(username: "local_u", title: "Local Name", publicKey: key, verified: 0, online: 1),
|
||||||
|
SearchUser(username: "local_only", title: "Local Only", publicKey: localOnlyKey, verified: 0, online: 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
let merged = SearchParityPolicy.mergeServerAndLocal(server: server, local: local)
|
||||||
|
XCTAssertEqual(merged.count, 2)
|
||||||
|
XCTAssertEqual(merged[0].publicKey, key)
|
||||||
|
XCTAssertEqual(merged[0].title, "Server Name")
|
||||||
|
XCTAssertEqual(merged[1].publicKey, localOnlyKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SearchParityTests {
|
||||||
|
static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) {
|
||||||
|
let mnemonic = try CryptoManager.shared.generateMnemonic()
|
||||||
|
let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic)
|
||||||
|
return (pair.privateKey.hexString, pair.publicKey.hexString)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeDialog(
|
||||||
|
account: String,
|
||||||
|
opponentKey: String,
|
||||||
|
username: String,
|
||||||
|
title: String
|
||||||
|
) -> Dialog {
|
||||||
|
Dialog(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
account: account,
|
||||||
|
opponentKey: opponentKey,
|
||||||
|
opponentTitle: title,
|
||||||
|
opponentUsername: username,
|
||||||
|
lastMessage: "",
|
||||||
|
lastMessageTimestamp: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeen: 0,
|
||||||
|
verified: 0,
|
||||||
|
iHaveSent: true,
|
||||||
|
isPinned: false,
|
||||||
|
isMuted: false,
|
||||||
|
lastMessageFromMe: false,
|
||||||
|
lastMessageDelivered: .delivered,
|
||||||
|
lastMessageRead: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockSearchDispatcher: SearchResultDispatching {
|
||||||
|
var connectionState: ConnectionState = .authenticated
|
||||||
|
var privateHash: String? = "mock-private-hash"
|
||||||
|
private(set) var sentQueries: [String] = []
|
||||||
|
private var handlers: [UUID: (PacketSearch) -> Void] = [:]
|
||||||
|
|
||||||
|
func sendSearchPacket(_ packet: PacketSearch) {
|
||||||
|
sentQueries.append(packet.search)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
|
||||||
|
let id = UUID()
|
||||||
|
handlers[id] = handler
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSearchResultHandler(_ id: UUID) {
|
||||||
|
handlers.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user