migrate persistence from JSON to SwiftData
Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions.
This commit is contained in:
@@ -80,6 +80,7 @@
|
||||
4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; };
|
||||
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */; };
|
||||
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
|
||||
4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
|
||||
4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; };
|
||||
51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
|
||||
52F75F60459DA50E1815479A /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
|
||||
@@ -88,7 +89,6 @@
|
||||
5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
|
||||
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
|
||||
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
|
||||
5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
|
||||
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
|
||||
6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
|
||||
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; };
|
||||
@@ -118,6 +118,7 @@
|
||||
7A469F326228FB660739D1C2 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; };
|
||||
7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; };
|
||||
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
|
||||
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
|
||||
7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; };
|
||||
7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; };
|
||||
7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
|
||||
@@ -154,7 +155,7 @@
|
||||
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; };
|
||||
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; };
|
||||
ADB2CCC69CA91FA820B3A4CA /* MessageReactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92CDC8D3E0DA178D0E3F52 /* MessageReactionTests.swift */; };
|
||||
AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; };
|
||||
AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; };
|
||||
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; };
|
||||
B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; };
|
||||
B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; };
|
||||
@@ -171,7 +172,6 @@
|
||||
BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; };
|
||||
BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; };
|
||||
C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
||||
C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */; };
|
||||
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; };
|
||||
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */; };
|
||||
C824389F95739F79F3E075C8 /* MessageInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */; };
|
||||
@@ -180,6 +180,7 @@
|
||||
CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; };
|
||||
CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; };
|
||||
CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; };
|
||||
CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
|
||||
CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; };
|
||||
D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; };
|
||||
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; };
|
||||
@@ -202,6 +203,7 @@
|
||||
E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; };
|
||||
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */; };
|
||||
E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
|
||||
E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
|
||||
E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; };
|
||||
EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
|
||||
ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
|
||||
@@ -213,8 +215,10 @@
|
||||
F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; };
|
||||
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; };
|
||||
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */; };
|
||||
F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */; };
|
||||
F558F58835D6E887DBF09EA0 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; };
|
||||
F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; };
|
||||
F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; };
|
||||
F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; };
|
||||
F9BEB6552D0407A160501369 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; };
|
||||
FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; };
|
||||
@@ -269,7 +273,6 @@
|
||||
0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudget.swift; sourceTree = "<group>"; };
|
||||
0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicyTests.swift; sourceTree = "<group>"; };
|
||||
0DE298382FC70ECF1513BD4A /* XMPPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPService.swift; sourceTree = "<group>"; };
|
||||
0E9EDF4E846FF431E6498419 /* ChatArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchive.swift; sourceTree = "<group>"; };
|
||||
101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationTests.swift; sourceTree = "<group>"; };
|
||||
12C6A6B83DA076867481CCF3 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
|
||||
13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntry.swift; sourceTree = "<group>"; };
|
||||
@@ -299,6 +302,7 @@
|
||||
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = "<group>"; };
|
||||
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = "<group>"; };
|
||||
37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = "<group>"; };
|
||||
3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = "<group>"; };
|
||||
3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = "<group>"; };
|
||||
3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = "<group>"; };
|
||||
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = "<group>"; };
|
||||
@@ -308,6 +312,7 @@
|
||||
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = "<group>"; };
|
||||
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = "<group>"; };
|
||||
5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = "<group>"; };
|
||||
5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = "<group>"; };
|
||||
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = "<group>"; };
|
||||
6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = "<group>"; };
|
||||
60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
@@ -348,6 +353,7 @@
|
||||
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = "<group>"; };
|
||||
A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||
A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = "<group>"; };
|
||||
ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = "<group>"; };
|
||||
B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = "<group>"; };
|
||||
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicyTests.swift; sourceTree = "<group>"; };
|
||||
B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerItem.swift; sourceTree = "<group>"; };
|
||||
@@ -380,6 +386,7 @@
|
||||
E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = "<group>"; };
|
||||
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
|
||||
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
|
||||
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
|
||||
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = "<group>"; };
|
||||
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
|
||||
@@ -388,7 +395,6 @@
|
||||
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = "<group>"; };
|
||||
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = "<group>"; };
|
||||
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = "<group>"; };
|
||||
FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -471,8 +477,10 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */,
|
||||
5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */,
|
||||
3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */,
|
||||
32DBAB8D25603892C7D67A23 /* AvatarCache.swift */,
|
||||
0E9EDF4E846FF431E6498419 /* ChatArchive.swift */,
|
||||
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */,
|
||||
);
|
||||
path = Persistence;
|
||||
sourceTree = "<group>";
|
||||
@@ -516,13 +524,13 @@
|
||||
children = (
|
||||
101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */,
|
||||
0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */,
|
||||
ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */,
|
||||
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */,
|
||||
A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */,
|
||||
61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */,
|
||||
22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */,
|
||||
C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */,
|
||||
375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */,
|
||||
FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */,
|
||||
24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */,
|
||||
C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */,
|
||||
C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */,
|
||||
@@ -834,6 +842,8 @@
|
||||
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */,
|
||||
B492852FD89322434B0914AE /* AppModel.swift in Sources */,
|
||||
F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */,
|
||||
7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */,
|
||||
E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */,
|
||||
ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */,
|
||||
1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */,
|
||||
088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */,
|
||||
@@ -849,7 +859,6 @@
|
||||
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */,
|
||||
6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */,
|
||||
73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */,
|
||||
AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */,
|
||||
7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */,
|
||||
66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */,
|
||||
D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */,
|
||||
@@ -870,6 +879,7 @@
|
||||
D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */,
|
||||
DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */,
|
||||
DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */,
|
||||
CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */,
|
||||
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */,
|
||||
814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */,
|
||||
89712B216A98861B5D994587 /* LocationProvider.swift in Sources */,
|
||||
@@ -937,6 +947,8 @@
|
||||
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */,
|
||||
21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */,
|
||||
9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */,
|
||||
AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */,
|
||||
4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */,
|
||||
4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */,
|
||||
79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */,
|
||||
496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */,
|
||||
@@ -952,7 +964,6 @@
|
||||
4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */,
|
||||
087E600676B3F6134BB26112 /* CallView.swift in Sources */,
|
||||
3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */,
|
||||
5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */,
|
||||
6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */,
|
||||
8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */,
|
||||
8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */,
|
||||
@@ -973,6 +984,7 @@
|
||||
7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */,
|
||||
D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */,
|
||||
6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */,
|
||||
F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */,
|
||||
64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */,
|
||||
B9CAB8C2C16A5A05CE38E8D7 /* LocationPickerView.swift in Sources */,
|
||||
49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */,
|
||||
@@ -1027,13 +1039,13 @@
|
||||
files = (
|
||||
6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */,
|
||||
822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */,
|
||||
F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */,
|
||||
C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */,
|
||||
7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */,
|
||||
481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */,
|
||||
10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */,
|
||||
B7ED810A1DD43B717DDC40B3 /* CallSDPOrderingTests.swift in Sources */,
|
||||
61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */,
|
||||
C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */,
|
||||
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */,
|
||||
5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */,
|
||||
3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */,
|
||||
|
||||
+2
-2
@@ -253,8 +253,8 @@ grep -q 'decodeSliceSize = 8' Sources/Shared/Models/ArchiveMessageBatchPolicy.sw
|
||||
echo "MAM decoding must stay batched in small per-slice groups"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || {
|
||||
echo "The durable archive snapshot must persist the XEP-0313 UID cursor"
|
||||
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift || {
|
||||
echo "The durable archive store must persist the XEP-0313 UID cursor"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
|
||||
@@ -72,7 +72,7 @@ final class AppModel: ObservableObject {
|
||||
private let avatarCache: AvatarCache
|
||||
private let mediaPreviewProcessor = MediaPreviewProcessor()
|
||||
private let mediaFileIO = MediaFileIO()
|
||||
private var archive: ChatArchive?
|
||||
private var store: ArchiveStore?
|
||||
private var bootstrapTask: Task<Void, Never>?
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var watchSyncTask: Task<Void, Never>?
|
||||
@@ -131,7 +131,7 @@ final class AppModel: ObservableObject {
|
||||
watchBridge.onReply = { [weak self] jid, text in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if !self.conversations.contains(where: { $0.id == jid.lowercased() }) {
|
||||
if !self.conversations.contains(where: { $0.jid == jid.lowercased() }) {
|
||||
self.upsertConversation(jid: jid, name: nil)
|
||||
}
|
||||
await self.sendText(text, to: jid)
|
||||
@@ -171,7 +171,7 @@ final class AppModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) {
|
||||
if !self.conversations.contains(where: { $0.jid == voice.jid.lowercased() }) {
|
||||
self.upsertConversation(jid: voice.jid, name: nil)
|
||||
}
|
||||
let messageID = await self.sendMedia(
|
||||
@@ -218,7 +218,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
var selectedConversation: Conversation? {
|
||||
guard let selectedConversationID else { return nil }
|
||||
return conversations.first { $0.id == selectedConversationID }
|
||||
return conversations.first { $0.jid == selectedConversationID }
|
||||
}
|
||||
|
||||
private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? {
|
||||
@@ -238,7 +238,7 @@ final class AppModel: ObservableObject {
|
||||
messages
|
||||
.filter { $0.conversationID == selectedConversationID }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
selectedMessagesCacheConversationID = selectedConversationID
|
||||
@@ -269,7 +269,7 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
|
||||
account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -280,7 +280,7 @@ final class AppModel: ObservableObject {
|
||||
password = storedPassword
|
||||
} catch {
|
||||
account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -340,7 +340,7 @@ final class AppModel: ObservableObject {
|
||||
} catch {
|
||||
await xmpp.disconnect()
|
||||
self.account = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -356,14 +356,14 @@ final class AppModel: ObservableObject {
|
||||
if let oldAccount {
|
||||
try? credentials.deletePassword(for: oldAccount.normalizedJID)
|
||||
if forgetHistory {
|
||||
try? await archive?.erase()
|
||||
try? store?.erase()
|
||||
}
|
||||
}
|
||||
persistTask?.cancel()
|
||||
resetArchiveBatchState()
|
||||
watchSyncTask?.cancel()
|
||||
watchSyncTask = nil
|
||||
archive = nil
|
||||
store = nil
|
||||
account = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
@@ -429,7 +429,7 @@ final class AppModel: ObservableObject {
|
||||
func startCall(to rawJID: String, withVideo: Bool) async {
|
||||
let jid = rawJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !jid.isEmpty else { return }
|
||||
guard conversations.first(where: { $0.id == jid })?.isGroup != true else {
|
||||
guard conversations.first(where: { $0.jid == jid })?.isGroup != true else {
|
||||
errorMessage = "Звонки в текущем MVP доступны только в личных чатах."
|
||||
return
|
||||
}
|
||||
@@ -493,7 +493,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
upsertConversation(jid: normalized, name: name)
|
||||
selectedConversationID = normalized
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
conversations[index].unreadCount = 0
|
||||
}
|
||||
if addToRoster {
|
||||
@@ -508,7 +508,7 @@ final class AppModel: ObservableObject {
|
||||
func selectConversation(id: String) {
|
||||
let normalized = id.lowercased()
|
||||
selectedConversationID = normalized
|
||||
// if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
// if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
// conversations[index].unreadCount = 0
|
||||
// }
|
||||
// schedulePersist()
|
||||
@@ -532,7 +532,7 @@ final class AppModel: ObservableObject {
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// return lhs.clientID < rhs.clientID
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
@@ -540,12 +540,12 @@ final class AppModel: ObservableObject {
|
||||
.filter { $0.stanzaID != nil }
|
||||
.min { lhs, rhs in
|
||||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
return lhs.id < rhs.id
|
||||
return lhs.clientID < rhs.clientID
|
||||
}?
|
||||
.stanzaID
|
||||
|
||||
isLoadingOlderHistory = true
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
xmpp.loadOlderHistory(
|
||||
conversationJID: conversation.jid,
|
||||
isGroup: conversation.isGroup,
|
||||
@@ -603,7 +603,7 @@ final class AppModel: ObservableObject {
|
||||
informationalMessage = "Приглашения отправлены: \(validInvitees.count)."
|
||||
}
|
||||
} catch {
|
||||
if let index = conversations.firstIndex(where: { $0.id == roomJID }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == roomJID }) {
|
||||
conversations[index].isGroupJoined = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
@@ -613,7 +613,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func joinGroup(jid rawJID: String, nickname preferredNickname: String? = nil) async {
|
||||
let jid = rawJID.lowercased()
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
|
||||
return
|
||||
}
|
||||
guard !conversations[index].isGroupJoined,
|
||||
@@ -647,7 +647,7 @@ final class AppModel: ObservableObject {
|
||||
func leaveGroup(jid rawJID: String) {
|
||||
let jid = rawJID.lowercased()
|
||||
xmpp.leaveRoom(roomJID: jid)
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
|
||||
return
|
||||
}
|
||||
conversations[index].isGroupJoined = false
|
||||
@@ -685,7 +685,7 @@ final class AppModel: ObservableObject {
|
||||
let account,
|
||||
let peer = explicitJID ?? selectedConversation?.jid
|
||||
else { return }
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
let replyTarget = replyingTo.flatMap { message -> ChatMessage? in
|
||||
@@ -750,7 +750,7 @@ final class AppModel: ObservableObject {
|
||||
guard let account,
|
||||
let peer = explicitJID ?? selectedConversation?.jid
|
||||
else { return }
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
let id = UUID().uuidString
|
||||
@@ -791,7 +791,7 @@ final class AppModel: ObservableObject {
|
||||
func editMessage(id: String, newBody rawText: String) async {
|
||||
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
let index = messages.firstIndex(where: { $0.id == id }),
|
||||
let index = messages.firstIndex(where: { $0.clientID == id }),
|
||||
messages[index].canBeEdited
|
||||
else { return }
|
||||
|
||||
@@ -818,7 +818,7 @@ final class AppModel: ObservableObject {
|
||||
to: previous.conversationID,
|
||||
messageID: correctionID,
|
||||
encrypted: encrypted,
|
||||
replacingMessageID: previous.id
|
||||
replacingMessageID: previous.clientID
|
||||
)
|
||||
updateMessage(id: id) {
|
||||
$0.delivery = .sent
|
||||
@@ -826,7 +826,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
correctionReceiptTargets.removeValue(forKey: correctionID)
|
||||
if let currentIndex = messages.firstIndex(where: { $0.id == id }),
|
||||
if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
|
||||
messages[currentIndex].delivery == .sending
|
||||
{
|
||||
messages[currentIndex] = previous
|
||||
@@ -840,7 +840,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func retractMessage(id: String) async {
|
||||
guard let account,
|
||||
let message = messages.first(where: { $0.id == id }),
|
||||
let message = messages.first(where: { $0.clientID == id }),
|
||||
message.canBeRetracted
|
||||
else { return }
|
||||
|
||||
@@ -848,7 +848,7 @@ final class AppModel: ObservableObject {
|
||||
do {
|
||||
try await xmpp.sendRetraction(
|
||||
to: message.conversationID,
|
||||
targetID: message.id,
|
||||
targetID: message.clientID,
|
||||
retractionID: retractionID,
|
||||
encrypted: message.security == .omemo
|
||||
)
|
||||
@@ -856,7 +856,7 @@ final class AppModel: ObservableObject {
|
||||
XMPPService.RetractionEnvelope(
|
||||
peerJID: message.conversationID,
|
||||
senderJID: account.normalizedJID,
|
||||
targetID: message.id,
|
||||
targetID: message.clientID,
|
||||
retractionID: retractionID,
|
||||
timestamp: Date(),
|
||||
isOutgoing: true
|
||||
@@ -876,7 +876,7 @@ final class AppModel: ObservableObject {
|
||||
guard !ids.isEmpty else { return }
|
||||
let normalizedConversationID = conversationID?.lowercased()
|
||||
let selected = messages.filter { message in
|
||||
ids.contains(message.id)
|
||||
ids.contains(message.clientID)
|
||||
&& (normalizedConversationID == nil
|
||||
|| message.conversationID == normalizedConversationID)
|
||||
}
|
||||
@@ -886,11 +886,11 @@ final class AppModel: ObservableObject {
|
||||
for message in selected {
|
||||
locallyDeletedMessageIDs.insert(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
let pendingKey = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
pendingCorrections.removeValue(forKey: pendingKey)
|
||||
@@ -899,7 +899,7 @@ final class AppModel: ObservableObject {
|
||||
guard envelope.peerJID.lowercased() == message.conversationID else {
|
||||
return true
|
||||
}
|
||||
if envelope.targetID == message.id { return false }
|
||||
if envelope.targetID == message.clientID { return false }
|
||||
if let stanzaID = message.stanzaID,
|
||||
envelope.targetID == stanzaID
|
||||
{
|
||||
@@ -907,19 +907,19 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
return true
|
||||
}
|
||||
removeCachedMedia(for: message.id)
|
||||
removeCachedMedia(for: message.clientID)
|
||||
}
|
||||
let selectedKeys = Set(
|
||||
selected.map {
|
||||
Self.localDeletionKey(
|
||||
messageID: $0.id,
|
||||
messageID: $0.clientID,
|
||||
conversationID: $0.conversationID
|
||||
)
|
||||
})
|
||||
messages.removeAll { message in
|
||||
selectedKeys.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
}
|
||||
@@ -946,7 +946,7 @@ final class AppModel: ObservableObject {
|
||||
selectedMessages
|
||||
.filter(\.canBeForwarded)
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
guard !forwardableMessages.isEmpty else { return false }
|
||||
@@ -995,7 +995,7 @@ final class AppModel: ObservableObject {
|
||||
case .attachment, .photo, .video, .audio, .voice, .videoNote:
|
||||
do {
|
||||
let url: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
url = cached
|
||||
} else {
|
||||
url = try await localAttachmentURL(
|
||||
@@ -1031,7 +1031,7 @@ final class AppModel: ObservableObject {
|
||||
guard let conversationID else {
|
||||
if let index = firstMessageIndexByID[id],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == id
|
||||
messages[index].clientID == id
|
||||
{
|
||||
return messages[index]
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ final class AppModel: ObservableObject {
|
||||
let id = explicitMessageID ?? UUID().uuidString
|
||||
let filename = Self.safeFilename(rawFilename)
|
||||
let kind = rawKind.isMedia ? rawKind : .attachment
|
||||
let conversation = conversations.first { $0.id == peer.lowercased() }
|
||||
let conversation = conversations.first { $0.jid == peer.lowercased() }
|
||||
let isGroup = conversation?.isGroup == true
|
||||
let encrypted = encryptionEnabled(for: peer)
|
||||
var duration = suppliedDuration
|
||||
@@ -1191,12 +1191,12 @@ final class AppModel: ObservableObject {
|
||||
&& message.delivery == .failed
|
||||
&& message.kind.isMedia
|
||||
&& message.remoteAttachmentURL == nil
|
||||
&& mediaPreviewURLs[message.id] != nil
|
||||
&& mediaPreviewURLs[message.clientID] != nil
|
||||
}
|
||||
|
||||
func retryMediaMessage(_ message: ChatMessage) async {
|
||||
guard canRetryMediaMessage(message),
|
||||
let localURL = mediaPreviewURLs[message.id]
|
||||
let localURL = mediaPreviewURLs[message.clientID]
|
||||
else { return }
|
||||
let activityToken = beginMediaSendActivity()
|
||||
defer { endMediaSendActivity(activityToken) }
|
||||
@@ -1211,7 +1211,7 @@ final class AppModel: ObservableObject {
|
||||
from: localURL,
|
||||
preferredKind: message.kind
|
||||
).data
|
||||
updateMessage(id: message.id) { $0.delivery = .sending }
|
||||
updateMessage(id: message.clientID) { $0.delivery = .sending }
|
||||
let result = try await uploadAndSendMediaWithRetry(
|
||||
data: data,
|
||||
filename: message.localFilename ?? localURL.lastPathComponent,
|
||||
@@ -1219,17 +1219,17 @@ final class AppModel: ObservableObject {
|
||||
kind: message.kind,
|
||||
duration: message.duration,
|
||||
to: message.conversationID,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
encrypted: message.security == .omemo,
|
||||
isGroup: message.isGroupMessage
|
||||
)
|
||||
updateMessage(id: message.id) {
|
||||
updateMessage(id: message.clientID) {
|
||||
$0.delivery = .sent
|
||||
$0.remoteAttachmentURL = result.remoteURL
|
||||
$0.encryptionFingerprint = result.fingerprint
|
||||
}
|
||||
} catch {
|
||||
updateMessage(id: message.id) { $0.delivery = .failed }
|
||||
updateMessage(id: message.clientID) { $0.delivery = .failed }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
schedulePersist()
|
||||
@@ -1364,7 +1364,7 @@ final class AppModel: ObservableObject {
|
||||
func previewAttachment(_ message: ChatMessage) async {
|
||||
do {
|
||||
let output: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
output = cached
|
||||
} else {
|
||||
guard message.remoteAttachmentURL != nil else { return }
|
||||
@@ -1377,36 +1377,36 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func mediaPreviewURL(for message: ChatMessage) -> URL? {
|
||||
mediaPreviewURLs[message.id]
|
||||
mediaPreviewURLs[message.clientID]
|
||||
}
|
||||
|
||||
func mediaThumbnail(for message: ChatMessage) -> Data? {
|
||||
mediaThumbnailData[message.id]
|
||||
mediaThumbnailData[message.clientID]
|
||||
}
|
||||
|
||||
func audioWaveform(for message: ChatMessage) -> [Float] {
|
||||
audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform
|
||||
audioWaveformSamples[message.clientID] ?? MediaPreviewProcessor.placeholderWaveform
|
||||
}
|
||||
|
||||
func isMediaPreviewLoading(_ message: ChatMessage) -> Bool {
|
||||
loadingMediaIDs.contains(message.id)
|
||||
loadingMediaIDs.contains(message.clientID)
|
||||
}
|
||||
|
||||
func prepareMediaPreview(_ message: ChatMessage) async {
|
||||
guard Self.supportsInlinePreview(message.kind),
|
||||
message.remoteAttachmentURL != nil,
|
||||
mediaPreviewURLs[message.id] == nil,
|
||||
loadingMediaIDs.insert(message.id).inserted
|
||||
mediaPreviewURLs[message.clientID] == nil,
|
||||
loadingMediaIDs.insert(message.clientID).inserted
|
||||
else { return }
|
||||
defer { loadingMediaIDs.remove(message.id) }
|
||||
defer { loadingMediaIDs.remove(message.clientID) }
|
||||
|
||||
do {
|
||||
let output = try await localAttachmentURL(
|
||||
for: message, directoryName: "LumaMediaPreviews")
|
||||
mediaPreviewURLs[message.id] = output
|
||||
mediaPreviewURLs[message.clientID] = output
|
||||
await analyzeMediaPreview(
|
||||
at: output,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
kind: message.kind,
|
||||
mimeType: message.mimeType
|
||||
)
|
||||
@@ -1421,20 +1421,20 @@ final class AppModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let output: URL
|
||||
if let cached = mediaPreviewURLs[message.id] {
|
||||
if let cached = mediaPreviewURLs[message.clientID] {
|
||||
output = cached
|
||||
} else {
|
||||
guard message.remoteAttachmentURL != nil else { return }
|
||||
loadingMediaIDs.insert(message.id)
|
||||
defer { loadingMediaIDs.remove(message.id) }
|
||||
loadingMediaIDs.insert(message.clientID)
|
||||
defer { loadingMediaIDs.remove(message.clientID) }
|
||||
output = try await localAttachmentURL(
|
||||
for: message,
|
||||
directoryName: "LumaMediaPreviews"
|
||||
)
|
||||
mediaPreviewURLs[message.id] = output
|
||||
mediaPreviewURLs[message.clientID] = output
|
||||
await analyzeMediaPreview(
|
||||
at: output,
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
kind: message.kind,
|
||||
mimeType: message.mimeType
|
||||
)
|
||||
@@ -1443,10 +1443,10 @@ final class AppModel: ObservableObject {
|
||||
audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
object: message.clientID
|
||||
)
|
||||
mediaViewerItem = MediaViewerItem(
|
||||
id: message.id,
|
||||
id: message.clientID,
|
||||
url: output,
|
||||
kind: message.kind,
|
||||
title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео")
|
||||
@@ -1466,7 +1466,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func encryptionPreference(for jid: String) -> EncryptionPreference {
|
||||
conversations
|
||||
.first(where: { $0.id == jid.lowercased() })?
|
||||
.first(where: { $0.jid == jid.lowercased() })?
|
||||
.encryptionPreference ?? .inheritGlobal
|
||||
}
|
||||
|
||||
@@ -1490,13 +1490,13 @@ final class AppModel: ObservableObject {
|
||||
|
||||
func typingText(for conversation: Conversation) -> String? {
|
||||
let names =
|
||||
typingParticipantsByConversation[conversation.id.lowercased()]
|
||||
typingParticipantsByConversation[conversation.jid.lowercased()]
|
||||
.map { Array($0.values) } ?? []
|
||||
return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup)
|
||||
}
|
||||
|
||||
func updateComposerActivity(_ text: String, in conversation: Conversation) {
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
localTypingPauseTasks[conversationID]?.cancel()
|
||||
localTypingPauseTasks[conversationID] = nil
|
||||
guard typingIndicatorsEnabled, appIsActive else { return }
|
||||
@@ -1523,7 +1523,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func endComposerActivity(in conversation: Conversation) {
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
localTypingPauseTasks[conversationID]?.cancel()
|
||||
localTypingPauseTasks[conversationID] = nil
|
||||
transitionLocalChatState(.inactive, in: conversation)
|
||||
@@ -1532,7 +1532,7 @@ final class AppModel: ObservableObject {
|
||||
func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async {
|
||||
guard let account,
|
||||
let message = message(
|
||||
withID: sourceMessage.id,
|
||||
withID: sourceMessage.clientID,
|
||||
in: sourceMessage.conversationID
|
||||
),
|
||||
message.canBeReactedTo,
|
||||
@@ -1586,7 +1586,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) else {
|
||||
return
|
||||
}
|
||||
conversations[index].encryptionPreference = preference
|
||||
@@ -1797,7 +1797,7 @@ final class AppModel: ObservableObject {
|
||||
// contacts screen as soon as Prosody confirms the roster push.
|
||||
rosterContactJIDs.remove(jid.lowercased())
|
||||
case .presence(let jid, let online):
|
||||
if let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) {
|
||||
conversations[index].isOnline = online
|
||||
}
|
||||
case .message(let envelope):
|
||||
@@ -1835,7 +1835,7 @@ final class AppModel: ObservableObject {
|
||||
shouldAutojoin: envelope.joined
|
||||
)
|
||||
if let index = conversations.firstIndex(where: {
|
||||
$0.id == envelope.roomJID.lowercased()
|
||||
$0.jid == envelope.roomJID.lowercased()
|
||||
}) {
|
||||
conversations[index].isGroupJoined = envelope.joined
|
||||
conversations[index].occupantCount = envelope.occupantCount
|
||||
@@ -1885,17 +1885,14 @@ final class AppModel: ObservableObject {
|
||||
persistTask?.cancel()
|
||||
persistTask = nil
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, let archive = self.archive else { return }
|
||||
let snapshot = ChatArchive.Snapshot(
|
||||
conversations: self.conversations,
|
||||
messages: self.messages,
|
||||
guard let self, let store = self.store else { return }
|
||||
try? store.save(
|
||||
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
|
||||
rosterContactJIDs: self.rosterContactJIDs,
|
||||
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: self.mamCheckpoints
|
||||
)
|
||||
try? await archive.save(snapshot)
|
||||
}
|
||||
case .mucArchiveSyncCompleted(let archiveKey, let checkpoint):
|
||||
mamCheckpoints[archiveKey] = checkpoint
|
||||
@@ -2030,7 +2027,7 @@ final class AppModel: ObservableObject {
|
||||
incrementUnread: envelope.isArchived ? false : nil
|
||||
)
|
||||
let pendingKey = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
if let correction = pendingCorrections.removeValue(forKey: pendingKey) {
|
||||
@@ -2113,14 +2110,16 @@ final class AppModel: ObservableObject {
|
||||
|
||||
private func upsertConversation(jid: String, name: String?) {
|
||||
let normalized = jid.lowercased()
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
conversations[index].displayName = name
|
||||
}
|
||||
} else {
|
||||
conversations.append(Conversation(jid: normalized, displayName: name))
|
||||
let conversation = Conversation(jid: normalized, displayName: name)
|
||||
store?.context.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if conversations.first(where: { $0.id == normalized })?.isGroup != true {
|
||||
if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
|
||||
if !isApplyingArchiveBatch {
|
||||
xmpp.prepareDirectChat(with: normalized)
|
||||
}
|
||||
@@ -2147,7 +2146,7 @@ final class AppModel: ObservableObject {
|
||||
} else {
|
||||
initialDisplayName = fallbackName
|
||||
}
|
||||
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
conversations[index].kind = .group
|
||||
if let resolvedName, !resolvedName.isEmpty {
|
||||
conversations[index].displayName = resolvedName
|
||||
@@ -2161,16 +2160,17 @@ final class AppModel: ObservableObject {
|
||||
conversations[index].shouldAutojoin || shouldAutojoin
|
||||
if let invitedBy { conversations[index].invitedBy = invitedBy }
|
||||
} else {
|
||||
conversations.append(
|
||||
Conversation(
|
||||
jid: normalized,
|
||||
displayName: initialDisplayName,
|
||||
encryptionPreference: .inheritGlobal,
|
||||
kind: .group,
|
||||
groupNickname: nickname,
|
||||
shouldAutojoin: shouldAutojoin,
|
||||
invitedBy: invitedBy
|
||||
))
|
||||
let conversation = Conversation(
|
||||
jid: normalized,
|
||||
displayName: initialDisplayName,
|
||||
encryptionPreference: .inheritGlobal,
|
||||
kind: .group,
|
||||
groupNickname: nickname,
|
||||
shouldAutojoin: shouldAutojoin,
|
||||
invitedBy: invitedBy
|
||||
)
|
||||
store?.context.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if !isApplyingArchiveBatch {
|
||||
sortConversations()
|
||||
@@ -2310,7 +2310,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func applyPendingReactions(for message: ChatMessage) {
|
||||
let identifiers = Set([message.id, message.stanzaID].compactMap { $0 })
|
||||
let identifiers = Set([message.clientID, message.stanzaID].compactMap { $0 })
|
||||
guard !identifiers.isEmpty else { return }
|
||||
let matches = pendingReactions.filter { _, envelope in
|
||||
envelope.peerJID.lowercased() == message.conversationID
|
||||
@@ -2394,7 +2394,7 @@ final class AppModel: ObservableObject {
|
||||
in conversation: Conversation
|
||||
) {
|
||||
guard typingIndicatorsEnabled, connectionStatus == .connected else { return }
|
||||
let conversationID = conversation.id.lowercased()
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
let previous = localChatStateByConversation[conversationID]
|
||||
if state == .paused, previous != .composing { return }
|
||||
if state == .inactive, previous == nil { return }
|
||||
@@ -2450,7 +2450,7 @@ final class AppModel: ObservableObject {
|
||||
original.retractedAt == nil || envelope.timestamp >= original.retractedAt!
|
||||
else { return }
|
||||
|
||||
removeCachedMedia(for: original.id)
|
||||
removeCachedMedia(for: original.clientID)
|
||||
messages[index].body = "Сообщение удалено"
|
||||
messages[index].kind = .system
|
||||
messages[index].remoteAttachmentURL = nil
|
||||
@@ -2471,7 +2471,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
pendingCorrections.removeValue(
|
||||
forKey: Self.localDeletionKey(
|
||||
messageID: original.id,
|
||||
messageID: original.clientID,
|
||||
conversationID: original.conversationID
|
||||
))
|
||||
updateConversationPreview(for: messages[index], incrementUnread: false)
|
||||
@@ -2485,13 +2485,13 @@ final class AppModel: ObservableObject {
|
||||
guard
|
||||
!locallyDeletedMessageIDs.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
))
|
||||
else { return false }
|
||||
upsertConversation(jid: message.conversationID, name: nil)
|
||||
if let index = messageIndex(
|
||||
id: message.id,
|
||||
id: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
) {
|
||||
let previous = messages[index]
|
||||
@@ -2551,6 +2551,13 @@ final class AppModel: ObservableObject {
|
||||
merged.byteCount = previous.byteCount
|
||||
}
|
||||
messages[index] = merged
|
||||
if previous !== merged {
|
||||
// `merged` is the freshly-created incoming message; replace the
|
||||
// previously managed object with it so SwiftData does not keep
|
||||
// an orphaned duplicate of the same clientID.
|
||||
store?.context.delete(previous)
|
||||
store?.context.insert(merged)
|
||||
}
|
||||
if let stanzaID = merged.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
Self.localDeletionKey(
|
||||
@@ -2568,15 +2575,16 @@ final class AppModel: ObservableObject {
|
||||
updateConversationPreview(for: merged, incrementUnread: false)
|
||||
return false
|
||||
}
|
||||
store?.context.insert(message)
|
||||
messages.append(message)
|
||||
let insertedIndex = messages.index(before: messages.endIndex)
|
||||
messageIndexByStorageKey[
|
||||
Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)] = insertedIndex
|
||||
if firstMessageIndexByID[message.id] == nil {
|
||||
firstMessageIndexByID[message.id] = insertedIndex
|
||||
if firstMessageIndexByID[message.clientID] == nil {
|
||||
firstMessageIndexByID[message.clientID] = insertedIndex
|
||||
}
|
||||
if let stanzaID = message.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
@@ -2602,14 +2610,14 @@ final class AppModel: ObservableObject {
|
||||
private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) {
|
||||
guard let index = firstMessageIndexByID[id],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == id
|
||||
messages[index].clientID == id
|
||||
else { return }
|
||||
mutation(&messages[index])
|
||||
updateConversationPreview(for: messages[index], incrementUnread: false)
|
||||
}
|
||||
|
||||
private func updateConversationPreview(for message: ChatMessage, incrementUnread: Bool) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == message.conversationID })
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == message.conversationID })
|
||||
else { return }
|
||||
if message.timestamp >= conversations[index].lastActivity {
|
||||
conversations[index].lastActivity = message.timestamp
|
||||
@@ -2624,14 +2632,14 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func rebuildConversationPreview(for conversationID: String) {
|
||||
guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else {
|
||||
guard let index = conversations.firstIndex(where: { $0.jid == conversationID }) else {
|
||||
return
|
||||
}
|
||||
let latest =
|
||||
messages
|
||||
.filter { $0.conversationID == conversationID }
|
||||
.max { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
conversations[index].lastActivity = latest?.timestamp ?? .distantPast
|
||||
@@ -2640,7 +2648,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func conversationName(for jid: String) -> String {
|
||||
conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid
|
||||
conversations.first(where: { $0.jid == jid.lowercased() })?.displayName ?? jid
|
||||
}
|
||||
|
||||
private func sortConversations() {
|
||||
@@ -2660,7 +2668,7 @@ final class AppModel: ObservableObject {
|
||||
)
|
||||
guard let index = messageIndexByStorageKey[key] else { return nil }
|
||||
if messages.indices.contains(index),
|
||||
messages[index].id == id,
|
||||
messages[index].clientID == id,
|
||||
messages[index].conversationID == normalizedConversationID
|
||||
{
|
||||
return index
|
||||
@@ -2716,14 +2724,14 @@ final class AppModel: ObservableObject {
|
||||
firstMessageIndexByID.removeAll(keepingCapacity: true)
|
||||
for (index, message) in messages.enumerated() {
|
||||
let key = Self.localDeletionKey(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
if messageIndexByStorageKey[key] == nil {
|
||||
messageIndexByStorageKey[key] = index
|
||||
}
|
||||
if firstMessageIndexByID[message.id] == nil {
|
||||
firstMessageIndexByID[message.id] = index
|
||||
if firstMessageIndexByID[message.clientID] == nil {
|
||||
firstMessageIndexByID[message.clientID] = index
|
||||
}
|
||||
if let stanzaID = message.stanzaID {
|
||||
let stanzaKey = Self.localDeletionKey(
|
||||
@@ -2749,22 +2757,16 @@ final class AppModel: ObservableObject {
|
||||
private func loadArchive(for jid: String) async {
|
||||
resetArchiveBatchState()
|
||||
resetMediaPreviews()
|
||||
let archive = ChatArchive(accountJID: jid)
|
||||
self.archive = archive
|
||||
let snapshot = await archive.load()
|
||||
let migrateLegacyGroupEncryption = snapshot.schemaVersion < 2
|
||||
conversations = snapshot.conversations.map { stored in
|
||||
var conversation = stored
|
||||
guard let store = try? ArchiveStore(accountJID: jid) else {
|
||||
self.store = nil
|
||||
return
|
||||
}
|
||||
self.store = store
|
||||
let snapshot = store.load()
|
||||
conversations = snapshot.conversations.map { conversation in
|
||||
if conversation.isGroup {
|
||||
conversation.isGroupJoined = false
|
||||
conversation.occupantCount = 0
|
||||
if migrateLegacyGroupEncryption, conversation.encryptionPreference == .disabled {
|
||||
// Older releases forced every MUC to disabled and had no
|
||||
// group encryption control, so this value was not a user
|
||||
// choice. Migrate it once, while preserving later explicit
|
||||
// per-room opt-outs in schema v2 snapshots.
|
||||
conversation.encryptionPreference = .inheritGlobal
|
||||
}
|
||||
}
|
||||
return conversation
|
||||
}
|
||||
@@ -2782,7 +2784,7 @@ final class AppModel: ObservableObject {
|
||||
messages = snapshot.messages.filter {
|
||||
!locallyDeletedMessageIDs.contains(
|
||||
Self.localDeletionKey(
|
||||
messageID: $0.id,
|
||||
messageID: $0.clientID,
|
||||
conversationID: $0.conversationID
|
||||
))
|
||||
}
|
||||
@@ -2798,21 +2800,18 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func schedulePersist() {
|
||||
guard let archive else { return }
|
||||
guard let store else { return }
|
||||
persistTask?.cancel()
|
||||
persistTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
let snapshot = ChatArchive.Snapshot(
|
||||
conversations: self.conversations,
|
||||
messages: self.messages,
|
||||
try? store.save(
|
||||
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
|
||||
rosterContactJIDs: self.rosterContactJIDs,
|
||||
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: self.mamCheckpoints
|
||||
)
|
||||
try? await archive.save(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2950,7 +2949,7 @@ final class AppModel: ObservableObject {
|
||||
if let duration = analysis.duration,
|
||||
let index = firstMessageIndexByID[messageID],
|
||||
messages.indices.contains(index),
|
||||
messages[index].id == messageID,
|
||||
messages[index].clientID == messageID,
|
||||
messages[index].duration == nil
|
||||
{
|
||||
messages[index].duration = duration
|
||||
@@ -2966,7 +2965,7 @@ final class AppModel: ObservableObject {
|
||||
throw AttachmentError.missingRemoteURL
|
||||
}
|
||||
let output = try Self.previewFileURL(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
filename: message.localFilename ?? "attachment",
|
||||
directoryName: directoryName
|
||||
)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
enum Direction: String, Codable, Sendable {
|
||||
@Model
|
||||
final class ChatMessage {
|
||||
enum Direction: String, Codable {
|
||||
case incoming
|
||||
case outgoing
|
||||
}
|
||||
|
||||
enum Delivery: String, Codable, Sendable {
|
||||
enum Delivery: String, Codable {
|
||||
case sending
|
||||
case sent
|
||||
case delivered
|
||||
case failed
|
||||
}
|
||||
|
||||
enum Security: String, Codable, Sendable {
|
||||
enum Security: String, Codable {
|
||||
case omemo
|
||||
case plaintext
|
||||
case decryptionFailed
|
||||
}
|
||||
|
||||
enum Kind: String, Codable, Sendable {
|
||||
enum Kind: String, Codable {
|
||||
case text
|
||||
case attachment
|
||||
case photo
|
||||
@@ -40,7 +42,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
let id: String
|
||||
@Attribute(.unique) var clientID: String
|
||||
var conversationID: String
|
||||
var senderJID: String
|
||||
var body: String
|
||||
@@ -68,6 +70,8 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
var callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
conversationID: String,
|
||||
@@ -97,7 +101,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
callHistory: CallHistoryMetadata? = nil,
|
||||
reactions: [MessageReaction] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.clientID = id
|
||||
self.conversationID = conversationID.lowercased()
|
||||
self.senderJID = Self.normalizedSenderJID(senderJID)
|
||||
self.body = body
|
||||
@@ -126,67 +130,6 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
self.reactions = reactions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case conversationID
|
||||
case senderJID
|
||||
case body
|
||||
case timestamp
|
||||
case direction
|
||||
case delivery
|
||||
case security
|
||||
case kind
|
||||
case remoteAttachmentURL
|
||||
case localFilename
|
||||
case mimeType
|
||||
case duration
|
||||
case byteCount
|
||||
case encryptionFingerprint
|
||||
case editedAt
|
||||
case replyToID
|
||||
case replyToJID
|
||||
case replyPreview
|
||||
case forwardedFrom
|
||||
case retractedAt
|
||||
case originID
|
||||
case stanzaID
|
||||
case senderDisplayName
|
||||
case isGroupMessage
|
||||
case callHistory
|
||||
case reactions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
|
||||
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
|
||||
body = try values.decode(String.self, forKey: .body)
|
||||
timestamp = try values.decode(Date.self, forKey: .timestamp)
|
||||
direction = try values.decode(Direction.self, forKey: .direction)
|
||||
delivery = try values.decode(Delivery.self, forKey: .delivery)
|
||||
security = try values.decode(Security.self, forKey: .security)
|
||||
kind = try values.decode(Kind.self, forKey: .kind)
|
||||
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
|
||||
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
|
||||
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
|
||||
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
|
||||
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
|
||||
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
|
||||
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
|
||||
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
|
||||
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
|
||||
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
|
||||
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
|
||||
originID = try values.decodeIfPresent(String.self, forKey: .originID)
|
||||
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
|
||||
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
|
||||
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
|
||||
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
|
||||
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
|
||||
}
|
||||
|
||||
var canBeEdited: Bool {
|
||||
!isRetracted
|
||||
&& !isGroupMessage
|
||||
@@ -218,7 +161,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
var replyIdentifier: String? {
|
||||
isGroupMessage ? stanzaID : id
|
||||
isGroupMessage ? stanzaID : clientID
|
||||
}
|
||||
|
||||
var quotePreview: String {
|
||||
|
||||
@@ -3,11 +3,21 @@ import Foundation
|
||||
/// A cached presentation row for the selected chat. Building day boundaries
|
||||
/// once per message mutation avoids walking the full history during every
|
||||
/// scroll-position or composer-state update.
|
||||
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
|
||||
struct ChatTimelineEntry: Identifiable, Hashable {
|
||||
let message: ChatMessage
|
||||
let startsNewDay: Bool
|
||||
|
||||
var id: String { message.id }
|
||||
var id: String { message.clientID }
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.message.clientID == rhs.message.clientID
|
||||
&& lhs.startsNewDay == rhs.startsNewDay
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(message.clientID)
|
||||
hasher.combine(startsNewDay)
|
||||
}
|
||||
|
||||
static func make(
|
||||
from messages: [ChatMessage],
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
@Model
|
||||
final class Conversation {
|
||||
enum Kind: String, Codable {
|
||||
case direct
|
||||
case group
|
||||
}
|
||||
|
||||
let id: String
|
||||
var jid: String
|
||||
@Attribute(.unique) var jid: String
|
||||
var displayName: String
|
||||
var lastMessage: String
|
||||
var lastActivity: Date
|
||||
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
init(
|
||||
jid: String,
|
||||
displayName: String? = nil,
|
||||
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
invitedBy: String? = nil
|
||||
) {
|
||||
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.id = normalized
|
||||
self.jid = normalized
|
||||
self.displayName = displayName?.nilIfBlank ?? normalized
|
||||
self.lastMessage = lastMessage
|
||||
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
||||
self.invitedBy = invitedBy?.nilIfBlank
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case jid
|
||||
case displayName
|
||||
case lastMessage
|
||||
case lastActivity
|
||||
case unreadCount
|
||||
case isOnline
|
||||
case isPinned
|
||||
case encryptionPreference
|
||||
case kind
|
||||
case groupNickname
|
||||
case isGroupJoined
|
||||
case shouldAutojoin
|
||||
case occupantCount
|
||||
case invitedBy
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
jid = try values.decode(String.self, forKey: .jid)
|
||||
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
|
||||
displayName = try values.decode(String.self, forKey: .displayName)
|
||||
lastMessage = try values.decode(String.self, forKey: .lastMessage)
|
||||
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
|
||||
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
|
||||
isOnline = try values.decode(Bool.self, forKey: .isOnline)
|
||||
isPinned = try values.decode(Bool.self, forKey: .isPinned)
|
||||
encryptionPreference = try values.decodeIfPresent(
|
||||
EncryptionPreference.self,
|
||||
forKey: .encryptionPreference
|
||||
) ?? .inheritGlobal
|
||||
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
|
||||
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
|
||||
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
|
||||
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
|
||||
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
|
||||
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
|
||||
}
|
||||
|
||||
var isGroup: Bool { kind == .group }
|
||||
|
||||
var initials: String {
|
||||
|
||||
@@ -80,7 +80,7 @@ enum MessageReactionPolicy {
|
||||
|
||||
extension ChatMessage {
|
||||
var reactionIdentifier: String? {
|
||||
isGroupMessage ? stanzaID : id
|
||||
isGroupMessage ? stanzaID : clientID
|
||||
}
|
||||
|
||||
var canBeReactedTo: Bool {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Codable representation of a single durable MAM checkpoint, keyed by the
|
||||
/// archive it belongs to (account or a specific MUC).
|
||||
struct MAMCheckpointEntry: Codable, Equatable, Sendable {
|
||||
let key: MAMArchiveKey
|
||||
let checkpoint: MAMArchiveCheckpoint
|
||||
}
|
||||
|
||||
/// Singleton per-account metadata that used to live in the JSON snapshot:
|
||||
/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
|
||||
@Model
|
||||
final class ArchiveMetadata {
|
||||
@Attribute(.unique) var accountJID: String
|
||||
var locallyDeletedMessageIDs: [String]
|
||||
var rosterContactJIDs: [String]
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMCheckpointEntry]
|
||||
|
||||
init(
|
||||
accountJID: String,
|
||||
locallyDeletedMessageIDs: [String] = [],
|
||||
rosterContactJIDs: [String] = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMCheckpointEntry] = []
|
||||
) {
|
||||
self.accountJID = accountJID
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
|
||||
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
|
||||
/// once, and exposes load/save/erase.
|
||||
@MainActor
|
||||
final class ArchiveStore {
|
||||
struct Loaded {
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
let container: ModelContainer
|
||||
let context: ModelContext
|
||||
private let accountJID: String
|
||||
private let storeURL: URL
|
||||
private let legacyJSONURL: URL
|
||||
private(set) var metadata: ArchiveMetadata?
|
||||
|
||||
init(accountJID: String) throws {
|
||||
self.accountJID = accountJID
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let hash = Self.stableHash(accountJID)
|
||||
storeURL = directory.appendingPathComponent("\(hash).store")
|
||||
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
|
||||
|
||||
let schema = Schema([
|
||||
Conversation.self,
|
||||
ChatMessage.self,
|
||||
ArchiveMetadata.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, url: storeURL)
|
||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
context = ModelContext(container)
|
||||
context.autosaveEnabled = false
|
||||
|
||||
migrateLegacyJSONIfNeeded()
|
||||
}
|
||||
|
||||
func load() -> Loaded {
|
||||
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
|
||||
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
|
||||
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
|
||||
|
||||
let metadata = fetchMetadata()
|
||||
let mamCheckpoints = Dictionary(
|
||||
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
return Loaded(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Set(metadata.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: mamCheckpoints
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists the passed metadata plus all pending model mutations.
|
||||
func save(
|
||||
locallyDeletedMessageIDs: Set<String>,
|
||||
rosterContactJIDs: Set<String>,
|
||||
lastSuccessfulMAMSync: Date?,
|
||||
lastSuccessfulMAMCursor: String?,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
) throws {
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
|
||||
metadata.rosterContactJIDs = Array(rosterContactJIDs)
|
||||
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
|
||||
metadata.mamCheckpoints = mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
try context.delete(model: Conversation.self)
|
||||
try context.delete(model: ChatMessage.self)
|
||||
try context.delete(model: ArchiveMetadata.self)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
private func fetchMetadata() -> ArchiveMetadata {
|
||||
if let metadata { return metadata }
|
||||
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
|
||||
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
|
||||
context.insert(metadata)
|
||||
self.metadata = metadata
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func migrateLegacyJSONIfNeeded() {
|
||||
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
|
||||
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
|
||||
return
|
||||
}
|
||||
for conversation in imported.conversations {
|
||||
context.insert(conversation)
|
||||
}
|
||||
for message in imported.messages {
|
||||
context.insert(message)
|
||||
}
|
||||
let metadata = fetchMetadata()
|
||||
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
|
||||
metadata.rosterContactJIDs = imported.rosterContactJIDs
|
||||
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
|
||||
metadata.mamCheckpoints = imported.mamCheckpoints
|
||||
try? context.save()
|
||||
try? FileManager.default.removeItem(at: legacyJSONURL)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
|
||||
(partial ^ UInt64(byte)) &* 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
actor ChatArchive {
|
||||
struct Snapshot: Codable, Sendable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
var schemaVersion: Int
|
||||
var conversations: [Conversation]
|
||||
var messages: [ChatMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
|
||||
init(
|
||||
conversations: [Conversation],
|
||||
messages: [ChatMessage],
|
||||
locallyDeletedMessageIDs: Set<String> = [],
|
||||
rosterContactJIDs: Set<String> = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.conversations = conversations
|
||||
self.messages = messages
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
lastSuccessfulMAMCursor
|
||||
)
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case conversations
|
||||
case messages
|
||||
case locallyDeletedMessageIDs
|
||||
case rosterContactJIDs
|
||||
case lastSuccessfulMAMSync
|
||||
case lastSuccessfulMAMCursor
|
||||
case mamCheckpoints
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
conversations = try values.decode([Conversation].self, forKey: .conversations)
|
||||
messages = try values.decode([ChatMessage].self, forKey: .messages)
|
||||
locallyDeletedMessageIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .locallyDeletedMessageIDs
|
||||
) ?? []
|
||||
rosterContactJIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .rosterContactJIDs
|
||||
) ?? []
|
||||
lastSuccessfulMAMSync = try values.decodeIfPresent(
|
||||
Date.self,
|
||||
forKey: .lastSuccessfulMAMSync
|
||||
)
|
||||
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
|
||||
)
|
||||
mamCheckpoints = try values.decodeIfPresent(
|
||||
[MAMArchiveKey: MAMArchiveCheckpoint].self, forKey: .mamCheckpoints
|
||||
) ?? [:]
|
||||
}
|
||||
|
||||
static let empty = Snapshot(conversations: [], messages: [])
|
||||
}
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(accountJID: String) {
|
||||
let fileManager = FileManager.default
|
||||
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? fileManager.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
|
||||
|
||||
encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
}
|
||||
|
||||
func load() -> Snapshot {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
|
||||
return .empty
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func save(_ snapshot: Snapshot) throws {
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
|
||||
(partial ^ UInt64(byte)) &* 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16, uppercase: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
|
||||
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
|
||||
/// objects once, so existing history survives the migration from the JSON file.
|
||||
enum LegacyArchiveImporter {
|
||||
struct LegacyConversation: Codable {
|
||||
enum Kind: String, Codable { case direct, group }
|
||||
var id: String
|
||||
var jid: String
|
||||
var displayName: String
|
||||
var lastMessage: String
|
||||
var lastActivity: Date
|
||||
var unreadCount: Int
|
||||
var isOnline: Bool
|
||||
var isPinned: Bool
|
||||
var encryptionPreference: EncryptionPreference
|
||||
var kind: Kind
|
||||
var groupNickname: String?
|
||||
var isGroupJoined: Bool
|
||||
var shouldAutojoin: Bool
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
}
|
||||
|
||||
struct LegacyMessage: Codable {
|
||||
enum Direction: String, Codable { case incoming, outgoing }
|
||||
enum Delivery: String, Codable { case sending, sent, delivered, failed }
|
||||
enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
|
||||
enum Kind: String, Codable {
|
||||
case text, attachment, photo, video, audio, voice, videoNote, location, system
|
||||
}
|
||||
var id: String
|
||||
var conversationID: String
|
||||
var senderJID: String
|
||||
var body: String
|
||||
var timestamp: Date
|
||||
var direction: Direction
|
||||
var delivery: Delivery
|
||||
var security: Security
|
||||
var kind: Kind
|
||||
var remoteAttachmentURL: String?
|
||||
var localFilename: String?
|
||||
var mimeType: String?
|
||||
var duration: TimeInterval?
|
||||
var byteCount: Int?
|
||||
var encryptionFingerprint: String?
|
||||
var editedAt: Date?
|
||||
var replyToID: String?
|
||||
var replyToJID: String?
|
||||
var replyPreview: String?
|
||||
var forwardedFrom: String?
|
||||
var retractedAt: Date?
|
||||
var originID: String?
|
||||
var stanzaID: String?
|
||||
var senderDisplayName: String?
|
||||
var isGroupMessage: Bool
|
||||
var callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
}
|
||||
|
||||
struct Snapshot: Codable {
|
||||
var schemaVersion: Int
|
||||
var conversations: [LegacyConversation]
|
||||
var messages: [LegacyMessage]
|
||||
var locallyDeletedMessageIDs: Set<String>
|
||||
var rosterContactJIDs: Set<String>
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
}
|
||||
|
||||
struct Imported {
|
||||
let conversations: [Conversation]
|
||||
let messages: [ChatMessage]
|
||||
let locallyDeletedMessageIDs: [String]
|
||||
let rosterContactJIDs: [String]
|
||||
let lastSuccessfulMAMSync: Date?
|
||||
let lastSuccessfulMAMCursor: String?
|
||||
let mamCheckpoints: [MAMCheckpointEntry]
|
||||
}
|
||||
|
||||
static func importIfNeeded(from url: URL) throws -> Imported? {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
let snapshot = try decoder.decode(Snapshot.self, from: data)
|
||||
|
||||
let conversations = snapshot.conversations.map { legacy -> Conversation in
|
||||
let conversation = Conversation(
|
||||
jid: legacy.jid,
|
||||
displayName: legacy.displayName,
|
||||
lastMessage: legacy.lastMessage,
|
||||
lastActivity: legacy.lastActivity,
|
||||
unreadCount: legacy.unreadCount,
|
||||
isOnline: legacy.isOnline,
|
||||
isPinned: legacy.isPinned,
|
||||
encryptionPreference: legacy.encryptionPreference,
|
||||
kind: legacy.kind == .group ? .group : .direct,
|
||||
groupNickname: legacy.groupNickname,
|
||||
isGroupJoined: legacy.isGroupJoined,
|
||||
shouldAutojoin: legacy.shouldAutojoin,
|
||||
occupantCount: legacy.occupantCount,
|
||||
invitedBy: legacy.invitedBy
|
||||
)
|
||||
return conversation
|
||||
}
|
||||
|
||||
let conversationByJID = Dictionary(
|
||||
conversations.map { ($0.jid, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let messages = snapshot.messages.map { legacy -> ChatMessage in
|
||||
let message = ChatMessage(
|
||||
id: legacy.id,
|
||||
conversationID: legacy.conversationID,
|
||||
senderJID: legacy.senderJID,
|
||||
body: legacy.body,
|
||||
timestamp: legacy.timestamp,
|
||||
direction: legacy.direction == .outgoing ? .outgoing : .incoming,
|
||||
delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
|
||||
security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
|
||||
kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
|
||||
remoteAttachmentURL: legacy.remoteAttachmentURL,
|
||||
localFilename: legacy.localFilename,
|
||||
mimeType: legacy.mimeType,
|
||||
duration: legacy.duration,
|
||||
byteCount: legacy.byteCount,
|
||||
encryptionFingerprint: legacy.encryptionFingerprint,
|
||||
editedAt: legacy.editedAt,
|
||||
replyToID: legacy.replyToID,
|
||||
replyToJID: legacy.replyToJID,
|
||||
replyPreview: legacy.replyPreview,
|
||||
forwardedFrom: legacy.forwardedFrom,
|
||||
retractedAt: legacy.retractedAt,
|
||||
originID: legacy.originID,
|
||||
stanzaID: legacy.stanzaID,
|
||||
senderDisplayName: legacy.senderDisplayName,
|
||||
isGroupMessage: legacy.isGroupMessage,
|
||||
callHistory: legacy.callHistory,
|
||||
reactions: legacy.reactions
|
||||
)
|
||||
message.conversation = conversationByJID[legacy.conversationID.lowercased()]
|
||||
return message
|
||||
}
|
||||
|
||||
return Imported(
|
||||
conversations: conversations,
|
||||
messages: messages,
|
||||
locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
|
||||
rosterContactJIDs: Array(snapshot.rosterContactJIDs),
|
||||
lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
|
||||
lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
|
||||
mamCheckpoints: snapshot.mamCheckpoints.map {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ enum ChatMediaImageCache {
|
||||
for message: ChatMessage,
|
||||
data: Data
|
||||
) -> ChatMediaPlatformImage? {
|
||||
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
|
||||
let cacheKey = "\(message.conversationID)\u{1F}\(message.clientID)" as NSString
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
messages: [ChatMessage]
|
||||
) -> Data? {
|
||||
let visibleConversations = Array(conversations.prefix(20))
|
||||
let visibleConversationIDs = Set(visibleConversations.map(\.id))
|
||||
let visibleConversationIDs = Set(visibleConversations.map(\.jid))
|
||||
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
|
||||
|
||||
// Keep a bounded, chronologically sorted window. MAM can append an old
|
||||
@@ -103,10 +103,10 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
jid: conversation.jid,
|
||||
name: conversation.displayName,
|
||||
unread: conversation.unreadCount,
|
||||
messages: (recentMessagesByConversation[conversation.id] ?? [])
|
||||
messages: (recentMessagesByConversation[conversation.jid] ?? [])
|
||||
.map {
|
||||
PhoneWatchSnapshot.Message(
|
||||
id: $0.id,
|
||||
id: $0.clientID,
|
||||
body: $0.previewText,
|
||||
timestamp: $0.timestamp,
|
||||
outgoing: $0.direction == .outgoing,
|
||||
@@ -128,7 +128,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
let middle = lowerBound + (upperBound - lowerBound) / 2
|
||||
let existing = sortedMessages[middle]
|
||||
let existingComesFirst = existing.timestamp < message.timestamp
|
||||
|| (existing.timestamp == message.timestamp && existing.id < message.id)
|
||||
|| (existing.timestamp == message.timestamp && existing.clientID < message.clientID)
|
||||
if existingComesFirst {
|
||||
lowerBound = middle + 1
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ struct CallView: View {
|
||||
let call: CallSnapshot
|
||||
|
||||
private var conversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
|
||||
model.conversations.first(where: { $0.jid == call.peerJID.lowercased() })
|
||||
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ private enum EmojiPickerPresentation: Identifiable {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .reaction(let message): return "reaction-\(message.id)"
|
||||
case .reaction(let message): return "reaction-\(message.clientID)"
|
||||
case .composer: return "composer"
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ struct ChatView: View {
|
||||
.overlay {
|
||||
replyThreadOverlay
|
||||
}
|
||||
.onChange(of: conversation.id) { _, _ in
|
||||
.onChange(of: conversation.jid) { _, _ in
|
||||
hasCompletedInitialScroll = false
|
||||
isNearTimelineBottom = true
|
||||
historyLoadAnchorID = nil
|
||||
@@ -284,7 +284,7 @@ struct ChatView: View {
|
||||
@ViewBuilder
|
||||
private var replyThreadOverlay: some View {
|
||||
if let selection = replyThreadSelection,
|
||||
let root = model.message(withID: selection.rootID, in: conversation.id) {
|
||||
let root = model.message(withID: selection.rootID, in: conversation.jid) {
|
||||
ReplyThreadOverlay(
|
||||
model: model,
|
||||
rootMessage: root,
|
||||
@@ -332,7 +332,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var selectedTimelineMessages: [ChatMessage] {
|
||||
model.selectedMessages.filter { selectedMessageIDs.contains($0.id) }
|
||||
model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) }
|
||||
}
|
||||
|
||||
private var isSelectingMessages: Bool {
|
||||
@@ -396,7 +396,7 @@ struct ChatView: View {
|
||||
model: model,
|
||||
message: message,
|
||||
isSelectionMode: isSelectingMessages,
|
||||
isSelected: selectedMessageIDs.contains(message.id),
|
||||
isSelected: selectedMessageIDs.contains(message.clientID),
|
||||
onAttachmentTap: {
|
||||
Task { await model.previewAttachment(message) }
|
||||
},
|
||||
@@ -417,7 +417,7 @@ struct ChatView: View {
|
||||
onReplyTap: { targetID in
|
||||
presentReplyThread(
|
||||
rootID: targetID,
|
||||
selectedReplyID: message.id
|
||||
selectedReplyID: message.clientID
|
||||
)
|
||||
},
|
||||
onReact: { emoji in
|
||||
@@ -427,7 +427,7 @@ struct ChatView: View {
|
||||
emojiPickerPresentation = .reaction(message)
|
||||
}
|
||||
)
|
||||
.id(message.id)
|
||||
.id(message.clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ struct ChatView: View {
|
||||
|
||||
timelineBottomSentinel
|
||||
#if os(iOS)
|
||||
ChatScrollMetricsObserver(identity: conversation.id) { isNearBottom in
|
||||
ChatScrollMetricsObserver(identity: conversation.jid) { isNearBottom in
|
||||
guard isNearTimelineBottom != isNearBottom else { return }
|
||||
isNearTimelineBottom = isNearBottom
|
||||
}
|
||||
@@ -766,13 +766,13 @@ struct ChatView: View {
|
||||
|
||||
private var editingMessage: ChatMessage? {
|
||||
guard let editingMessageID else { return nil }
|
||||
return model.selectedMessages.first { $0.id == editingMessageID }
|
||||
return model.selectedMessages.first { $0.clientID == editingMessageID }
|
||||
}
|
||||
|
||||
private var replyingToMessage: ChatMessage? {
|
||||
guard let replyingToMessageID else { return nil }
|
||||
return model.selectedMessages.first {
|
||||
$0.id == replyingToMessageID && $0.canBeRepliedTo
|
||||
$0.clientID == replyingToMessageID && $0.canBeRepliedTo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +790,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var liveConversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
|
||||
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
|
||||
}
|
||||
|
||||
private var chatNavigationTitle: some View {
|
||||
@@ -1212,7 +1212,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func presentReplyThread(rootID: String, selectedReplyID: String) {
|
||||
guard model.message(withID: rootID, in: conversation.id) != nil else {
|
||||
guard model.message(withID: rootID, in: conversation.jid) != nil else {
|
||||
model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю."
|
||||
return
|
||||
}
|
||||
@@ -1234,7 +1234,7 @@ struct ChatView: View {
|
||||
for root: ChatMessage,
|
||||
selectedReplyID: String
|
||||
) -> [ChatMessage] {
|
||||
var sourceIdentifiers: Set<String> = [root.id]
|
||||
var sourceIdentifiers: Set<String> = [root.clientID]
|
||||
if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) }
|
||||
if let replyIdentifier = root.replyIdentifier {
|
||||
sourceIdentifiers.insert(replyIdentifier)
|
||||
@@ -1244,12 +1244,12 @@ struct ChatView: View {
|
||||
guard let replyToID = candidate.replyToID else { return false }
|
||||
return sourceIdentifiers.contains(replyToID)
|
||||
}
|
||||
if !replies.contains(where: { $0.id == selectedReplyID }),
|
||||
let selected = model.message(withID: selectedReplyID, in: conversation.id) {
|
||||
if !replies.contains(where: { $0.clientID == selectedReplyID }),
|
||||
let selected = model.message(withID: selectedReplyID, in: conversation.jid) {
|
||||
replies.append(selected)
|
||||
}
|
||||
return replies.sorted { lhs, rhs in
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
|
||||
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
}
|
||||
@@ -1407,7 +1407,7 @@ struct ChatView: View {
|
||||
guard message.canBeEdited else { return }
|
||||
cancelComposerRecording(feedback: false)
|
||||
replyingToMessageID = nil
|
||||
editingMessageID = message.id
|
||||
editingMessageID = message.clientID
|
||||
draft = message.body
|
||||
isComposerFocused = true
|
||||
}
|
||||
@@ -1429,14 +1429,14 @@ struct ChatView: View {
|
||||
dismissKeyboard()
|
||||
if editingMessageID != nil { cancelEditing() }
|
||||
cancelReplying()
|
||||
selectedMessageIDs.insert(message.id)
|
||||
selectedMessageIDs.insert(message.clientID)
|
||||
}
|
||||
|
||||
private func toggleMessageSelection(_ message: ChatMessage) {
|
||||
if selectedMessageIDs.contains(message.id) {
|
||||
selectedMessageIDs.remove(message.id)
|
||||
if selectedMessageIDs.contains(message.clientID) {
|
||||
selectedMessageIDs.remove(message.clientID)
|
||||
} else {
|
||||
selectedMessageIDs.insert(message.id)
|
||||
selectedMessageIDs.insert(message.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,7 +1457,7 @@ struct ChatView: View {
|
||||
editingMessageID = nil
|
||||
draft = ""
|
||||
}
|
||||
replyingToMessageID = message.id
|
||||
replyingToMessageID = message.clientID
|
||||
isComposerFocused = true
|
||||
}
|
||||
|
||||
@@ -1526,7 +1526,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func perform(_ action: MessageDestructiveAction) {
|
||||
let ids = Set(action.messages.map(\.id))
|
||||
let ids = Set(action.messages.map(\.clientID))
|
||||
if let editingMessageID, ids.contains(editingMessageID) {
|
||||
cancelEditing()
|
||||
}
|
||||
@@ -1536,11 +1536,11 @@ struct ChatView: View {
|
||||
selectedMessageIDs.subtract(ids)
|
||||
switch action.kind {
|
||||
case .localDelete:
|
||||
model.deleteMessagesLocally(ids: ids, in: conversation.id)
|
||||
model.deleteMessagesLocally(ids: ids, in: conversation.jid)
|
||||
case .retract:
|
||||
Task {
|
||||
for message in action.messages where message.canBeRetracted {
|
||||
await model.retractMessage(id: message.id)
|
||||
await model.retractMessage(id: message.clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2161,7 +2161,7 @@ private struct MessageDestructiveAction: Identifiable {
|
||||
let messages: [ChatMessage]
|
||||
|
||||
var id: String {
|
||||
let messageIDs = messages.map(\.id).sorted().joined(separator: "-")
|
||||
let messageIDs = messages.map(\.clientID).sorted().joined(separator: "-")
|
||||
return "\(kind == .localDelete ? "local" : "retract")-\(messageIDs)"
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ struct AudioMessagePlayer: View {
|
||||
}
|
||||
|
||||
private var isCurrent: Bool {
|
||||
playback.currentMessageID == message.id
|
||||
playback.currentMessageID == message.clientID
|
||||
}
|
||||
|
||||
private var isPlaying: Bool {
|
||||
@@ -194,7 +194,7 @@ struct AudioMessagePlayer: View {
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: downloadedURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
@@ -203,7 +203,7 @@ struct AudioMessagePlayer: View {
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
@@ -213,7 +213,7 @@ struct AudioMessagePlayer: View {
|
||||
private func seek(_ fraction: Double) {
|
||||
guard let localURL else { return }
|
||||
playback.seek(
|
||||
messageID: message.id,
|
||||
messageID: message.clientID,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice,
|
||||
|
||||
@@ -124,7 +124,7 @@ struct PhotoAttachmentPreview: View {
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
if model.mediaViewerItem?.id != message.clientID,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
|
||||
@@ -112,10 +112,10 @@ struct ReplyThreadOverlay: View {
|
||||
model: model,
|
||||
message: reply,
|
||||
maximumBubbleWidth: maximumBubbleWidth,
|
||||
isSelected: reply.id == selectedReplyID,
|
||||
showsDeliveryStatus: reply.id == selectedReplyID
|
||||
isSelected: reply.clientID == selectedReplyID,
|
||||
showsDeliveryStatus: reply.clientID == selectedReplyID
|
||||
)
|
||||
.id(reply.id)
|
||||
.id(reply.clientID)
|
||||
}
|
||||
}
|
||||
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
||||
|
||||
@@ -142,7 +142,7 @@ struct VideoAttachmentPreview: View {
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
if model.mediaViewerItem?.id != message.clientID,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ struct VideoNotePreview: View {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
||||
guard let activeID = notification.object as? String,
|
||||
activeID != message.id else { return }
|
||||
activeID != message.clientID else { return }
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
@@ -146,7 +146,7 @@ struct VideoNotePreview: View {
|
||||
model.audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
object: message.clientID
|
||||
)
|
||||
player.play()
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ struct GroupInfoView: View {
|
||||
}
|
||||
|
||||
private var liveConversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
|
||||
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
|
||||
}
|
||||
|
||||
private var parsedInvitees: [String] {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct MainChatView: View {
|
||||
|
||||
private var filteredRosterContacts: [Conversation] {
|
||||
model.conversations
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ struct MainChatView: View {
|
||||
} detail: {
|
||||
if let conversation = model.selectedConversation {
|
||||
ChatView(model: model, conversation: conversation)
|
||||
.id(conversation.id)
|
||||
.id(conversation.jid)
|
||||
} else {
|
||||
WelcomeDetailView()
|
||||
}
|
||||
@@ -120,7 +120,7 @@ struct MainChatView: View {
|
||||
imageData: model.avatarData(for: conversation.jid),
|
||||
isEncrypted: model.encryptionEnabled(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ struct MainChatView: View {
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
@@ -164,7 +164,7 @@ struct MainChatView: View {
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.tag(conversation.jid)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import SwiftData
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
@MainActor
|
||||
final class ArchiveStoreTests: XCTestCase {
|
||||
func testStoreRoundTrip() async throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let store = try ArchiveStore(accountJID: jid)
|
||||
defer { try? store.erase() }
|
||||
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let message = ChatMessage(
|
||||
conversationID: conversation.jid,
|
||||
senderJID: jid,
|
||||
body: "Encrypted hello",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
store.context.insert(conversation)
|
||||
store.context.insert(message)
|
||||
|
||||
try store.save(
|
||||
locallyDeletedMessageIDs: ["deleted-message-id"],
|
||||
rosterContactJIDs: [conversation.jid],
|
||||
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
lastSuccessfulMAMCursor: "mam-cursor-42",
|
||||
mamCheckpoints: [:]
|
||||
)
|
||||
|
||||
let loaded = store.load()
|
||||
XCTAssertEqual(loaded.conversations.map(\.jid), [conversation.jid])
|
||||
XCTAssertEqual(loaded.messages.map(\.clientID), [message.clientID])
|
||||
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
|
||||
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
|
||||
XCTAssertEqual(
|
||||
loaded.lastSuccessfulMAMSync,
|
||||
Date(timeIntervalSince1970: 1_700_000_000)
|
||||
)
|
||||
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
|
||||
}
|
||||
|
||||
func testLegacyImportMapsConversationAndMessage() throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let legacy = LegacyArchiveImporter.Snapshot(
|
||||
schemaVersion: 1,
|
||||
conversations: [
|
||||
LegacyArchiveImporter.LegacyConversation(
|
||||
id: "bob@example.org",
|
||||
jid: "bob@example.org",
|
||||
displayName: "Bob",
|
||||
lastMessage: "",
|
||||
lastActivity: .distantPast,
|
||||
unreadCount: 0,
|
||||
isOnline: false,
|
||||
isPinned: false,
|
||||
encryptionPreference: .inheritGlobal,
|
||||
kind: .direct,
|
||||
groupNickname: nil,
|
||||
isGroupJoined: false,
|
||||
shouldAutojoin: false,
|
||||
occupantCount: 0,
|
||||
invitedBy: nil
|
||||
)
|
||||
],
|
||||
messages: [
|
||||
LegacyArchiveImporter.LegacyMessage(
|
||||
id: "msg-1",
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: jid,
|
||||
body: "Hello",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo,
|
||||
kind: .text,
|
||||
remoteAttachmentURL: nil,
|
||||
localFilename: nil,
|
||||
mimeType: nil,
|
||||
duration: nil,
|
||||
byteCount: nil,
|
||||
encryptionFingerprint: nil,
|
||||
editedAt: nil,
|
||||
replyToID: nil,
|
||||
replyToJID: nil,
|
||||
replyPreview: nil,
|
||||
forwardedFrom: nil,
|
||||
retractedAt: nil,
|
||||
originID: nil,
|
||||
stanzaID: nil,
|
||||
senderDisplayName: nil,
|
||||
isGroupMessage: false,
|
||||
callHistory: nil,
|
||||
reactions: []
|
||||
)
|
||||
],
|
||||
locallyDeletedMessageIDs: [],
|
||||
rosterContactJIDs: [],
|
||||
lastSuccessfulMAMSync: nil,
|
||||
lastSuccessfulMAMCursor: nil,
|
||||
mamCheckpoints: [:]
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
let data = try encoder.encode(legacy)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try data.write(to: url)
|
||||
|
||||
let imported = try LegacyArchiveImporter.importIfNeeded(from: url)
|
||||
XCTAssertNotNil(imported)
|
||||
XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"])
|
||||
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
|
||||
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org")
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class ChatArchiveTests: XCTestCase {
|
||||
func testSnapshotRoundTrip() async throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let archive = ChatArchive(accountJID: jid)
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let message = ChatMessage(
|
||||
conversationID: conversation.id,
|
||||
senderJID: jid,
|
||||
body: "Encrypted hello",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
|
||||
try await archive.save(.init(
|
||||
conversations: [conversation],
|
||||
messages: [message],
|
||||
locallyDeletedMessageIDs: ["deleted-message-id"],
|
||||
rosterContactJIDs: [conversation.jid],
|
||||
lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
lastSuccessfulMAMCursor: "mam-cursor-42"
|
||||
))
|
||||
let loaded = await archive.load()
|
||||
|
||||
XCTAssertEqual(loaded.conversations, [conversation])
|
||||
XCTAssertEqual(loaded.messages, [message])
|
||||
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"]))
|
||||
XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid]))
|
||||
XCTAssertEqual(
|
||||
loaded.lastSuccessfulMAMSync,
|
||||
Date(timeIntervalSince1970: 1_700_000_000)
|
||||
)
|
||||
XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42")
|
||||
XCTAssertEqual(loaded.schemaVersion, ChatArchive.Snapshot.currentSchemaVersion)
|
||||
try await archive.erase()
|
||||
}
|
||||
|
||||
func testLegacySnapshotDefaultsToSchemaOne() throws {
|
||||
let data = Data(
|
||||
#"{"conversations":[],"messages":[],"locallyDeletedMessageIDs":[]}"#.utf8
|
||||
)
|
||||
|
||||
let snapshot = try JSONDecoder().decode(ChatArchive.Snapshot.self, from: data)
|
||||
|
||||
XCTAssertEqual(snapshot.schemaVersion, 1)
|
||||
XCTAssertTrue(snapshot.rosterContactJIDs.isEmpty)
|
||||
XCTAssertNil(snapshot.lastSuccessfulMAMSync)
|
||||
XCTAssertNil(snapshot.lastSuccessfulMAMCursor)
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,6 @@ final class EncryptionPreferenceTests: XCTestCase {
|
||||
XCTAssertFalse(EncryptionPreference.disabled.resolved(globalEnabled: true))
|
||||
}
|
||||
|
||||
func testConversationWithoutEncryptionFieldStillDecodes() throws {
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let encoded = try JSONEncoder().encode(conversation)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "encryptionPreference")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
||||
|
||||
let decoded = try JSONDecoder().decode(Conversation.self, from: legacyData)
|
||||
|
||||
XCTAssertEqual(decoded.encryptionPreference, .inheritGlobal)
|
||||
XCTAssertEqual(decoded.jid, conversation.jid)
|
||||
}
|
||||
|
||||
func testGlobalSettingIsStoredPerAccount() throws {
|
||||
let suite = "LumaTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
|
||||
@@ -3,7 +3,7 @@ import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class GroupConversationTests: XCTestCase {
|
||||
func testGroupConversationRoundTrips() throws {
|
||||
func testGroupConversationProperties() {
|
||||
let conversation = Conversation(
|
||||
jid: "team@conference.example.org",
|
||||
displayName: "Команда",
|
||||
@@ -16,33 +16,11 @@ final class GroupConversationTests: XCTestCase {
|
||||
invitedBy: "bob@example.org"
|
||||
)
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Conversation.self,
|
||||
from: JSONEncoder().encode(conversation)
|
||||
)
|
||||
|
||||
XCTAssertTrue(decoded.isGroup)
|
||||
XCTAssertEqual(decoded.groupNickname, "Alice")
|
||||
XCTAssertEqual(decoded.occupantCount, 4)
|
||||
XCTAssertEqual(decoded.invitedBy, "bob@example.org")
|
||||
}
|
||||
|
||||
func testLegacyConversationDefaultsToDirect() throws {
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let encoded = try JSONEncoder().encode(conversation)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
for key in ["kind", "groupNickname", "isGroupJoined", "shouldAutojoin", "occupantCount", "invitedBy"] {
|
||||
object.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Conversation.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertFalse(decoded.isGroup)
|
||||
XCTAssertFalse(decoded.isGroupJoined)
|
||||
XCTAssertEqual(decoded.occupantCount, 0)
|
||||
XCTAssertTrue(conversation.isGroup)
|
||||
XCTAssertEqual(conversation.groupNickname, "Alice")
|
||||
XCTAssertEqual(conversation.occupantCount, 4)
|
||||
XCTAssertEqual(conversation.invitedBy, "bob@example.org")
|
||||
XCTAssertEqual(conversation.encryptionPreference, .disabled)
|
||||
}
|
||||
|
||||
func testNewGroupInheritsGlobalEncryptionByDefault() {
|
||||
@@ -52,5 +30,6 @@ final class GroupConversationTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(conversation.encryptionPreference, .inheritGlobal)
|
||||
XCTAssertTrue(conversation.isGroup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,32 +55,4 @@ final class MediaMetadataTests: XCTestCase {
|
||||
XCTAssertFalse(photo.hasSuffix(".aesgcm"))
|
||||
XCTAssertFalse(voice.hasSuffix(".aesgcm"))
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutMediaMetadataStillDecodes() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "old attachment",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo,
|
||||
kind: .attachment,
|
||||
remoteAttachmentURL: "aesgcm://upload.example.org/file.aesgcm#key",
|
||||
localFilename: "file.pdf"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "mimeType")
|
||||
object.removeValue(forKey: "duration")
|
||||
object.removeValue(forKey: "byteCount")
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertEqual(decoded.localFilename, "file.pdf")
|
||||
XCTAssertNil(decoded.mimeType)
|
||||
XCTAssertNil(decoded.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,31 +4,32 @@ import XCTest
|
||||
|
||||
final class MessageCorrectionTests: XCTestCase {
|
||||
func testOnlyDeliveredOrSentOutgoingTextCanBeEdited() {
|
||||
let editable = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "До исправления",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
var incoming = editable
|
||||
incoming.direction = .incoming
|
||||
var attachment = editable
|
||||
attachment.kind = .attachment
|
||||
var failed = editable
|
||||
failed.delivery = .failed
|
||||
var retracted = editable
|
||||
retracted.retractedAt = Date()
|
||||
func make(
|
||||
direction: ChatMessage.Direction = .outgoing,
|
||||
delivery: ChatMessage.Delivery = .sent,
|
||||
kind: ChatMessage.Kind = .text,
|
||||
retractedAt: Date? = nil
|
||||
) -> ChatMessage {
|
||||
ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "До исправления",
|
||||
direction: direction,
|
||||
delivery: delivery,
|
||||
security: .omemo,
|
||||
kind: kind,
|
||||
retractedAt: retractedAt
|
||||
)
|
||||
}
|
||||
|
||||
XCTAssertTrue(editable.canBeEdited)
|
||||
XCTAssertFalse(incoming.canBeEdited)
|
||||
XCTAssertFalse(attachment.canBeEdited)
|
||||
XCTAssertFalse(failed.canBeEdited)
|
||||
XCTAssertFalse(retracted.canBeEdited)
|
||||
XCTAssertTrue(make().canBeEdited)
|
||||
XCTAssertFalse(make(direction: .incoming).canBeEdited)
|
||||
XCTAssertFalse(make(kind: .attachment).canBeEdited)
|
||||
XCTAssertFalse(make(delivery: .failed).canBeEdited)
|
||||
XCTAssertFalse(make(retractedAt: Date()).canBeEdited)
|
||||
}
|
||||
|
||||
func testEditedTimestampRoundTrips() throws {
|
||||
func testEditedTimestampIsStored() {
|
||||
let editedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
@@ -40,30 +41,6 @@ final class MessageCorrectionTests: XCTestCase {
|
||||
editedAt: editedAt
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.editedAt, editedAt)
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutEditedTimestampStillDecodes() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Старое сообщение",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
object.removeValue(forKey: "editedAt")
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertNil(decoded.editedAt)
|
||||
XCTAssertEqual(message.editedAt, editedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MessageInteractionTests: XCTestCase {
|
||||
func testReplyMetadataRoundTrips() throws {
|
||||
func testReplyMetadataIsStored() {
|
||||
let message = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
@@ -16,12 +16,9 @@ final class MessageInteractionTests: XCTestCase {
|
||||
replyPreview: "Исходный текст"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.replyToID, "original-id")
|
||||
XCTAssertEqual(decoded.replyToJID, "bob@example.org")
|
||||
XCTAssertEqual(decoded.replyPreview, "Исходный текст")
|
||||
XCTAssertEqual(message.replyToID, "original-id")
|
||||
XCTAssertEqual(message.replyToJID, "bob@example.org")
|
||||
XCTAssertEqual(message.replyPreview, "Исходный текст")
|
||||
}
|
||||
|
||||
func testRetractedMessageCannotBeEditedRepliedOrForwarded() {
|
||||
@@ -42,35 +39,6 @@ final class MessageInteractionTests: XCTestCase {
|
||||
XCTAssertEqual(message.previewText, "🚫 Сообщение удалено")
|
||||
}
|
||||
|
||||
func testLegacyMessageWithoutInteractionFieldsStillDecodes() throws {
|
||||
let original = ChatMessage(
|
||||
conversationID: "bob@example.org",
|
||||
senderJID: "alice@example.org",
|
||||
body: "Старый архив",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .omemo
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(original)
|
||||
var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
for key in [
|
||||
"replyToID", "replyToJID", "replyPreview", "forwardedFrom", "retractedAt",
|
||||
"stanzaID", "senderDisplayName", "isGroupMessage", "callHistory"
|
||||
] {
|
||||
object.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
ChatMessage.self,
|
||||
from: JSONSerialization.data(withJSONObject: object)
|
||||
)
|
||||
|
||||
XCTAssertNil(decoded.replyToID)
|
||||
XCTAssertNil(decoded.retractedAt)
|
||||
XCTAssertEqual(decoded.body, "Старый архив")
|
||||
XCTAssertFalse(decoded.isGroupMessage)
|
||||
}
|
||||
|
||||
func testGroupReplyRequiresRoomAssignedStanzaID() {
|
||||
var message = ChatMessage(
|
||||
conversationID: "team@conference.example.org",
|
||||
@@ -92,7 +60,7 @@ final class MessageInteractionTests: XCTestCase {
|
||||
XCTAssertFalse(message.canBeRetracted)
|
||||
}
|
||||
|
||||
func testCompletedVideoCallHistoryRoundTripsWithDuration() throws {
|
||||
func testCompletedVideoCallHistoryShowsMetadata() {
|
||||
let metadata = CallHistoryMetadata(isVideo: true, outcome: .completed)
|
||||
let message = ChatMessage(
|
||||
id: "call-alice-123",
|
||||
@@ -107,14 +75,11 @@ final class MessageInteractionTests: XCTestCase {
|
||||
callHistory: metadata
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(ChatMessage.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.callHistory, metadata)
|
||||
XCTAssertEqual(decoded.callTitle, "Исходящий видеозвонок")
|
||||
XCTAssertEqual(decoded.callSubtitle, "Длительность 1:05")
|
||||
XCTAssertEqual(decoded.previewText, "🎥 Исходящий видеозвонок")
|
||||
XCTAssertFalse(decoded.canBeRepliedTo)
|
||||
XCTAssertFalse(decoded.canBeForwarded)
|
||||
XCTAssertEqual(message.callHistory, metadata)
|
||||
XCTAssertEqual(message.callTitle, "Исходящий видеозвонок")
|
||||
XCTAssertEqual(message.callSubtitle, "Длительность 1:05")
|
||||
XCTAssertEqual(message.previewText, "🎥 Исходящий видеозвонок")
|
||||
XCTAssertFalse(message.canBeRepliedTo)
|
||||
XCTAssertFalse(message.canBeForwarded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,22 +27,4 @@ final class MessageReactionTests: XCTestCase {
|
||||
MessageReactionSummary(emoji: "❤️", count: 1, includesOwnReaction: true)
|
||||
)
|
||||
}
|
||||
|
||||
func testOlderArchivesDecodeWithoutReactionField() throws {
|
||||
let message = ChatMessage(
|
||||
conversationID: "peer@example.org",
|
||||
senderJID: "peer@example.org",
|
||||
body: "hello",
|
||||
direction: .incoming,
|
||||
delivery: .delivered,
|
||||
security: .plaintext
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(message)
|
||||
var object = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: encoded) as? [String: Any]
|
||||
)
|
||||
object.removeValue(forKey: "reactions")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
||||
XCTAssertEqual(try JSONDecoder().decode(ChatMessage.self, from: legacyData).reactions, [])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user