From cbb820a86eeaf1c4cc30f93c6b48bd43c68b18ff Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Tue, 18 Aug 2026 22:58:18 +0700 Subject: [PATCH] migrate persistence from JSON to SwiftData Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions. --- Luma.xcodeproj/project.pbxproj | 32 ++- Scripts/verify.sh | 4 +- Sources/Shared/Models/AppModel.swift | 255 +++++++++--------- Sources/Shared/Models/ChatMessage.swift | 81 +----- Sources/Shared/Models/ChatTimelineEntry.swift | 14 +- Sources/Shared/Models/Conversation.swift | 53 +--- Sources/Shared/Models/MessageReaction.swift | 2 +- .../Persistence/ArchiveMetadataRecord.swift | 37 +++ Sources/Shared/Persistence/ArchiveStore.swift | 134 +++++++++ Sources/Shared/Persistence/ChatArchive.swift | 125 --------- .../Persistence/LegacyArchiveImporter.swift | 160 +++++++++++ .../Shared/Services/ChatMediaImageCache.swift | 2 +- .../Shared/Services/PhoneWatchBridge.swift | 8 +- Sources/Shared/UI/CallView.swift | 2 +- Sources/Shared/UI/ChatView.swift | 52 ++-- .../UI/Components/AudioMessagePlayer.swift | 8 +- .../Components/PhotoAttachmentPreview.swift | 2 +- .../UI/Components/ReplyThreadOverlay.swift | 6 +- .../Components/VideoAttachmentPreview.swift | 2 +- .../UI/Components/VideoNotePreview.swift | 4 +- Sources/Shared/UI/GroupInfoView.swift | 2 +- Sources/Shared/UI/MainChatView.swift | 10 +- Tests/ArchiveStoreTests.swift | 120 +++++++++ Tests/ChatArchiveTests.swift | 54 ---- Tests/EncryptionPreferenceTests.swift | 13 - Tests/GroupConversationTests.swift | 35 +-- Tests/MediaMetadataTests.swift | 28 -- Tests/MessageCorrectionTests.swift | 71 ++--- Tests/MessageInteractionTests.swift | 57 +--- Tests/MessageReactionTests.swift | 18 -- 30 files changed, 726 insertions(+), 665 deletions(-) create mode 100644 Sources/Shared/Persistence/ArchiveMetadataRecord.swift create mode 100644 Sources/Shared/Persistence/ArchiveStore.swift delete mode 100644 Sources/Shared/Persistence/ChatArchive.swift create mode 100644 Sources/Shared/Persistence/LegacyArchiveImporter.swift create mode 100644 Tests/ArchiveStoreTests.swift delete mode 100644 Tests/ChatArchiveTests.swift diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index b122a02..f3dabe5 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicyTests.swift; sourceTree = ""; }; 0DE298382FC70ECF1513BD4A /* XMPPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPService.swift; sourceTree = ""; }; - 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchive.swift; sourceTree = ""; }; 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationTests.swift; sourceTree = ""; }; 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntry.swift; sourceTree = ""; }; @@ -299,6 +302,7 @@ 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; }; 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = ""; }; 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = ""; }; + 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = ""; }; 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = ""; }; 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = ""; }; 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; @@ -308,6 +312,7 @@ 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; }; 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = ""; }; + 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = ""; }; 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = ""; }; 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = ""; }; 60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -348,6 +353,7 @@ A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = ""; }; A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = ""; }; A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = ""; }; + ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = ""; }; B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = ""; }; B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicyTests.swift; sourceTree = ""; }; B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerItem.swift; sourceTree = ""; }; @@ -380,6 +386,7 @@ E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = ""; }; E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; + EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; }; 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 = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; @@ -388,7 +395,6 @@ F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; }; F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = ""; }; - FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveTests.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index bd481cb..4986bdb 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -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 || { diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index cd214dc..bf9b9bb 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -72,7 +72,7 @@ final class AppModel: ObservableObject { private let avatarCache: AvatarCache private let mediaPreviewProcessor = MediaPreviewProcessor() private let mediaFileIO = MediaFileIO() - private var archive: ChatArchive? + private var store: ArchiveStore? private var bootstrapTask: Task? private var persistTask: Task? private var watchSyncTask: Task? @@ -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 ) diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift index ee44364..954981b 100644 --- a/Sources/Shared/Models/ChatMessage.swift +++ b/Sources/Shared/Models/ChatMessage.swift @@ -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 { diff --git a/Sources/Shared/Models/ChatTimelineEntry.swift b/Sources/Shared/Models/ChatTimelineEntry.swift index aa15796..7188d20 100644 --- a/Sources/Shared/Models/ChatTimelineEntry.swift +++ b/Sources/Shared/Models/ChatTimelineEntry.swift @@ -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], diff --git a/Sources/Shared/Models/Conversation.swift b/Sources/Shared/Models/Conversation.swift index dab4911..672f208 100644 --- a/Sources/Shared/Models/Conversation.swift +++ b/Sources/Shared/Models/Conversation.swift @@ -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 { diff --git a/Sources/Shared/Models/MessageReaction.swift b/Sources/Shared/Models/MessageReaction.swift index 6090b53..4fc2cec 100644 --- a/Sources/Shared/Models/MessageReaction.swift +++ b/Sources/Shared/Models/MessageReaction.swift @@ -80,7 +80,7 @@ enum MessageReactionPolicy { extension ChatMessage { var reactionIdentifier: String? { - isGroupMessage ? stanzaID : id + isGroupMessage ? stanzaID : clientID } var canBeReactedTo: Bool { diff --git a/Sources/Shared/Persistence/ArchiveMetadataRecord.swift b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift new file mode 100644 index 0000000..aff9d47 --- /dev/null +++ b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift @@ -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 + } +} diff --git a/Sources/Shared/Persistence/ArchiveStore.swift b/Sources/Shared/Persistence/ArchiveStore.swift new file mode 100644 index 0000000..5b58a5d --- /dev/null +++ b/Sources/Shared/Persistence/ArchiveStore.swift @@ -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 + var rosterContactJIDs: Set + 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(sortBy: conversationSort))) ?? [] + let messages = (try? context.fetch(FetchDescriptor())) ?? [] + + 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, + rosterContactJIDs: Set, + 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()).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) + } +} diff --git a/Sources/Shared/Persistence/ChatArchive.swift b/Sources/Shared/Persistence/ChatArchive.swift deleted file mode 100644 index f40698c..0000000 --- a/Sources/Shared/Persistence/ChatArchive.swift +++ /dev/null @@ -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 - var rosterContactJIDs: Set - var lastSuccessfulMAMSync: Date? - var lastSuccessfulMAMCursor: String? - var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] - - init( - conversations: [Conversation], - messages: [ChatMessage], - locallyDeletedMessageIDs: Set = [], - rosterContactJIDs: Set = [], - 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.self, - forKey: .locallyDeletedMessageIDs - ) ?? [] - rosterContactJIDs = try values.decodeIfPresent( - Set.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) - } -} diff --git a/Sources/Shared/Persistence/LegacyArchiveImporter.swift b/Sources/Shared/Persistence/LegacyArchiveImporter.swift new file mode 100644 index 0000000..7e9c851 --- /dev/null +++ b/Sources/Shared/Persistence/LegacyArchiveImporter.swift @@ -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 + var rosterContactJIDs: Set + 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) + } + ) + } +} diff --git a/Sources/Shared/Services/ChatMediaImageCache.swift b/Sources/Shared/Services/ChatMediaImageCache.swift index e1b9b0e..eaac45f 100644 --- a/Sources/Shared/Services/ChatMediaImageCache.swift +++ b/Sources/Shared/Services/ChatMediaImageCache.swift @@ -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 } diff --git a/Sources/Shared/Services/PhoneWatchBridge.swift b/Sources/Shared/Services/PhoneWatchBridge.swift index 6066315..4d856f2 100644 --- a/Sources/Shared/Services/PhoneWatchBridge.swift +++ b/Sources/Shared/Services/PhoneWatchBridge.swift @@ -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 { diff --git a/Sources/Shared/UI/CallView.swift b/Sources/Shared/UI/CallView.swift index 4ea1957..459dc6a 100644 --- a/Sources/Shared/UI/CallView.swift +++ b/Sources/Shared/UI/CallView.swift @@ -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)) } diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index ca220c9..5abfc93 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -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 = [root.id] + var sourceIdentifiers: Set = [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)" } diff --git a/Sources/Shared/UI/Components/AudioMessagePlayer.swift b/Sources/Shared/UI/Components/AudioMessagePlayer.swift index efe6331..0541e5b 100644 --- a/Sources/Shared/UI/Components/AudioMessagePlayer.swift +++ b/Sources/Shared/UI/Components/AudioMessagePlayer.swift @@ -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, diff --git a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift index 965fa48..0e569f1 100644 --- a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift @@ -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() } diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift index 728e2ab..6dc1813 100644 --- a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -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) diff --git a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift index 7d49694..c9d2617 100644 --- a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift @@ -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() } diff --git a/Sources/Shared/UI/Components/VideoNotePreview.swift b/Sources/Shared/UI/Components/VideoNotePreview.swift index 977f20c..79717e9 100644 --- a/Sources/Shared/UI/Components/VideoNotePreview.swift +++ b/Sources/Shared/UI/Components/VideoNotePreview.swift @@ -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() } diff --git a/Sources/Shared/UI/GroupInfoView.swift b/Sources/Shared/UI/GroupInfoView.swift index 5961956..b3af7dd 100644 --- a/Sources/Shared/UI/GroupInfoView.swift +++ b/Sources/Shared/UI/GroupInfoView.swift @@ -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] { diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift index 2843079..838c303 100644 --- a/Sources/Shared/UI/MainChatView.swift +++ b/Sources/Shared/UI/MainChatView.swift @@ -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: { diff --git a/Tests/ArchiveStoreTests.swift b/Tests/ArchiveStoreTests.swift new file mode 100644 index 0000000..6a65de3 --- /dev/null +++ b/Tests/ArchiveStoreTests.swift @@ -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") + } +} diff --git a/Tests/ChatArchiveTests.swift b/Tests/ChatArchiveTests.swift deleted file mode 100644 index 6c3ea28..0000000 --- a/Tests/ChatArchiveTests.swift +++ /dev/null @@ -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) - } -} diff --git a/Tests/EncryptionPreferenceTests.swift b/Tests/EncryptionPreferenceTests.swift index 5e98704..72b2138 100644 --- a/Tests/EncryptionPreferenceTests.swift +++ b/Tests/EncryptionPreferenceTests.swift @@ -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)) diff --git a/Tests/GroupConversationTests.swift b/Tests/GroupConversationTests.swift index c1f464b..569d36b 100644 --- a/Tests/GroupConversationTests.swift +++ b/Tests/GroupConversationTests.swift @@ -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) } } diff --git a/Tests/MediaMetadataTests.swift b/Tests/MediaMetadataTests.swift index 8741803..d7756cb 100644 --- a/Tests/MediaMetadataTests.swift +++ b/Tests/MediaMetadataTests.swift @@ -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) - } } diff --git a/Tests/MessageCorrectionTests.swift b/Tests/MessageCorrectionTests.swift index 81eae23..0c98ead 100644 --- a/Tests/MessageCorrectionTests.swift +++ b/Tests/MessageCorrectionTests.swift @@ -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) } } diff --git a/Tests/MessageInteractionTests.swift b/Tests/MessageInteractionTests.swift index de4fcbc..c271b99 100644 --- a/Tests/MessageInteractionTests.swift +++ b/Tests/MessageInteractionTests.swift @@ -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) } } diff --git a/Tests/MessageReactionTests.swift b/Tests/MessageReactionTests.swift index 4487de2..acd05f4 100644 --- a/Tests/MessageReactionTests.swift +++ b/Tests/MessageReactionTests.swift @@ -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, []) - } }