Паритет вложений и поиска на 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
|
||||
desktop
|
||||
server
|
||||
docs
|
||||
Telegram-iOS
|
||||
AGENTS.md
|
||||
|
||||
|
||||
@@ -7,12 +7,19 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
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 */; };
|
||||
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
|
||||
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, ); }; };
|
||||
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.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 */; };
|
||||
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
||||
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
|
||||
@@ -26,6 +33,13 @@
|
||||
remoteGlobalIDString = E47730762E9823BA2D02A197;
|
||||
remoteInfo = RosettaNotificationService;
|
||||
};
|
||||
D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 853F29612F4B50410092AD05;
|
||||
remoteInfo = Rosetta;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -44,10 +58,17 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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; };
|
||||
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; };
|
||||
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; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -59,6 +80,14 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
392BE571D30FB1DDB2423F0D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
806C964D76E024430307C151 /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
853F295F2F4B50410092AD05 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -83,6 +112,19 @@
|
||||
/* End PBXFrameworksBuildPhase 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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -98,6 +140,7 @@
|
||||
853F29632F4B50410092AD05 /* Products */,
|
||||
95676C1A4D239B1FF9E73782 /* Frameworks */,
|
||||
BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */,
|
||||
0D5BD0581AA976925F688CDA /* RosettaTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -106,6 +149,7 @@
|
||||
children = (
|
||||
853F29622F4B50410092AD05 /* Rosetta.app */,
|
||||
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */,
|
||||
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -130,6 +174,24 @@
|
||||
/* End PBXGroup 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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */;
|
||||
@@ -217,11 +279,19 @@
|
||||
targets = (
|
||||
853F29612F4B50410092AD05 /* Rosetta */,
|
||||
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
||||
219188CF4FCBF8E8CF11BEC2 /* RosettaTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
7158F51EC726FE216D7D8374 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
853F29602F4B50410092AD05 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -246,6 +316,19 @@
|
||||
);
|
||||
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 */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -263,6 +346,12 @@
|
||||
target = E47730762E9823BA2D02A197 /* RosettaNotificationService */;
|
||||
targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */;
|
||||
};
|
||||
48C7E25DF2079AC460C3DCE2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = Rosetta;
|
||||
target = 853F29612F4B50410092AD05 /* Rosetta */;
|
||||
targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -522,9 +611,53 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* 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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
@@ -21,14 +21,49 @@
|
||||
ReferencedContainer = "container:Rosetta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</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>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "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>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
|
||||
@@ -7,6 +7,17 @@ import GRDB
|
||||
final class 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 currentAccount: String = ""
|
||||
@@ -175,6 +186,9 @@ final class DatabaseManager {
|
||||
WHERE m.account = dialogs.account
|
||||
AND m.dialog_key = CASE
|
||||
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
|
||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||
END
|
||||
@@ -198,9 +212,22 @@ final class DatabaseManager {
|
||||
if try db.tableExists("sync_cursors") {
|
||||
try db.execute(
|
||||
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
|
||||
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 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
|
||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||
END
|
||||
@@ -421,6 +449,7 @@ final class DatabaseManager {
|
||||
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 'conversation:%' THEN NEW.opponent_key
|
||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||
ELSE NEW.opponent_key || ':' || NEW.account
|
||||
END
|
||||
@@ -435,6 +464,7 @@ final class DatabaseManager {
|
||||
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 'conversation:%' THEN NEW.opponent_key
|
||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||
ELSE NEW.opponent_key || ':' || NEW.account
|
||||
END
|
||||
@@ -462,6 +492,7 @@ final class DatabaseManager {
|
||||
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 'conversation:%' THEN NEW.opponent_key
|
||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||
ELSE NEW.opponent_key || ':' || NEW.account
|
||||
END
|
||||
@@ -476,6 +507,7 @@ final class DatabaseManager {
|
||||
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 'conversation:%' THEN NEW.opponent_key
|
||||
WHEN NEW.account < NEW.opponent_key THEN NEW.account || ':' || NEW.opponent_key
|
||||
ELSE NEW.opponent_key || ':' || NEW.account
|
||||
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)
|
||||
dbPool = pool
|
||||
|
||||
@@ -600,7 +828,7 @@ final class DatabaseManager {
|
||||
|
||||
// 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 baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
@@ -614,7 +842,12 @@ final class DatabaseManager {
|
||||
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)
|
||||
if trimmed.isEmpty { return "anonymous" }
|
||||
return String(trimmed.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" })
|
||||
@@ -622,13 +855,29 @@ final class DatabaseManager {
|
||||
|
||||
// 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.
|
||||
/// Android: `MessageRepository.getDialogKey()` — sorted pair for direct chats.
|
||||
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
|
||||
if account == opponentKey { return account }
|
||||
if normalizedAccount == normalizedOpponent { return normalizedAccount }
|
||||
// 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)
|
||||
@@ -684,10 +933,21 @@ final class DatabaseManager {
|
||||
sql: """
|
||||
INSERT INTO accounts_sync_times (account, last_sync) VALUES (?, ?)
|
||||
ON CONFLICT(account) DO UPDATE SET last_sync = excluded.last_sync
|
||||
""",
|
||||
""",
|
||||
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") {
|
||||
try db.execute(
|
||||
sql: """
|
||||
@@ -709,3 +969,195 @@ final class DatabaseManager {
|
||||
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
|
||||
|
||||
/// 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).
|
||||
nonisolated func saveImage(_ image: UIImage, forAttachmentId id: String) {
|
||||
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
||||
|
||||
@@ -118,6 +118,7 @@ final class DialogRepository {
|
||||
let account = currentAccount
|
||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||
let isSavedMessages = account == opponentKey
|
||||
let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)
|
||||
|
||||
// Preserve fields not derived from messages
|
||||
let existing = dialogs[opponentKey]
|
||||
@@ -163,6 +164,7 @@ final class DialogRepository {
|
||||
case .file: lastMessageText = "File"
|
||||
case .avatar: lastMessageText = "Avatar"
|
||||
case .messages: lastMessageText = "Forwarded message"
|
||||
case .call: lastMessageText = "Call"
|
||||
}
|
||||
} else if textIsEmpty {
|
||||
lastMessageText = ""
|
||||
@@ -187,7 +189,8 @@ final class DialogRepository {
|
||||
dialog.lastMessage = lastMessageText
|
||||
dialog.lastMessageTimestamp = lastMsg.timestamp
|
||||
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.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
|
||||
// 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,
|
||||
verified: Int = 0, myPublicKey: String
|
||||
) {
|
||||
let isGroupDialog = DatabaseManager.isGroupDialogKey(opponentKey)
|
||||
if var existing = dialogs[opponentKey] {
|
||||
var changed = false
|
||||
if !title.isEmpty, existing.opponentTitle != title {
|
||||
@@ -214,6 +218,9 @@ final class DialogRepository {
|
||||
if verified > existing.verified {
|
||||
existing.verified = verified; changed = true
|
||||
}
|
||||
if isGroupDialog, !existing.iHaveSent {
|
||||
existing.iHaveSent = true; changed = true
|
||||
}
|
||||
guard changed else { return }
|
||||
dialogs[opponentKey] = existing
|
||||
persistDialog(existing)
|
||||
@@ -225,7 +232,7 @@ final class DialogRepository {
|
||||
opponentTitle: title, opponentUsername: username,
|
||||
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
||||
isOnline: false, lastSeen: 0, verified: verified,
|
||||
iHaveSent: false, isPinned: false, isMuted: false,
|
||||
iHaveSent: isGroupDialog, isPinned: false, isMuted: false,
|
||||
lastMessageFromMe: false, lastMessageDelivered: .waiting,
|
||||
lastMessageRead: false
|
||||
)
|
||||
|
||||
@@ -280,11 +280,18 @@ final class MessageRepository: ObservableObject {
|
||||
myPublicKey: String,
|
||||
decryptedText: String,
|
||||
attachmentPassword: String? = nil,
|
||||
fromSync: Bool = false
|
||||
fromSync: Bool = false,
|
||||
dialogIdentityOverride: String? = nil
|
||||
) {
|
||||
PerformanceLogger.shared.track("message.upsert")
|
||||
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 timestamp = normalizeTimestamp(packet.timestamp)
|
||||
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 bubbleSize: CGSize // Bubble size (for shape path)
|
||||
let mergeType: BubbleMergeType
|
||||
let hasTail: Bool
|
||||
|
||||
// MARK: - Text
|
||||
@@ -88,6 +89,7 @@ extension MessageCellLayout {
|
||||
let position: BubblePosition
|
||||
let deliveryStatus: DeliveryStatus
|
||||
let text: String
|
||||
let timestampText: String
|
||||
let hasReplyQuote: Bool
|
||||
let replyName: String?
|
||||
let replyText: String?
|
||||
@@ -100,6 +102,36 @@ extension MessageCellLayout {
|
||||
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.
|
||||
/// Uses CoreText for text measurement (thread-safe).
|
||||
/// 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?) {
|
||||
let font = UIFont.systemFont(ofSize: 17, 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 / screenScale
|
||||
|
||||
let hasTail = (config.position == .single || config.position == .bottom)
|
||||
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 screenPixel = 1.0 / max(UIScreen.main.scale, 1)
|
||||
let metrics = BubbleMetrics.telegram()
|
||||
let mergeType = BubbleGeometryEngine.mergeType(for: config.position)
|
||||
let hasTail = BubbleGeometryEngine.hasTail(for: mergeType)
|
||||
let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error
|
||||
let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0
|
||||
let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset)
|
||||
@@ -137,12 +165,29 @@ extension MessageCellLayout {
|
||||
messageType = .text
|
||||
}
|
||||
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) ──
|
||||
let topPad: CGFloat = 6 + screenPixel
|
||||
let bottomPad: CGFloat = 6 - screenPixel
|
||||
let leftPad: CGFloat = 11
|
||||
let rightPad: CGFloat = 11
|
||||
let topPad: CGFloat = metrics.textInsets.top
|
||||
let bottomPad: CGFloat = metrics.textInsets.bottom
|
||||
let leftPad: CGFloat = metrics.textInsets.left
|
||||
let rightPad: CGFloat = metrics.textInsets.right
|
||||
let statusTrailingCompensation: CGFloat = isTextMessage
|
||||
? max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||
: 0
|
||||
|
||||
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
|
||||
// Text is measured at the WIDEST possible constraint.
|
||||
@@ -165,22 +210,21 @@ extension MessageCellLayout {
|
||||
}
|
||||
|
||||
// ── 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 isMediaOnly = config.imageCount > 0 && config.text.isEmpty
|
||||
let isMediaMessage = config.imageCount > 0
|
||||
let statusWidth: CGFloat = hasStatusIcon
|
||||
? floor(floor(font.pointSize * 13.0 / 17.0))
|
||||
? textStatusLaneMetrics.statusWidth
|
||||
: 0
|
||||
let checkW: CGFloat = statusWidth
|
||||
// Telegram date/status lane keeps a wider visual gap before checks.
|
||||
let timeGap: CGFloat = hasStatusIcon ? 5 : 0
|
||||
let statusGap: CGFloat = 2
|
||||
let metadataWidth = tsSize.width + timeGap + checkW
|
||||
let timeToCheckGap: CGFloat = hasStatusIcon
|
||||
? textStatusLaneMetrics.timeToCheckGap
|
||||
: 0
|
||||
let metadataWidth = tsSize.width + timeToCheckGap + checkW
|
||||
|
||||
// ── STEP 3: Inline vs Wrapped determination ──
|
||||
let timestampInline: Bool
|
||||
let trailingWidthForStatus: CGFloat
|
||||
if isTextMessage && !config.text.isEmpty {
|
||||
let trailingWidthForStatus: CGFloat
|
||||
if let cachedTextLayout {
|
||||
if cachedTextLayout.lastLineHasRTL {
|
||||
trailingWidthForStatus = 10_000
|
||||
@@ -192,7 +236,34 @@ extension MessageCellLayout {
|
||||
} else {
|
||||
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 {
|
||||
timestampInline = true
|
||||
}
|
||||
@@ -200,10 +271,16 @@ extension MessageCellLayout {
|
||||
// ── STEP 4: Bubble dimensions (unified width + height) ──
|
||||
|
||||
// Content blocks above the text area
|
||||
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
|
||||
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
|
||||
var photoH: CGFloat = 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 fileH: CGFloat = CGFloat(config.fileCount) * 56
|
||||
@@ -211,30 +288,21 @@ extension MessageCellLayout {
|
||||
// Tiny floor just to prevent zero-width collapse.
|
||||
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
||||
let minW: CGFloat = 40
|
||||
let mediaWidthFraction: CGFloat
|
||||
let mediaAbsoluteCap: CGFloat
|
||||
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))
|
||||
)
|
||||
let mediaBubbleMaxWidth = min(effectiveMaxBubbleWidth, mediaDimensions.maxWidth)
|
||||
let mediaBubbleMinWidth = min(mediaDimensions.minWidth, mediaBubbleMaxWidth)
|
||||
|
||||
var bubbleW: CGFloat
|
||||
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
|
||||
|
||||
if config.imageCount > 0 {
|
||||
// Media bubbles should not stretch edge-to-edge; keep Telegram-like cap.
|
||||
bubbleW = mediaBubbleMaxWidth
|
||||
photoH = Self.collageHeight(count: config.imageCount, width: bubbleW - 8)
|
||||
bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
|
||||
photoH = Self.collageHeight(
|
||||
count: config.imageCount,
|
||||
width: bubbleW - 8,
|
||||
maxHeight: mediaDimensions.maxHeight,
|
||||
minHeight: mediaDimensions.minHeight
|
||||
)
|
||||
bubbleH += photoH
|
||||
if !config.text.isEmpty {
|
||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||
@@ -243,16 +311,18 @@ extension MessageCellLayout {
|
||||
} else if isTextMessage && !config.text.isEmpty {
|
||||
// ── EXACT TELEGRAM MATH — no other modifiers ──
|
||||
let actualTextW = textMeasurement.size.width
|
||||
let lastLineW = textMeasurement.trailingLineWidth
|
||||
|
||||
let finalContentW: CGFloat
|
||||
if timestampInline {
|
||||
// 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
|
||||
} else {
|
||||
// WRAPPED: status drops to new line below text
|
||||
finalContentW = max(actualTextW, metadataWidth)
|
||||
finalContentW = max(actualTextW, wrappedStatusContentWidth)
|
||||
bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
|
||||
}
|
||||
|
||||
@@ -284,6 +354,63 @@ extension MessageCellLayout {
|
||||
|
||||
// ── 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),
|
||||
// NOT textMeasurement.size.width. Using the measured width causes UILabel to
|
||||
// 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 }
|
||||
|
||||
let textFrame = CGRect(x: leftPad, y: textY,
|
||||
width: bubbleW - leftPad - rightPad,
|
||||
height: textMeasurement.size.height)
|
||||
|
||||
// Metadata frames:
|
||||
// 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
|
||||
)
|
||||
if isShortSingleLineText && timestampInline {
|
||||
// Optical centering for short one-line text without inflating bubble height.
|
||||
let maxTextY = tsFrame.minY - textStatusLaneMetrics.textToMetadataGap - textMeasurement.size.height
|
||||
if maxTextY > textY {
|
||||
textY = min(textY + 1.5, maxTextY)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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
|
||||
}
|
||||
let textFrame = CGRect(
|
||||
x: leftPad,
|
||||
y: textY,
|
||||
width: bubbleW - leftPad - rightPad,
|
||||
height: textMeasurement.size.height
|
||||
)
|
||||
|
||||
// Accessory frames (reply, photo, file, forward)
|
||||
let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41)
|
||||
@@ -369,6 +460,7 @@ extension MessageCellLayout {
|
||||
messageType: messageType,
|
||||
bubbleFrame: bubbleFrame,
|
||||
bubbleSize: CGSize(width: bubbleW, height: bubbleH),
|
||||
mergeType: mergeType,
|
||||
hasTail: hasTail,
|
||||
textFrame: textFrame,
|
||||
textSize: textMeasurement.size,
|
||||
@@ -400,26 +492,38 @@ extension MessageCellLayout {
|
||||
// MARK: - Collage Height (Thread-Safe)
|
||||
|
||||
/// 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 }
|
||||
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 {
|
||||
let cellW = (width - 2) / 2
|
||||
return min(cellW * 1.28, 330)
|
||||
return min(max(cellW * 1.28, minHeight), maxHeight)
|
||||
}
|
||||
if count == 3 {
|
||||
let leftW = width * 0.66
|
||||
return min(leftW * 1.16, 330)
|
||||
return min(max(leftW * 1.16, minHeight), maxHeight)
|
||||
}
|
||||
if count == 4 {
|
||||
let cellW = (width - 2) / 2
|
||||
let cellH = min(cellW * 0.85, 160)
|
||||
return cellH * 2 + 2
|
||||
let cellH = min(max(cellW * 0.85, minHeight / 2), maxHeight / 2)
|
||||
return min(max(cellH * 2 + 2, minHeight), maxHeight)
|
||||
}
|
||||
// 5+
|
||||
let topH = min(width / 2 * 0.85, 176)
|
||||
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)
|
||||
@@ -579,9 +683,16 @@ extension MessageCellLayout {
|
||||
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
|
||||
var result: [String: MessageCellLayout] = [:]
|
||||
var textResult: [String: CoreTextTextLayout] = [:]
|
||||
let timestampFormatter = DateFormatter()
|
||||
timestampFormatter.dateFormat = "HH:mm"
|
||||
timestampFormatter.locale = .autoupdatingCurrent
|
||||
timestampFormatter.timeZone = .autoupdatingCurrent
|
||||
|
||||
for (index, message) in messages.enumerated() {
|
||||
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)
|
||||
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||
@@ -633,6 +744,7 @@ extension MessageCellLayout {
|
||||
position: position,
|
||||
deliveryStatus: message.deliveryStatus,
|
||||
text: displayText,
|
||||
timestampText: timestampText,
|
||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||
replyName: nil,
|
||||
replyText: nil,
|
||||
|
||||
@@ -23,9 +23,16 @@ enum PacketRegistry {
|
||||
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() },
|
||||
@@ -61,6 +68,7 @@ enum AttachmentType: Int, Codable {
|
||||
case messages = 1
|
||||
case file = 2
|
||||
case avatar = 3
|
||||
case call = 4
|
||||
}
|
||||
|
||||
struct MessageAttachment: Codable, Equatable {
|
||||
@@ -100,3 +108,145 @@ struct SearchUser {
|
||||
var verified: 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 onSearchResult: ((PacketSearch) -> 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 onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
||||
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
||||
@@ -482,10 +489,38 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
if let p = packet as? PacketDeviceNew {
|
||||
onDeviceNewReceived?(p)
|
||||
}
|
||||
case 0x0A:
|
||||
if let p = packet as? PacketRequestUpdate {
|
||||
onRequestUpdateReceived?(p)
|
||||
}
|
||||
case 0x0B:
|
||||
if let p = packet as? PacketTyping {
|
||||
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:
|
||||
if let p = packet as? PacketRequestTransport {
|
||||
Self.logger.info("📥 Transport server: \(p.transportServer)")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
typealias Stream = PacketBitStream
|
||||
|
||||
/// Bit-aligned binary stream for protocol packets.
|
||||
/// Matches the React Native / Android implementation exactly.
|
||||
final class Stream: @unchecked Sendable {
|
||||
final class PacketBitStream: NSObject {
|
||||
|
||||
private var bytes: [UInt8]
|
||||
private var readPointer: Int = 0
|
||||
@@ -10,19 +12,25 @@ final class Stream: @unchecked Sendable {
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
override init() {
|
||||
bytes = []
|
||||
bytes.reserveCapacity(256)
|
||||
super.init()
|
||||
}
|
||||
|
||||
init(data: Data) {
|
||||
bytes = Array(data)
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Output
|
||||
|
||||
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
|
||||
@@ -37,6 +45,9 @@ final class Stream: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func readBit() -> Int {
|
||||
guard readPointer < bytes.count * 8 else {
|
||||
return 0
|
||||
}
|
||||
let byteIndex = readPointer >> 3
|
||||
let shift = 7 - (readPointer & 7)
|
||||
let bit = (bytes[byteIndex] >> shift) & 1
|
||||
@@ -76,6 +87,11 @@ final class Stream: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func readInt8() -> Int {
|
||||
guard readPointer + 9 <= bytes.count * 8 else {
|
||||
readPointer = bytes.count * 8
|
||||
return 0
|
||||
}
|
||||
|
||||
var value = 0
|
||||
let signShift = 7 - (readPointer & 7)
|
||||
let negationBit = Int((bytes[readPointer >> 3] >> signShift) & 1)
|
||||
@@ -171,6 +187,16 @@ final class Stream: @unchecked Sendable {
|
||||
|
||||
func readBytes() -> Data {
|
||||
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)
|
||||
for _ in 0..<length {
|
||||
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")
|
||||
|
||||
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 currentPublicKey: String = ""
|
||||
private(set) var displayName: String = ""
|
||||
@@ -49,6 +63,8 @@ final class SessionManager {
|
||||
private var pendingOutgoingAttempts: [String: Int] = [:]
|
||||
private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts
|
||||
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
|
||||
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
|
||||
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
|
||||
|
||||
// MARK: - Foreground Detection (Android parity)
|
||||
|
||||
@@ -127,7 +143,12 @@ final class SessionManager {
|
||||
let crypto = CryptoManager.shared
|
||||
|
||||
// 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
|
||||
// Android parity: provide private key to caches for encryption at rest
|
||||
AttachmentCache.shared.privateKey = privateKeyHex
|
||||
@@ -137,13 +158,20 @@ final class SessionManager {
|
||||
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
|
||||
displayName = account.displayName ?? ""
|
||||
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).
|
||||
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
|
||||
accountPublicKey: account.publicKey,
|
||||
@@ -219,14 +247,27 @@ final class SessionManager {
|
||||
privateKeyHex: privKey,
|
||||
privateKeyHash: hash
|
||||
)
|
||||
let targetDialogKey = packet.toPublicKey
|
||||
|
||||
// Prefer caller-provided title/username (from ChatDetailView route),
|
||||
// fall back to existing dialog data, then empty.
|
||||
let existingDialog = DialogRepository.shared.dialogs[toPublicKey]
|
||||
let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "")
|
||||
let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "")
|
||||
let existingDialog = DialogRepository.shared.dialogs[targetDialogKey]
|
||||
let groupMetadata = GroupRepository.shared.groupMetadata(
|
||||
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(
|
||||
opponentKey: toPublicKey,
|
||||
opponentKey: targetDialogKey,
|
||||
title: title,
|
||||
username: username,
|
||||
myPublicKey: currentPublicKey
|
||||
@@ -241,9 +282,10 @@ final class SessionManager {
|
||||
packet,
|
||||
myPublicKey: currentPublicKey,
|
||||
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.
|
||||
// Without this, if app is killed within 800ms debounce window,
|
||||
@@ -252,9 +294,9 @@ final class SessionManager {
|
||||
MessageRepository.shared.persistNow()
|
||||
|
||||
// Saved Messages: local-only, no server send
|
||||
if toPublicKey == currentPublicKey {
|
||||
if targetDialogKey == currentPublicKey {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -368,7 +410,7 @@ final class SessionManager {
|
||||
// Upload encrypted blob to transport server in background (desktop: uploadFile)
|
||||
let tag: String
|
||||
do {
|
||||
tag = try await TransportManager.shared.uploadFile(
|
||||
tag = try await attachmentFlowTransport.uploadFile(
|
||||
id: attachmentId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
@@ -398,12 +440,12 @@ final class SessionManager {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||
// Send to server for multi-device sync (unlike text Saved Messages)
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
packetFlowSender.sendPacket(packet)
|
||||
Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)")
|
||||
return
|
||||
}
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
packetFlowSender.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
||||
@@ -595,18 +637,32 @@ final class SessionManager {
|
||||
MessageRepository.shared.persistNow()
|
||||
|
||||
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)
|
||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Phase 2: Upload in background, then send packet ──
|
||||
let flowTransport = attachmentFlowTransport
|
||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||
of: (Int, String).self
|
||||
) { group in
|
||||
for (index, item) in encryptedAttachments.enumerated() {
|
||||
group.addTask {
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
let tag = try await flowTransport.uploadFile(
|
||||
id: item.original.id, content: item.encryptedData
|
||||
)
|
||||
return (index, tag)
|
||||
@@ -616,7 +672,7 @@ final class SessionManager {
|
||||
for try await (index, tag) in group { tags[index] = tag }
|
||||
return encryptedAttachments.enumerated().map { index, item in
|
||||
let tag = tags[index] ?? ""
|
||||
let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)"
|
||||
let preview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: item.preview)
|
||||
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)")
|
||||
return MessageAttachment(id: item.original.id, preview: preview, blob: "", type: item.original.type)
|
||||
}
|
||||
@@ -629,7 +685,7 @@ final class SessionManager {
|
||||
var packet = optimisticPacket
|
||||
packet.attachments = messageAttachments
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
packetFlowSender.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
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)")
|
||||
#endif
|
||||
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
let tag = try await attachmentFlowTransport.uploadFile(
|
||||
id: newAttId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
@@ -738,14 +794,8 @@ final class SessionManager {
|
||||
let originalPreview = replyMessages
|
||||
.flatMap { $0.attachments }
|
||||
.first(where: { $0.id == originalId })?.preview ?? ""
|
||||
let blurhash: String
|
||||
if let range = originalPreview.range(of: "::") {
|
||||
blurhash = String(originalPreview[range.upperBound...])
|
||||
} else {
|
||||
blurhash = ""
|
||||
}
|
||||
|
||||
let newPreview = "\(tag)::\(blurhash)"
|
||||
let blurhash = AttachmentPreviewCodec.blurHash(from: originalPreview)
|
||||
let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: blurhash)
|
||||
attachmentIdMap[originalId] = (newAttId, newPreview)
|
||||
|
||||
// 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))")
|
||||
#endif
|
||||
|
||||
let tag = try await TransportManager.shared.uploadFile(
|
||||
let tag = try await attachmentFlowTransport.uploadFile(
|
||||
id: newAttId,
|
||||
content: Data(encryptedBlob.utf8)
|
||||
)
|
||||
@@ -786,14 +836,13 @@ final class SessionManager {
|
||||
let originalPreview = replyMessages
|
||||
.flatMap { $0.attachments }
|
||||
.first(where: { $0.id == originalId })?.preview ?? ""
|
||||
let fileMeta: String
|
||||
if let range = originalPreview.range(of: "::") {
|
||||
fileMeta = String(originalPreview[range.upperBound...])
|
||||
} else {
|
||||
fileMeta = "\(fileInfo.data.count)::\(fileInfo.fileName)"
|
||||
}
|
||||
|
||||
let newPreview = "\(tag)::\(fileMeta)"
|
||||
let filePreview = AttachmentPreviewCodec.parseFilePreview(
|
||||
originalPreview,
|
||||
fallbackFileName: fileInfo.fileName,
|
||||
fallbackFileSize: fileInfo.data.count
|
||||
)
|
||||
let fileMeta = "\(filePreview.fileSize)::\(filePreview.fileName)"
|
||||
let newPreview = AttachmentPreviewCodec.compose(downloadTag: tag, payload: fileMeta)
|
||||
attachmentIdMap[originalId] = (newAttId, newPreview)
|
||||
|
||||
#if DEBUG
|
||||
@@ -927,7 +976,7 @@ final class SessionManager {
|
||||
return
|
||||
}
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
packetFlowSender.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
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).
|
||||
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,
|
||||
ProtocolManager.shared.connectionState == .authenticated
|
||||
else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
|
||||
let lastSent = lastTypingSentAt[normalized] ?? 0
|
||||
if now - lastSent < ProtocolConstants.typingThrottleMs {
|
||||
return
|
||||
}
|
||||
lastTypingSentAt[toPublicKey] = now
|
||||
lastTypingSentAt[normalized] = now
|
||||
|
||||
var packet = PacketTyping()
|
||||
packet.privateKey = hash
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
packet.toPublicKey = normalized
|
||||
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
|
||||
/// message timestamp > last sent read receipt timestamp. Retries once after 2s.
|
||||
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
|
||||
guard normalized != currentPublicKey,
|
||||
!normalized.isEmpty,
|
||||
@@ -1070,21 +1127,17 @@ final class SessionManager {
|
||||
proto.onReadReceived = { [weak self] packet in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
guard Self.isSupportedDirectReadPacket(packet, ownKey: self.currentPublicKey) else {
|
||||
guard let context = Self.resolveReadPacketContext(packet, ownKey: self.currentPublicKey) else {
|
||||
Self.logger.debug(
|
||||
"Skipping unsupported read packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let fromKey = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toKey = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let ownKey = self.currentPublicKey
|
||||
let isOwnReadSync = fromKey == ownKey
|
||||
let opponentKey = isOwnReadSync ? toKey : fromKey
|
||||
guard !opponentKey.isEmpty else { return }
|
||||
let opponentKey = context.dialogKey
|
||||
|
||||
if isOwnReadSync {
|
||||
if context.fromMe {
|
||||
// Android parity: read sync from another own device means
|
||||
// incoming messages in this dialog should become read locally.
|
||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||
@@ -1127,8 +1180,11 @@ final class SessionManager {
|
||||
proto.onTypingReceived = { [weak self] packet in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
guard packet.toPublicKey == self.currentPublicKey else { return }
|
||||
MessageRepository.shared.markTyping(from: packet.fromPublicKey)
|
||||
guard let context = Self.resolveTypingPacketContext(packet, ownKey: self.currentPublicKey) else {
|
||||
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
|
||||
Task { @MainActor in
|
||||
self?.handleDeviceNewLogin(packet)
|
||||
@@ -1365,14 +1482,26 @@ final class SessionManager {
|
||||
let currentPrivateKeyHex = self.privateKeyHex
|
||||
let currentPrivateKeyHash = self.privateKeyHash
|
||||
|
||||
let fromMe = packet.fromPublicKey == myKey
|
||||
|
||||
guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else {
|
||||
guard let context = Self.resolveMessagePacketContext(packet, ownKey: myKey) else {
|
||||
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 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 ──
|
||||
// decryptIncomingMessage (ECDH + XChaCha20) and attachment blob
|
||||
@@ -1382,7 +1511,8 @@ final class SessionManager {
|
||||
Self.decryptAndProcessAttachments(
|
||||
packet: packet,
|
||||
myPublicKey: myKey,
|
||||
privateKeyHex: currentPrivateKeyHex
|
||||
privateKeyHex: currentPrivateKeyHex,
|
||||
groupKey: groupKey
|
||||
)
|
||||
}.value
|
||||
|
||||
@@ -1409,22 +1539,32 @@ final class SessionManager {
|
||||
myPublicKey: myKey,
|
||||
decryptedText: text,
|
||||
attachmentPassword: resolvedAttachmentPassword,
|
||||
fromSync: effectiveFromSync
|
||||
fromSync: effectiveFromSync,
|
||||
dialogIdentityOverride: opponentKey
|
||||
)
|
||||
// Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||
// Full recalculation of lastMessage, unread, iHaveSent, delivery from DB.
|
||||
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),
|
||||
// they are clearly online — update their online status immediately.
|
||||
// This supplements PacketOnlineState (0x05) which may arrive with delay.
|
||||
if !fromMe && !effectiveFromSync {
|
||||
if !fromMe && !effectiveFromSync && !isGroupDialog {
|
||||
DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
|
||||
}
|
||||
|
||||
let dialog = DialogRepository.shared.dialogs[opponentKey]
|
||||
|
||||
if dialog?.opponentTitle.isEmpty == true {
|
||||
if !isGroupDialog, dialog?.opponentTitle.isEmpty == true {
|
||||
requestUserInfoIfNeeded(opponentKey: opponentKey, privateKeyHash: currentPrivateKeyHash)
|
||||
}
|
||||
|
||||
@@ -1585,12 +1725,14 @@ final class SessionManager {
|
||||
nonisolated private static func decryptAndProcessAttachments(
|
||||
packet: PacketMessage,
|
||||
myPublicKey: String,
|
||||
privateKeyHex: String?
|
||||
privateKeyHex: String?,
|
||||
groupKey: String?
|
||||
) -> IncomingCryptoResult? {
|
||||
guard let result = decryptIncomingMessage(
|
||||
packet: packet,
|
||||
myPublicKey: myPublicKey,
|
||||
privateKeyHex: privateKeyHex
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupKey: groupKey
|
||||
) else { return nil }
|
||||
|
||||
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(
|
||||
@@ -1641,10 +1792,25 @@ final class SessionManager {
|
||||
nonisolated private static func decryptIncomingMessage(
|
||||
packet: PacketMessage,
|
||||
myPublicKey: String,
|
||||
privateKeyHex: String?
|
||||
privateKeyHex: String?,
|
||||
groupKey: String?
|
||||
) -> (text: String, rawKeyData: Data?)? {
|
||||
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 {
|
||||
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 {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if DatabaseManager.isGroupDialogKey(normalized) { return false }
|
||||
return normalized.hasPrefix("#")
|
||||
|| normalized.hasPrefix("group:")
|
||||
|| normalized.hasPrefix("conversation:")
|
||||
}
|
||||
|
||||
private static func isSupportedDirectPeerKey(_ peerKey: String, ownKey: String) -> Bool {
|
||||
@@ -1705,34 +1895,117 @@ final class SessionManager {
|
||||
return !isUnsupportedDialogKey(normalized)
|
||||
}
|
||||
|
||||
private static func isSupportedDirectMessagePacket(_ packet: PacketMessage, ownKey: String) -> Bool {
|
||||
if ownKey.isEmpty { return false }
|
||||
let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if from.isEmpty || to.isEmpty { return false }
|
||||
private static func resolveDialogContext(from: String, to: String, ownKey: String) -> PacketDialogContext? {
|
||||
if ownKey.isEmpty { return nil }
|
||||
let fromKey = from.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toKey = to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if fromKey.isEmpty || toKey.isEmpty { return nil }
|
||||
|
||||
if from == ownKey {
|
||||
return isSupportedDirectPeerKey(to, ownKey: ownKey)
|
||||
if DatabaseManager.isGroupDialogKey(toKey) {
|
||||
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 {
|
||||
if ownKey.isEmpty { return false }
|
||||
let from = packet.fromPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let to = packet.toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if from.isEmpty || to.isEmpty { return false }
|
||||
private static func resolveMessagePacketContext(_ packet: PacketMessage, ownKey: String) -> PacketDialogContext? {
|
||||
resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey)
|
||||
}
|
||||
|
||||
if from == ownKey {
|
||||
return isSupportedDirectPeerKey(to, ownKey: ownKey)
|
||||
private static func resolveReadPacketContext(_ packet: PacketRead, ownKey: String) -> PacketDialogContext? {
|
||||
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 isSupportedDirectPeerKey(from, ownKey: ownKey)
|
||||
return (kind, context.dialogKey, context.fromMe)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1744,6 +2017,7 @@ final class SessionManager {
|
||||
guard let privateKeyHash else { return }
|
||||
let normalized = opponentKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return }
|
||||
guard !DatabaseManager.isGroupDialogKey(normalized) else { return }
|
||||
guard !requestedUserInfoKeys.contains(normalized) else { return }
|
||||
|
||||
requestedUserInfoKeys.insert(normalized)
|
||||
@@ -1769,6 +2043,7 @@ final class SessionManager {
|
||||
var hasName: [String] = []
|
||||
for (key, dialog) in dialogs {
|
||||
guard key != ownKey, !key.isEmpty else { continue }
|
||||
guard !DatabaseManager.isGroupDialogKey(key) else { continue }
|
||||
if dialog.opponentTitle.isEmpty {
|
||||
missingName.append(key)
|
||||
} else {
|
||||
@@ -1863,9 +2138,37 @@ final class SessionManager {
|
||||
privateKeyHex: String,
|
||||
privateKeyHash: String
|
||||
) 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(
|
||||
plaintext: text,
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
recipientPublicKeyHex: normalizedTarget
|
||||
)
|
||||
|
||||
guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else {
|
||||
@@ -1879,7 +2182,7 @@ final class SessionManager {
|
||||
|
||||
var packet = PacketMessage()
|
||||
packet.fromPublicKey = currentPublicKey
|
||||
packet.toPublicKey = toPublicKey
|
||||
packet.toPublicKey = normalizedTarget
|
||||
packet.content = encrypted.content
|
||||
packet.chachaKey = encrypted.chachaKey
|
||||
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()
|
||||
} catch let sessionError as SessionManager.StartSessionError {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = unlockMessage(for: sessionError)
|
||||
}
|
||||
isUnlocking = false
|
||||
} catch {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = "Wrong password. Please try again."
|
||||
errorMessage = unlockMessage(for: error)
|
||||
}
|
||||
isUnlocking = false
|
||||
}
|
||||
@@ -398,11 +403,23 @@ private extension UnlockView {
|
||||
} catch {
|
||||
// SessionManager.startSession failed — stored password might be wrong
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
errorMessage = "Wrong password. Please try again."
|
||||
errorMessage = unlockMessage(for: error)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,224 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Bubble Position
|
||||
|
||||
enum BubblePosition: Sendable, Equatable {
|
||||
case single, top, mid, bottom
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let position: BubblePosition
|
||||
let outgoing: Bool
|
||||
let hasTail: Bool
|
||||
|
||||
/// How far the tail protrudes beyond the bubble body edge (points).
|
||||
static let tailProtrusion: CGFloat = 6
|
||||
private let mergeType: BubbleMergeType
|
||||
private let metrics: BubbleMetrics
|
||||
|
||||
init(position: BubblePosition, outgoing: Bool) {
|
||||
self.position = position
|
||||
self.outgoing = outgoing
|
||||
switch position {
|
||||
case .single, .bottom: self.hasTail = true
|
||||
case .top, .mid: self.hasTail = false
|
||||
}
|
||||
self.mergeType = BubbleGeometryEngine.mergeType(for: position)
|
||||
self.metrics = .telegram()
|
||||
}
|
||||
|
||||
static var tailProtrusion: CGFloat {
|
||||
BubbleMetrics.telegram().tailProtrusion
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var p = Path()
|
||||
|
||||
// Body rect: inset on the near side when tail is present
|
||||
let bodyRect: CGRect
|
||||
if hasTail {
|
||||
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()
|
||||
}
|
||||
BubbleGeometryEngine.makeSwiftUIPath(
|
||||
in: rect,
|
||||
mergeType: mergeType,
|
||||
outgoing: outgoing,
|
||||
metrics: metrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,21 @@ struct ChatDetailView: View {
|
||||
}
|
||||
|
||||
private var maxBubbleWidth: CGFloat {
|
||||
let w = UIScreen.main.bounds.width
|
||||
if w <= 500 {
|
||||
return max(224, min(w * 0.72, w - 104))
|
||||
}
|
||||
return min(w * 0.66, 460)
|
||||
let screenWidth = UIScreen.main.bounds.width
|
||||
let listHorizontalInsets: CGFloat = 20 // NativeMessageList section insets: leading/trailing 10
|
||||
let bubbleHorizontalMargins: CGFloat = 16 // 8pt left + 8pt right bubble lane reserves
|
||||
let availableWidth = max(40, screenWidth - listHorizontalInsets - bubbleHorizontalMargins)
|
||||
|
||||
// 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.
|
||||
@@ -1096,8 +1106,8 @@ private extension ChatDetailView {
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !caption.isEmpty { return caption }
|
||||
let parts = file.preview.components(separatedBy: "::")
|
||||
if parts.count >= 3 { return parts[2] }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
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 }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !caption.isEmpty { return caption }
|
||||
// Parse filename from preview (tag::fileSize::fileName)
|
||||
let parts = file.preview.components(separatedBy: "::")
|
||||
if parts.count >= 3 { return parts[2] }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
@@ -1261,7 +1270,7 @@ private extension ChatDetailView {
|
||||
for att in replyData.attachments {
|
||||
if att.type == AttachmentType.image.rawValue {
|
||||
// ── 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
|
||||
let jpegData = await Task.detached(priority: .userInitiated) {
|
||||
image.jpegData(compressionQuality: 0.85)
|
||||
@@ -1269,14 +1278,35 @@ private extension ChatDetailView {
|
||||
if let jpegData {
|
||||
forwardedImages[att.id] = jpegData
|
||||
#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
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
#if DEBUG
|
||||
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 {
|
||||
// ── File re-upload (Desktop parity: prepareAttachmentsToSend) ──
|
||||
let parts = att.preview.components(separatedBy: "::")
|
||||
let fileName = parts.count > 2 ? parts[2] : "file"
|
||||
let parsedFile = AttachmentPreviewCodec.parseFilePreview(
|
||||
att.preview,
|
||||
fallbackFileName: "file"
|
||||
)
|
||||
let fileName = parsedFile.fileName
|
||||
|
||||
// Try local cache first
|
||||
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
|
||||
let cdnTag = parts.first ?? ""
|
||||
let cdnTag = parsedFile.downloadTag
|
||||
guard !cdnTag.isEmpty else {
|
||||
#if DEBUG
|
||||
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)
|
||||
}
|
||||
|
||||
// Check cache immediately — image may already be there.
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
// Fast path: memory cache only (no disk/crypto on UI path).
|
||||
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
|
||||
// Retry: the original MessageImageView may still be downloading.
|
||||
// Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations.
|
||||
// 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: original MessageImageView may still be downloading.
|
||||
for _ in 0..<5 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = img
|
||||
return
|
||||
}
|
||||
@@ -1968,8 +2011,7 @@ struct ForwardedImagePreviewCell: View {
|
||||
|
||||
private func extractBlurHash() -> String? {
|
||||
guard !attachment.preview.isEmpty else { return nil }
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : attachment.preview
|
||||
let hash = AttachmentPreviewCodec.blurHash(from: attachment.preview)
|
||||
return hash.isEmpty ? nil : hash
|
||||
}
|
||||
}
|
||||
@@ -2003,16 +2045,28 @@ struct ReplyQuoteThumbnail: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Check AttachmentCache for the actual downloaded photo.
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
// Fast path: memory cache only.
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
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 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
if Task.isCancelled { return }
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
cachedImage = cached
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,21 +169,33 @@ struct FullScreenImageViewer: View {
|
||||
struct FullScreenImageFromCache: View {
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
@State private var image: UIImage?
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) {
|
||||
if let image {
|
||||
FullScreenImageViewer(image: image, onDismiss: onDismiss)
|
||||
} else {
|
||||
// Cache miss — show error with close button
|
||||
// Cache miss/loading state — show placeholder with close button.
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
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))
|
||||
if isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
Text("Loading...")
|
||||
.font(.system(size: 15))
|
||||
.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 {
|
||||
HStack {
|
||||
@@ -204,6 +216,21 @@ struct FullScreenImageFromCache: View {
|
||||
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] {
|
||||
let i = index + offset
|
||||
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.
|
||||
/// Format: "tag::blurhash" → returns "blurhash".
|
||||
private func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
AttachmentPreviewCodec.blurHash(from: preview)
|
||||
}
|
||||
|
||||
// MARK: - Download
|
||||
|
||||
private func loadFromCache() async {
|
||||
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
avatarImage = cached
|
||||
showAvatar = true // No animation for cached — show immediately
|
||||
return
|
||||
@@ -322,7 +321,6 @@ struct MessageAvatarView: View {
|
||||
/// Extracts the server tag from preview string.
|
||||
/// Format: "tag::blurhash" → returns "tag".
|
||||
private func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ struct MessageCellView: View, Equatable {
|
||||
if hasCaption { return reply.message }
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first {
|
||||
let parts = file.preview.components(separatedBy: "::")
|
||||
if parts.count > 2 { return parts[2] }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
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 blurHash: String? = {
|
||||
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
|
||||
let parts = att.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : att.preview
|
||||
let hash = AttachmentPreviewCodec.blurHash(from: att.preview)
|
||||
return hash.isEmpty ? nil : hash
|
||||
}()
|
||||
|
||||
@@ -628,8 +627,8 @@ struct MessageCellView: View, Equatable {
|
||||
@ViewBuilder
|
||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||
let filename: String = {
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
if parts.count > 2 { return parts[2] }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return attachment.id.isEmpty ? "File" : attachment.id
|
||||
}()
|
||||
HStack(spacing: 8) {
|
||||
|
||||
@@ -93,11 +93,8 @@ struct MessageFileView: View {
|
||||
|
||||
/// Parses "tag::filesize::filename" preview format.
|
||||
private var fileMetadata: (tag: String, size: Int, name: String) {
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
let tag = parts.first ?? ""
|
||||
let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
|
||||
let name = parts.count > 2 ? parts[2] : "file"
|
||||
return (tag, size, name)
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
|
||||
return (parsed.downloadTag, parsed.fileSize, parsed.fileName)
|
||||
}
|
||||
|
||||
private var fileName: String { fileMetadata.name }
|
||||
|
||||
@@ -230,8 +230,8 @@ struct MessageImageView: View {
|
||||
|
||||
private func loadFromCache() async {
|
||||
PerformanceLogger.shared.track("image.cacheLoad")
|
||||
// Fast path: NSCache hit (synchronous, sub-microsecond)
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
|
||||
image = cached
|
||||
return
|
||||
}
|
||||
@@ -331,12 +331,10 @@ struct MessageImageView: View {
|
||||
// MARK: - Preview Parsing
|
||||
|
||||
private func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||
}
|
||||
|
||||
private func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
AttachmentPreviewCodec.blurHash(from: preview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,110 +23,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
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"
|
||||
|
||||
// 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)
|
||||
private static let outgoingCheckColor = UIColor.white
|
||||
private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
|
||||
private static let mediaMetaColor = UIColor.white
|
||||
private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor)
|
||||
private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor)
|
||||
private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor)
|
||||
private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor)
|
||||
private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor)
|
||||
private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor)
|
||||
private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
|
||||
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
|
||||
private static let errorIcon = generateErrorIcon(color: .systemRed)
|
||||
private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
|
||||
private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
|
||||
private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor)
|
||||
private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor)
|
||||
private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor)
|
||||
private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor)
|
||||
private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor)
|
||||
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
|
||||
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
|
||||
private static let maxVisiblePhotoTiles = 5
|
||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
@@ -197,6 +110,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
private var totalPhotoAttachmentCount = 0
|
||||
private var photoLoadTasks: [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 failedAttachmentIds: Set<String> = []
|
||||
|
||||
@@ -394,7 +308,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
self.actions = actions
|
||||
|
||||
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.
|
||||
// Same CTTypesetter pipeline → identical line breaks, zero recomputation.
|
||||
@@ -482,7 +399,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
if let layout = currentLayout, layout.hasFile {
|
||||
fileContainer.isHidden = false
|
||||
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 = ""
|
||||
} else {
|
||||
fileContainer.isHidden = true
|
||||
@@ -502,14 +423,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
guard let layout = currentLayout else { return }
|
||||
|
||||
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
|
||||
let bubbleX: CGFloat
|
||||
if layout.isOutgoing {
|
||||
bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset
|
||||
bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
|
||||
} else {
|
||||
bubbleX = 6 + 2
|
||||
bubbleX = tailProtrusion + 2
|
||||
}
|
||||
|
||||
bubbleView.frame = CGRect(
|
||||
@@ -522,17 +444,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
if layout.hasTail {
|
||||
if layout.isOutgoing {
|
||||
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 {
|
||||
shapeRect = CGRect(x: -6, y: 0,
|
||||
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
|
||||
shapeRect = CGRect(x: -tailProtrusion, y: 0,
|
||||
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
|
||||
}
|
||||
} else {
|
||||
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
|
||||
}
|
||||
bubbleLayer.path = BubblePathCache.shared.path(
|
||||
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
|
||||
bubbleOutlineLayer.frame = bubbleView.bounds
|
||||
@@ -569,6 +493,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
checkReadView.frame = layout.checkReadFrame
|
||||
clockFrameView.frame = layout.clockFrame
|
||||
clockMinView.frame = layout.clockFrame
|
||||
#if DEBUG
|
||||
assertStatusLaneFramesValid(layout: layout)
|
||||
#endif
|
||||
|
||||
// Telegram-style date/status pill on media-only bubbles.
|
||||
updateStatusBackgroundFrame()
|
||||
@@ -734,12 +661,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
|
||||
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)
|
||||
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) {
|
||||
@@ -767,6 +712,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
downloadingAttachmentIds.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 {
|
||||
let isActiveTile = index < photoAttachments.count
|
||||
@@ -789,7 +738,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
|
||||
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)
|
||||
setPhotoTileImage(cached, at: index, animated: false)
|
||||
placeholderView.isHidden = true
|
||||
@@ -797,7 +746,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
indicator.isHidden = true
|
||||
errorView.isHidden = true
|
||||
} 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
|
||||
let hasFailed = failedAttachmentIds.contains(attachment.id)
|
||||
if hasFailed {
|
||||
@@ -823,8 +777,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
|
||||
private func layoutPhotoTiles() {
|
||||
guard !photoAttachments.isEmpty else { return }
|
||||
updatePhotoContainerMask()
|
||||
guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
|
||||
updatePhotoContainerMask(layout: layout)
|
||||
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
|
||||
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
|
||||
photoTileImageViews[index].frame = frame
|
||||
@@ -845,58 +799,61 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
photoContainer.bringSubviewToFront(photoUploadingIndicator)
|
||||
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
|
||||
layoutPhotoOverflowOverlay(frames: frames)
|
||||
applyPhotoLastTileMask(frames: frames, layout: layout)
|
||||
}
|
||||
|
||||
private func updatePhotoContainerMask() {
|
||||
guard let layout = currentLayout else {
|
||||
private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
|
||||
guard let layout = layout ?? currentLayout else {
|
||||
photoContainer.layer.mask = nil
|
||||
return
|
||||
}
|
||||
photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
|
||||
bounds: photoContainer.bounds,
|
||||
mergeType: layout.mergeType,
|
||||
outgoing: layout.isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
let inset: CGFloat = 2
|
||||
let r: CGFloat = max(16 - inset, 0)
|
||||
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)
|
||||
}
|
||||
}()
|
||||
private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
|
||||
guard !frames.isEmpty else { return }
|
||||
|
||||
let maxR = min(rect.width, rect.height) / 2
|
||||
let cTL = min(tl, maxR), cTR = min(tr, maxR)
|
||||
let cBL = min(bl, maxR), cBR = min(br, maxR)
|
||||
// Reset per-tile masks first.
|
||||
for index in 0..<photoTileImageViews.count {
|
||||
photoTileImageViews[index].layer.mask = nil
|
||||
photoTilePlaceholderViews[index].layer.mask = nil
|
||||
photoTileButtons[index].layer.mask = nil
|
||||
}
|
||||
photoOverflowOverlayView.layer.mask = nil
|
||||
|
||||
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()
|
||||
let lastVisibleIndex = photoAttachments.count - 1
|
||||
guard lastVisibleIndex >= 0, lastVisibleIndex < frames.count else { return }
|
||||
let tileFrame = frames[lastVisibleIndex]
|
||||
guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask(
|
||||
tileFrame: tileFrame,
|
||||
containerBounds: photoContainer.bounds,
|
||||
mergeType: layout.mergeType,
|
||||
outgoing: layout.isOutgoing
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
mask.frame = rect
|
||||
mask.path = path.cgPath
|
||||
photoContainer.layer.mask = mask
|
||||
mask.frame = CGRect(origin: .zero, size: view.bounds.size)
|
||||
mask.path = path
|
||||
view.layer.mask = mask
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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) {
|
||||
if photoLoadTasks[attachment.id] != nil { return }
|
||||
let attachmentId = attachment.id
|
||||
@@ -1140,6 +1132,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
task.cancel()
|
||||
}
|
||||
photoDownloadTasks.removeAll()
|
||||
for task in photoBlurHashTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
photoBlurHashTasks.removeAll()
|
||||
downloadingAttachmentIds.removeAll()
|
||||
failedAttachmentIds.removeAll()
|
||||
photoContainer.layer.mask = nil
|
||||
@@ -1151,35 +1147,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
for index in 0..<photoTileImageViews.count {
|
||||
photoTileImageViews[index].image = nil
|
||||
photoTileImageViews[index].isHidden = true
|
||||
photoTileImageViews[index].layer.mask = nil
|
||||
photoTilePlaceholderViews[index].isHidden = true
|
||||
photoTilePlaceholderViews[index].layer.mask = nil
|
||||
photoTileActivityIndicators[index].stopAnimating()
|
||||
photoTileActivityIndicators[index].isHidden = true
|
||||
photoTileErrorViews[index].isHidden = true
|
||||
photoTileButtons[index].isHidden = true
|
||||
photoTileButtons[index].layer.mask = nil
|
||||
}
|
||||
photoOverflowOverlayView.layer.mask = nil
|
||||
}
|
||||
|
||||
private static func extractTag(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.first ?? preview
|
||||
AttachmentPreviewCodec.downloadTag(from: preview)
|
||||
}
|
||||
|
||||
private static func extractBlurHash(from preview: String) -> String {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
return parts.count > 1 ? parts[1] : ""
|
||||
AttachmentPreviewCodec.blurHash(from: preview)
|
||||
}
|
||||
|
||||
private static func blurHashImage(from preview: String) -> UIImage? {
|
||||
private static func cachedBlurHashImage(from preview: String) -> UIImage? {
|
||||
let hash = extractBlurHash(from: preview)
|
||||
guard !hash.isEmpty else { return nil }
|
||||
if let cached = 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
|
||||
return blurHashCache.object(forKey: hash as NSString)
|
||||
}
|
||||
|
||||
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) {
|
||||
if isSentVisible && !wasSentCheckVisible {
|
||||
checkSentView.alpha = 0
|
||||
checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
|
||||
checkSentView.alpha = 1
|
||||
checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
|
||||
UIView.animate(
|
||||
withDuration: 0.16,
|
||||
withDuration: 0.1,
|
||||
delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState]
|
||||
) {
|
||||
self.checkSentView.alpha = 1
|
||||
self.checkSentView.transform = .identity
|
||||
}
|
||||
} else if !isSentVisible {
|
||||
@@ -1259,14 +1249,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
}
|
||||
|
||||
if isReadVisible && !wasReadCheckVisible {
|
||||
checkReadView.alpha = 0
|
||||
checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
|
||||
checkReadView.alpha = 1
|
||||
checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
|
||||
UIView.animate(
|
||||
withDuration: 0.16,
|
||||
delay: 0.02,
|
||||
withDuration: 0.1,
|
||||
delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState]
|
||||
) {
|
||||
self.checkReadView.alpha = 1
|
||||
self.checkReadView.transform = .identity
|
||||
}
|
||||
} else if !isReadVisible {
|
||||
@@ -1312,6 +1301,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
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
|
||||
|
||||
override func prepareForReuse() {
|
||||
@@ -1369,18 +1382,35 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
|
||||
final class BubblePathCache {
|
||||
static let shared = BubblePathCache()
|
||||
|
||||
private let pathVersion = 8
|
||||
private let pathVersion = 9
|
||||
private var cache: [String: CGPath] = [:]
|
||||
|
||||
func path(
|
||||
size: CGSize, origin: CGPoint,
|
||||
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
|
||||
mergeType: BubbleMergeType,
|
||||
isOutgoing: Bool,
|
||||
metrics: BubbleMetrics
|
||||
) -> 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 }
|
||||
|
||||
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
|
||||
|
||||
// Evict if cache grows too large
|
||||
@@ -1390,101 +1420,4 @@ final class BubblePathCache {
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
struct Config {
|
||||
@@ -73,6 +82,11 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
// MARK: - Scroll-to-Bottom Button
|
||||
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.
|
||||
private var lastReportedAtBottom: Bool = true
|
||||
|
||||
@@ -142,6 +156,7 @@ final class NativeMessageListController: UIViewController {
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
applyInsets()
|
||||
updateScrollToBottomButtonConstraints()
|
||||
// Update composer bottom when keyboard is hidden
|
||||
if currentKeyboardHeight == 0 {
|
||||
composerBottomConstraint?.constant = -view.safeAreaInsets.bottom
|
||||
@@ -327,7 +342,7 @@ final class NativeMessageListController: UIViewController {
|
||||
// MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer)
|
||||
|
||||
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)
|
||||
|
||||
// Container: Auto Layout positions it, clipsToBounds prevents overflow.
|
||||
@@ -339,41 +354,66 @@ final class NativeMessageListController: UIViewController {
|
||||
container.isUserInteractionEnabled = true
|
||||
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([
|
||||
container.widthAnchor.constraint(equalToConstant: size),
|
||||
container.heightAnchor.constraint(equalToConstant: size),
|
||||
container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
container.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -76),
|
||||
trailing,
|
||||
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
|
||||
// transform matrix during interactive keyboard dismiss.
|
||||
let button = UIButton(type: .custom)
|
||||
button.frame = rect
|
||||
button.clipsToBounds = true
|
||||
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
|
||||
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)
|
||||
glass.isCircle = true
|
||||
glass.isUserInteractionEnabled = false
|
||||
button.addSubview(glass)
|
||||
|
||||
// Chevron down icon: hardcoded 42×42 frame, centered contentMode.
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
||||
let chevron = UIImage(systemName: "chevron.down", withConfiguration: config)
|
||||
let imageView = UIImageView(image: chevron)
|
||||
imageView.tintColor = .white
|
||||
// Telegram-style down icon (canvas 38×38, line width 1.5).
|
||||
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
|
||||
imageView.contentMode = .center
|
||||
imageView.frame = rect
|
||||
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)
|
||||
scrollToBottomButton = button
|
||||
updateScrollToBottomButtonConstraints()
|
||||
updateScrollToBottomBadge()
|
||||
}
|
||||
|
||||
@objc private func scrollToBottomTapped() {
|
||||
@@ -382,7 +422,7 @@ final class NativeMessageListController: UIViewController {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Auto Layout ↔ transform race condition during interactive dismiss.
|
||||
func setScrollToBottomVisible(_ visible: Bool) {
|
||||
@@ -390,13 +430,79 @@ final class NativeMessageListController: UIViewController {
|
||||
let isCurrentlyVisible = button.alpha > 0.5
|
||||
guard visible != isCurrentlyVisible else { return }
|
||||
|
||||
UIView.animate(withDuration: visible ? 0.25 : 0.2, delay: 0,
|
||||
usingSpringWithDamping: 0.8, initialSpringVelocity: 0,
|
||||
UIView.animate(withDuration: 0.3, delay: 0,
|
||||
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
|
||||
options: .beginFromCurrentState) {
|
||||
button.alpha = visible ? 1 : 0
|
||||
button.layer.transform = visible
|
||||
? 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)
|
||||
updateScrollToBottomBadge()
|
||||
}
|
||||
|
||||
// MARK: - Layout Calculation (Telegram asyncLayout pattern)
|
||||
@@ -473,10 +580,11 @@ final class NativeMessageListController: UIViewController {
|
||||
/// would double the adjustment → content teleports upward.
|
||||
private func applyInsets() {
|
||||
guard collectionView != nil else { return }
|
||||
updateScrollToBottomButtonConstraints()
|
||||
|
||||
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
|
||||
let composerHeight = lastComposerHeight
|
||||
let newInsetTop = composerHeight + composerBottom
|
||||
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
|
||||
let topInset = view.safeAreaInsets.top + 6
|
||||
|
||||
let oldInsetTop = collectionView.contentInset.top
|
||||
@@ -496,6 +604,7 @@ final class NativeMessageListController: UIViewController {
|
||||
if shouldCompensate {
|
||||
collectionView.contentOffset.y = oldOffset - delta
|
||||
}
|
||||
updateScrollToBottomBadge()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
|
||||
let composerH = lastComposerHeight
|
||||
let newInsetTop = composerH + composerBottom
|
||||
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
|
||||
let topInset = view.safeAreaInsets.top + 6
|
||||
let oldInsetTop = collectionView.contentInset.top
|
||||
let delta = newInsetTop - oldInsetTop
|
||||
@@ -655,6 +764,7 @@ extension NativeMessageListController: UICollectionViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
let isAtBottom = offsetFromBottom < 50
|
||||
updateScrollToBottomBadge()
|
||||
|
||||
// Dedup — only fire when value actually changes.
|
||||
// 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 {
|
||||
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 {
|
||||
/// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time.
|
||||
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
|
||||
/// to avoid @Published re-render cascade through ChatListView.
|
||||
/// Uses unified search policy from ChatListViewModel callback merge.
|
||||
@ViewBuilder
|
||||
var activeSearchContent: some View {
|
||||
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
// 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
|
||||
let hasAnyResult = !viewModel.serverSearchResults.isEmpty
|
||||
|
||||
if viewModel.isServerSearching && !hasAnyResult {
|
||||
SearchSkeletonView()
|
||||
} else if !viewModel.isServerSearching && !hasAnyResult {
|
||||
noResultsState
|
||||
} else {
|
||||
resultsList(localResults: localResults, serverOnly: serverOnly)
|
||||
resultsList(results: viewModel.serverSearchResults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +58,14 @@ private extension ChatListSearchContent {
|
||||
.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.
|
||||
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
|
||||
func resultsList(results: [SearchUser]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(localResults) { dialog in
|
||||
Button {
|
||||
onOpenDialog(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
ChatRowView(dialog: dialog)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ForEach(serverOnly, id: \.publicKey) { user in
|
||||
ForEach(results, id: \.publicKey) { user in
|
||||
serverUserRow(user)
|
||||
if user.publicKey != serverOnly.last?.publicKey {
|
||||
if user.publicKey != results.last?.publicKey {
|
||||
Divider()
|
||||
.padding(.leading, 76)
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
|
||||
@@ -30,10 +30,12 @@ final class ChatListViewModel: ObservableObject {
|
||||
private var searchHandlerToken: UUID?
|
||||
private var recentSearchesCancellable: AnyCancellable?
|
||||
private let recentRepository = RecentSearchesRepository.shared
|
||||
private let searchDispatcher: SearchResultDispatching
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||
self.searchDispatcher = searchDispatcher
|
||||
configureRecentSearches()
|
||||
setupSearchCallback()
|
||||
}
|
||||
@@ -107,7 +109,7 @@ final class ChatListViewModel: ObservableObject {
|
||||
// MARK: - Actions
|
||||
|
||||
func setSearchQuery(_ query: String) {
|
||||
searchQuery = normalizeSearchInput(query)
|
||||
searchQuery = SearchParityPolicy.sanitizeInput(query)
|
||||
triggerServerSearch()
|
||||
}
|
||||
|
||||
@@ -132,11 +134,11 @@ final class ChatListViewModel: ObservableObject {
|
||||
|
||||
private func setupSearchCallback() {
|
||||
if let token = searchHandlerToken {
|
||||
ProtocolManager.shared.removeSearchResultHandler(token)
|
||||
searchDispatcher.removeSearchResultHandler(token)
|
||||
}
|
||||
|
||||
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
|
||||
guard let self else {
|
||||
Self.logger.debug("Search callback: self is nil")
|
||||
@@ -147,7 +149,16 @@ final class ChatListViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
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.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
|
||||
for user in packet.users {
|
||||
@@ -169,7 +180,7 @@ final class ChatListViewModel: ObservableObject {
|
||||
searchRetryTask?.cancel()
|
||||
searchRetryTask = nil
|
||||
|
||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
let trimmed = SearchParityPolicy.normalizedQuery(searchQuery)
|
||||
if trimmed.isEmpty {
|
||||
// Guard: only publish if value actually changes (avoids extra re-renders)
|
||||
if !serverSearchResults.isEmpty { serverSearchResults = [] }
|
||||
@@ -184,7 +195,7 @@ final class ChatListViewModel: ObservableObject {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
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 }
|
||||
|
||||
self.sendSearchPacket(query: currentQuery)
|
||||
@@ -193,8 +204,8 @@ final class ChatListViewModel: ObservableObject {
|
||||
|
||||
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
|
||||
private func sendSearchPacket(query: String) {
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||
let connState = searchDispatcher.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash
|
||||
|
||||
guard connState == .authenticated, let hash else {
|
||||
// Not authenticated — wait for reconnect then send
|
||||
@@ -205,9 +216,9 @@ final class ChatListViewModel: ObservableObject {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
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
|
||||
if ProtocolManager.shared.connectionState == .authenticated {
|
||||
if self.searchDispatcher.connectionState == .authenticated {
|
||||
Self.logger.debug("Connection restored — sending pending search")
|
||||
self.sendSearchPacket(query: query)
|
||||
return
|
||||
@@ -223,14 +234,9 @@ final class ChatListViewModel: ObservableObject {
|
||||
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = query.lowercased()
|
||||
packet.search = query
|
||||
Self.logger.debug("📤 Sending search packet for '\(query)'")
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
private func normalizeSearchInput(_ input: String) -> String {
|
||||
input.replacingOccurrences(of: "@", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
searchDispatcher.sendSearchPacket(packet)
|
||||
}
|
||||
|
||||
// MARK: - Recent Searches
|
||||
|
||||
@@ -30,10 +30,12 @@ final class SearchViewModel: ObservableObject {
|
||||
private var searchHandlerToken: UUID?
|
||||
private var recentSearchesCancellable: AnyCancellable?
|
||||
private let recentRepository = RecentSearchesRepository.shared
|
||||
private let searchDispatcher: SearchResultDispatching
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||
self.searchDispatcher = searchDispatcher
|
||||
configureRecentSearches()
|
||||
setupSearchCallback()
|
||||
}
|
||||
@@ -41,7 +43,7 @@ final class SearchViewModel: ObservableObject {
|
||||
// MARK: - Search Logic
|
||||
|
||||
func setSearchQuery(_ query: String) {
|
||||
searchQuery = normalizeSearchInput(query)
|
||||
searchQuery = SearchParityPolicy.sanitizeInput(query)
|
||||
onSearchQueryChanged()
|
||||
}
|
||||
|
||||
@@ -49,15 +51,15 @@ final class SearchViewModel: ObservableObject {
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
|
||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
let normalized = SearchParityPolicy.normalizedQuery(searchQuery)
|
||||
if normalized.isEmpty {
|
||||
searchResults = []
|
||||
isSearching = false
|
||||
lastSearchedText = ""
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == lastSearchedText {
|
||||
if normalized == lastSearchedText {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,13 +73,13 @@ final class SearchViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !currentQuery.isEmpty, currentQuery == trimmed else {
|
||||
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||
guard !currentQuery.isEmpty, currentQuery == normalized else {
|
||||
return
|
||||
}
|
||||
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||
let connState = self.searchDispatcher.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? self.searchDispatcher.privateHash
|
||||
|
||||
guard connState == .authenticated, let hash else {
|
||||
self.isSearching = false
|
||||
@@ -88,9 +90,9 @@ final class SearchViewModel: ObservableObject {
|
||||
|
||||
var packet = PacketSearch()
|
||||
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() {
|
||||
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
|
||||
guard let self else { return }
|
||||
let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
|
||||
guard !query.isEmpty else {
|
||||
self.isSearching = false
|
||||
return
|
||||
}
|
||||
|
||||
// Merge server results with client-side public key matches.
|
||||
// Server only matches by username; public key matching is local
|
||||
// (same approach as Android).
|
||||
var merged = packet.users
|
||||
let serverKeys = Set(merged.map(\.publicKey))
|
||||
|
||||
let localMatches = self.findLocalPublicKeyMatches(query: query)
|
||||
for match in localMatches where !serverKeys.contains(match.publicKey) {
|
||||
merged.append(match)
|
||||
}
|
||||
|
||||
self.searchResults = merged
|
||||
let localMatches = SearchParityPolicy.localAugmentedUsers(
|
||||
query: query,
|
||||
currentPublicKey: SessionManager.shared.currentPublicKey,
|
||||
dialogs: Array(DialogRepository.shared.dialogs.values)
|
||||
)
|
||||
self.searchResults = SearchParityPolicy.mergeServerAndLocal(
|
||||
server: packet.users,
|
||||
local: localMatches
|
||||
)
|
||||
self.isSearching = false
|
||||
|
||||
// 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
|
||||
|
||||
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