migrate persistence from JSON to SwiftData
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

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:
wt
2026-08-18 22:58:18 +07:00
parent 0317277d46
commit cbb820a86e
30 changed files with 726 additions and 665 deletions
+22 -10
View File
@@ -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
View File
@@ -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 || {
+127 -128
View File
@@ -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
)
+12 -69
View File
@@ -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 {
+12 -2
View File
@@ -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],
+8 -45
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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))
}
+26 -26
View File
@@ -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()
}
+1 -1
View File
@@ -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] {
+5 -5
View File
@@ -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: {
+120
View File
@@ -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")
}
}
-54
View File
@@ -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)
}
}
-13
View File
@@ -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))
+7 -28
View File
@@ -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)
}
}
-28
View File
@@ -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)
}
}
+24 -47
View File
@@ -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)
}
}
+11 -46
View File
@@ -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)
}
}
-18
View File
@@ -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, [])
}
}