diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index ec026cc..212328a 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -37,7 +37,8 @@ XEP-0045-комнатами в отдельном разделе «Группо собственных дополнительных устройств; при выключенном отправляется обычный `` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический fallback на plaintext. UI сразу показывает optimistic message и обновляет -состояние по результату записи/receipt. +состояние по результату записи/receipt; XEP-0333 ``-маркер +собеседника переводит исходящее сообщение в состояние «прочитано». ## OMEMO 2 @@ -218,6 +219,24 @@ trickle ICE candidates накапливаются до успешного нач clientID карточки на исходном устройстве. Пропущенный входящий звонок увеличивает счётчик непрочитанных на каждом устройстве. +Состояние «прочитано» (`.read`) синхронизируется между устройствами тем же +каналом. Устройство, получившее от собеседника ``-маркер +(XEP-0333) или, для MUC, маркер от участника комнаты, переводит исходящее +сообщение в `.read` и отправляет собственному bare JID служебное сообщение с +`` (чат, origin-id, stanza-id +сообщения). Carbons доставляют его на остальные устройства, MAM архивирует — +поэтому свежая установка и повторные прогоны архива сходятся к одному +состоянию, даже когда маркер приходит раньше самого сообщения (MAM отдаёт +станзы от новых к старым): маркер ждёт в `pendingReadMarkers` до upsert +сообщения. Для комнат маркер адресован собственному bare JID и ссылается на +stanza-id из MUC-MAM, так что состояние прочтения не попадает в архив +комнаты. При открытии 1:1-чата Luma отправляет собеседнику `` для +последнего входящего сообщения (в MUC маркеры не отправляются — XEP-0333). +Слияние копий одного исходящего сообщения (live/MAM/MUC-MAM/Carbons) +монотонно: `Delivery.merged(with:)` не позволяет архивному повтору с `.sent` +понизить уже установленные `.delivered`/`.read`, а `.failed` остаётся +неизменным до явной повторной отправки. + Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого iOS-приложения намеренно не имитируется: production-путь требует VoIP push, PushKit/CallKit и серверной APNs-инфраструктуры. diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index f489203..6c023a2 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; }; 1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; }; 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */; }; + 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; }; 1BD84AA796838F48F847D192 /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; }; 1C78234B3FA4E528C7F701B7 /* LumaOMEMO2Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */; }; 1C948E97A14DBA90C24A68FF /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; }; @@ -58,6 +59,7 @@ 28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; }; 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; }; + 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; }; 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; }; 2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; 2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; }; @@ -147,6 +149,7 @@ 791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; }; 7A469F326228FB660739D1C2 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; }; 7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; }; + 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */; }; 7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; @@ -161,7 +164,6 @@ 822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; }; 8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; }; 86D39FA124355A8B53903164 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 6EDEC6EEE315B77491214398 /* Martin */; }; - 86EB99233959DC85A30D3969 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A6FCCE22DA85B845A81D42 /* ChatBubbleShape.swift */; }; 8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; }; 8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; }; 89712B216A98861B5D994587 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; @@ -176,10 +178,12 @@ 945014504AA02172175B48D2 /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; }; 94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; }; 958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */; }; + 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; }; 992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 371134B6F3E284082E28FE08 /* MartinOMEMO */; }; 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; }; 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; 9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; }; + 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; }; 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */; }; 9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; }; 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */; }; @@ -228,7 +232,7 @@ 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 */; }; - CC81612D8BDE237988AC1C06 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A6FCCE22DA85B845A81D42 /* ChatBubbleShape.swift */; }; + D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */; }; D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; }; D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; }; @@ -434,6 +438,7 @@ 9B11FED2D58621C071C3AD14 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = ""; }; 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplySwipePolicy.swift; sourceTree = ""; }; + 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = ""; }; A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneWatchBridge.swift; sourceTree = ""; }; A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataTests.swift; sourceTree = ""; }; A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = ""; }; @@ -458,6 +463,7 @@ BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaWatchApp.swift; sourceTree = ""; }; C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDraft.swift; sourceTree = ""; }; C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSDPOrderingTests.swift; sourceTree = ""; }; + C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSyncTests.swift; sourceTree = ""; }; C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicyTests.swift; sourceTree = ""; }; C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = ""; }; C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = ""; }; @@ -474,20 +480,21 @@ D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = ""; }; D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = ""; }; D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = ""; }; + D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageDeliveryTests.swift; sourceTree = ""; }; D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = ""; }; D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = ""; }; D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = ""; }; D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; }; + DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSync.swift; sourceTree = ""; }; DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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; name = LumaMac.app; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; - F4A6FCCE22DA85B845A81D42 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = ""; }; F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslChallengeModule.swift; sourceTree = ""; }; F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = ""; }; F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; @@ -538,6 +545,7 @@ 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */, 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */, 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */, + DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */, 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */, B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */, F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */, @@ -657,6 +665,7 @@ C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */, C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */, 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */, + D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */, 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */, C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */, C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */, @@ -680,6 +689,7 @@ 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */, F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */, + C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */, 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, @@ -798,7 +808,7 @@ D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */, 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */, 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */, - F4A6FCCE22DA85B845A81D42 /* ChatBubbleShape.swift */, + 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */, F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */, 758B448979C845F56168A51F /* ConversationRow.swift */, 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */, @@ -1041,7 +1051,7 @@ 2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */, 6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */, 73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */, - CC81612D8BDE237988AC1C06 /* ChatBubbleShape.swift in Sources */, + 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */, 7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */, 66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */, D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */, @@ -1103,6 +1113,7 @@ C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */, 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */, 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */, + 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */, 67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */, ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */, D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */, @@ -1166,7 +1177,7 @@ 4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */, 087E600676B3F6134BB26112 /* CallView.swift in Sources */, 3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */, - 86EB99233959DC85A30D3969 /* ChatBubbleShape.swift in Sources */, + 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */, 6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */, 8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */, 8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */, @@ -1228,6 +1239,7 @@ 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */, 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */, C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */, + 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */, 3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */, 3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */, 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */, @@ -1266,6 +1278,7 @@ 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */, B7ED810A1DD43B717DDC40B3 /* CallSDPOrderingTests.swift in Sources */, 61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */, + 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */, 12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */, 5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */, 3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */, @@ -1289,6 +1302,7 @@ 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */, + D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */, 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */, @@ -1766,7 +1780,6 @@ }; 371134B6F3E284082E28FE08 /* MartinOMEMO */ = { isa = XCSwiftPackageProductDependency; - package = 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */; productName = MartinOMEMO; }; 4BD4324EE01A6DD46EBE7FDA /* Martin */ = { @@ -1781,7 +1794,6 @@ }; 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */ = { isa = XCSwiftPackageProductDependency; - package = 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */; productName = MartinOMEMO; }; A99B4C1B1459E1C34F214164 /* OpenSSL */ = { diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 667e471..caf0323 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -91,9 +91,12 @@ required=( Tests/SCRAMSHA512Tests.swift Tests/AppLockPolicyTests.swift Tests/CallHistorySyncTests.swift + Tests/ReadStateSyncTests.swift + Tests/ChatMessageDeliveryTests.swift UITests/TimelineUITests.swift Sources/Shared/XMPP/LumaScramSha512Mechanism.swift Sources/Shared/XMPP/CallHistorySync.swift + Sources/Shared/XMPP/ReadStateSync.swift Sources/Shared/Security/AppLockVault.swift Sources/Shared/Models/AppLockPolicy.swift Sources/Shared/UI/AppLockView.swift @@ -166,7 +169,7 @@ test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || { exit 1 } -if grep -R -q 'ChatBubbleShape\|maximumMediaSize\|25 МБ' Sources/Shared; then +if grep -R -q 'ChatBubbleTailShape\|maximumMediaSize\|25 МБ' Sources/Shared; then echo "Legacy bubble tails or the old client attachment limit are still present" exit 1 fi @@ -406,6 +409,30 @@ grep -q 'CallHistorySync.envelope' Sources/Shared/XMPP/XMPPService.swift || { echo "Incoming call-history payloads must be intercepted" exit 1 } +grep -q 'https://luma.chat/read-marker' Sources/Shared/XMPP/ReadStateSync.swift || { + echo "Read state must sync through its service namespace" + exit 1 +} +grep -q 'ReadStateSync.envelope' Sources/Shared/XMPP/XMPPService.swift || { + echo "Incoming read-marker payloads must be intercepted" + exit 1 +} +grep -q 'syncReadState' Sources/Shared/Models/AppModel.swift || { + echo "Read receipts must be synced to the user's other devices" + exit 1 +} +grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || { + echo "XEP-0333 displayed markers must mark messages as read" + exit 1 +} +grep -q 'case read(conversationJID' Sources/Shared/XMPP/XMPPService.swift || { + echo "Read receipts must reference the conversation" + exit 1 +} +grep -q 'merged(with: message.delivery)' Sources/Shared/Models/AppModel.swift || { + echo "MAM replays must not downgrade delivered or read state" + exit 1 +} grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { echo "Group chats must support local deletion" exit 1 diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index 009753d..5c99b44 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -113,6 +113,17 @@ final class AppModel: ObservableObject { private var pendingRetractions: [String: XMPPService.RetractionEnvelope] = [:] private var pendingReactions: [String: XMPPService.ReactionEnvelope] = [:] private var correctionReceiptTargets: [String: String] = [:] + + private struct PendingReadMarker { + var envelope: ReadStateSync.Envelope + var cameFromPeer: Bool + } + /// Read markers that arrived before the message they reference. MAM + /// replays newest stanzas first, so a read-marker is routinely applied + /// before its message on a fresh install; the marker waits here until the + /// message itself is upserted. + private var pendingReadMarkers: [String: PendingReadMarker] = [:] + private var lastDisplayedMarkerByConversation: [String: String] = [:] private var localChatStateByConversation: [String: ChatTypingState] = [:] private var localTypingPauseTasks: [String: Task] = [:] private var remoteTypingExpiryTasks: [String: Task] = [:] @@ -730,6 +741,7 @@ final class AppModel: ObservableObject { if let index = conversations.firstIndex(where: { $0.jid == normalized }) { conversations[index].unreadCount = 0 } + sendDisplayedMarkerIfNeeded(for: normalized) if addToRoster { rosterContactJIDs.insert(normalized) xmpp.addToRoster(jid: normalized, name: name) @@ -747,6 +759,7 @@ final class AppModel: ObservableObject { if let index = conversations.firstIndex(where: { $0.jid == normalized }) { conversations[index].unreadCount = 0 } + sendDisplayedMarkerIfNeeded(for: normalized) schedulePersist() syncWatch() if hasMoreOlderHistoryByConversation[normalized] == false, @@ -2197,6 +2210,25 @@ final class AppModel: ObservableObject { case .delivered(let messageID): let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID updateMessage(id: targetID) { $0.delivery = .delivered } + case .read(let conversationID, let messageID): + // Peer-originated read receipt (XEP-0333 displayed marker): apply + // it locally and broadcast it to the user's other devices through + // a self-addressed read-marker message. + let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID + applyReadState( + ReadStateSync.Envelope( + id: UUID().uuidString, + conversationJID: conversationID, + messageID: targetID, + stanzaID: nil, + timestamp: Date() + ), + cameFromPeer: true + ) + case .readState(let envelope): + // Read-marker from another of the user's devices (Carbons live or + // MAM replay): apply without re-broadcasting. + applyReadState(envelope, cameFromPeer: false) case .omemo(let ready, let fingerprint): isOMEMOReady = ready ownFingerprint = fingerprint @@ -2413,6 +2445,112 @@ final class AppModel: ObservableObject { schedulePersist() } + // MARK: - Read receipts (XEP-0333) and cross-device read-state sync + + private func applyReadState(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) { + let conversationID = envelope.conversationJID.lowercased() + if let index = + messageIndex(originID: envelope.messageID, conversationID: conversationID) + ?? messageIndex(referenceID: envelope.messageID, conversationID: conversationID) + ?? envelope.stanzaID.flatMap({ + messageIndex(referenceID: $0, conversationID: conversationID) + }) + { + markRead(at: index, cameFromPeer: cameFromPeer) + return + } + // The marker raced ahead of the message (typical for MAM replays, + // which arrive newest first). Apply it when the message lands. + stashPendingReadMarker(envelope, cameFromPeer: cameFromPeer) + } + + /// Promotes a matching outgoing message to `.read` and, when the receipt + /// came from the peer (not from another own device), broadcasts the state + /// to the user's other devices via Carbons/MAM. Failed messages are never + /// promoted by a receipt. + private func markRead(at index: Int, cameFromPeer: Bool) { + let message = messages[index] + guard message.direction == .outgoing, + message.delivery != .read, + message.delivery != .failed, + !message.isRetracted + else { return } + messages[index].delivery = .read + schedulePersist() + guard cameFromPeer else { return } + xmpp.syncReadState( + ReadStateSync.Envelope( + id: UUID().uuidString, + conversationJID: message.conversationID, + messageID: message.originID ?? message.clientID, + stanzaID: message.stanzaID, + timestamp: Date() + )) + } + + private func stashPendingReadMarker(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) { + // The marker may reference the message through its origin-id or its + // archived stanza-id; index it under every reference the arriving + // message can expose so `applyPendingReadState` always finds it. + let references = Set([envelope.messageID, envelope.stanzaID].compactMap { $0 }) + for reference in references { + let key = pendingReadMarkerKey( + conversationID: envelope.conversationJID, + reference: reference + ) + // A peer marker must keep its broadcast flag: a device-sync + // envelope must not silently shadow it. + if pendingReadMarkers[key]?.cameFromPeer == true { continue } + pendingReadMarkers[key] = PendingReadMarker(envelope: envelope, cameFromPeer: cameFromPeer) + } + if pendingReadMarkers.count > 200 { + if let oldestKey = pendingReadMarkers.min(by: { + $0.value.envelope.timestamp < $1.value.envelope.timestamp + })?.key { + pendingReadMarkers.removeValue(forKey: oldestKey) + } + } + } + + private func pendingReadMarkerKey(conversationID: String, reference: String) -> String { + "\(conversationID.lowercased())|\(reference)" + } + + /// Applies any read marker that arrived before the message it references. + /// Called from `upsertMessage` once the message itself is in the array. + private func applyPendingReadState(for message: ChatMessage, at index: Int) { + guard message.direction == .outgoing else { return } + let references = Set([message.clientID, message.originID, message.stanzaID].compactMap { $0 }) + for reference in references { + let key = pendingReadMarkerKey( + conversationID: message.conversationID, + reference: reference + ) + guard let pending = pendingReadMarkers.removeValue(forKey: key) else { continue } + markRead(at: index, cameFromPeer: pending.cameFromPeer) + } + } + + /// Sends an XEP-0333 `` marker for the newest incoming 1:1 + /// message once the chat is opened, so the peer's client can show it as + /// read. MUC rooms never get markers (XEP-0333); deduped per message so + /// repeated SwiftUI appearances do not resend. + private func sendDisplayedMarkerIfNeeded(for conversationID: String) { + guard let account else { return } + let normalized = conversationID.lowercased() + guard normalized != account.normalizedJID else { return } + guard let latest = messages + .filter({ $0.conversationID == normalized && $0.direction == .incoming }) + .max(by: { $0.timestamp < $1.timestamp }), + !latest.isGroupMessage, + !latest.isRetracted, + latest.kind != .system + else { return } + guard lastDisplayedMarkerByConversation[normalized] != latest.clientID else { return } + lastDisplayedMarkerByConversation[normalized] = latest.clientID + xmpp.sendDisplayedMarker(messageID: latest.clientID, to: normalized) + } + private func recordCallHistory(_ entry: CallHistoryEntry) { guard let account else { return } let direction: ChatMessage.Direction = @@ -2868,9 +3006,7 @@ final class AppModel: ObservableObject { ) { let previous = messages[index] if previous.isRetracted { - if message.delivery == .delivered { - messages[index].delivery = .delivered - } + messages[index].delivery = previous.delivery.merged(with: message.delivery) updateConversationPreview(for: messages[index], incrementUnread: false) return false } @@ -2880,9 +3016,9 @@ final class AppModel: ObservableObject { merged.mimeType = previous.mimeType ?? message.mimeType merged.duration = previous.duration ?? message.duration merged.byteCount = previous.byteCount ?? message.byteCount - if previous.delivery == .delivered { - merged.delivery = .delivered - } + // MAM/MUC-MAM replays and Carbon copies carry `.sent`; a + // locally delivered or read copy must never be downgraded. + merged.delivery = previous.delivery.merged(with: message.delivery) } merged.replyToID = message.replyToID ?? previous.replyToID merged.replyToJID = message.replyToJID ?? previous.replyToJID @@ -2944,6 +3080,7 @@ final class AppModel: ObservableObject { conversationID: merged.conversationID )] = index } + applyPendingReadState(for: merged, at: index) updateConversationPreview(for: merged, incrementUnread: false) return false } @@ -2975,6 +3112,7 @@ final class AppModel: ObservableObject { let shouldIncrement = unreadOverride ?? (message.direction == .incoming && selectedConversationID != message.conversationID) + applyPendingReadState(for: message, at: insertedIndex) updateConversationPreview(for: message, incrementUnread: shouldIncrement) return true } diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift index 0ca425d..9ed4af1 100644 --- a/Sources/Shared/Models/ChatMessage.swift +++ b/Sources/Shared/Models/ChatMessage.swift @@ -14,6 +14,27 @@ final class ChatMessage { case delivered case failed case read + + /// Monotonic promotion used when the same outgoing message is replayed + /// from MAM/MUC-MAM or Carbons: a copy that was already delivered or + /// read locally must never be downgraded by an archive replay carrying + /// `.sent`. `.failed` is sticky in both directions — an archive + /// replay must not silently "fix" a failed send; the user resends it + /// explicitly. + func merged(with other: Delivery) -> Delivery { + guard self != .failed, other != .failed else { return .failed } + return other.rank > self.rank ? other : self + } + + private var rank: Int { + switch self { + case .sending: return 0 + case .sent: return 1 + case .delivered: return 2 + case .read: return 3 + case .failed: return -1 + } + } } enum Security: String, Codable { @@ -136,7 +157,7 @@ final class ChatMessage { && !isGroupMessage && direction == .outgoing && kind == .text - && (delivery == .sent || delivery == .delivered) + && (delivery == .sent || delivery == .delivered || delivery == .read) } var isRetracted: Bool { @@ -158,7 +179,7 @@ final class ChatMessage { && !isGroupMessage && !isRetracted && kind != .system - && (delivery == .sent || delivery == .delivered) + && (delivery == .sent || delivery == .delivered || delivery == .read) } var replyIdentifier: String? { diff --git a/Sources/Shared/XMPP/ReadStateSync.swift b/Sources/Shared/XMPP/ReadStateSync.swift new file mode 100644 index 0000000..791d63d --- /dev/null +++ b/Sources/Shared/XMPP/ReadStateSync.swift @@ -0,0 +1,74 @@ +import Foundation +import Martin + +/// Service messages that sync read receipts between the user's own devices. +/// When a device marks an outgoing message as read (the peer's XEP-0333 +/// marker arrived there) it sends a message to its own bare JID +/// carrying a `` payload; +/// Carbons deliver it to the other devices and MAM archives it, so a fresh +/// install and every later archive replay converge on the same read state. +/// Receivers upsert the state on the matching outgoing message, deduplicating +/// by the marker id. +/// +/// MUC read state never leaks into the room archive: the marker is a plain +/// self-addressed message whose payload references the room JID and the +/// archived stanza-id of the room message. Group conversations are matched +/// through the MUC-MAM stanza-id, 1:1 chats through the origin-id. +enum ReadStateSync { + static let namespace = "https://luma.chat/read-marker" + + struct Envelope: Equatable, Sendable { + /// Unique marker id; matches the message origin-id so Carbons and MAM + /// copies deduplicate into a single state application. + let id: String + /// Bare JID of the conversation holding the read message: a peer or + /// a MUC room. + let conversationJID: String + /// Origin-id (clientID) of the read message on the sending device. + let messageID: String + /// Archived stanza-id of the read message (1:1 archive or MUC-MAM); + /// group messages are matched through it on the receiving devices. + let stanzaID: String? + let timestamp: Date + } + + static func envelope(from message: Message) -> Envelope? { + guard let element = message.element.findChild( + name: "read-marker", + xmlns: namespace + ) else { + return nil + } + let value = { (name: String) -> String? in + element.findChild(name: name)?.value + } + guard let rawConversation = value("conversation"), !rawConversation.isEmpty, + let rawMessage = value("id"), !rawMessage.isEmpty + else { return nil } + let rawStanza = value("stanza") + return Envelope( + id: message.originId ?? message.id ?? UUID().uuidString, + conversationJID: rawConversation.lowercased(), + messageID: rawMessage, + stanzaID: (rawStanza?.isEmpty == false) ? rawStanza : nil, + timestamp: value("when").flatMap { timestampFormatter.date(from: $0) } ?? Date() + ) + } + + static func payloadElement(marker: Envelope) -> Element { + let element = Element(name: "read-marker", xmlns: namespace) + element.addChild(Element(name: "conversation", cdata: marker.conversationJID.lowercased())) + element.addChild(Element(name: "id", cdata: marker.messageID)) + if let stanzaID = marker.stanzaID, !stanzaID.isEmpty { + element.addChild(Element(name: "stanza", cdata: stanzaID)) + } + element.addChild(Element(name: "when", cdata: timestampFormatter.string(from: marker.timestamp))) + return element + } + + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index 0198057..a3d850b 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -248,6 +248,7 @@ final class XMPPService { case roomInvitation(RoomInvitationEnvelope) case avatar(jid: String, data: Data) case delivered(messageID: String) + case read(conversationJID: String, messageID: String) case omemo(ready: Bool, ownFingerprint: String?) case archiveBatch([ArchiveMutation]) case archiveSyncing(Bool) @@ -256,6 +257,7 @@ final class XMPPService { case call(CallSnapshot?) case callHistory(CallHistoryEntry) case callHistorySync(CallHistorySync.Envelope) + case readState(ReadStateSync.Envelope) case callError(String) case recoverableError(String) } @@ -816,6 +818,41 @@ final class XMPPService { .info("sent call-history id=\(entry.id) peer=\(entry.peerJID) status=\(entry.outcome.rawValue)") } + /// Sends a `` service message to the user's own bare JID + /// so Carbons deliver it to the other devices and MAM archives it. The + /// message is plaintext and never reaches the chat counterpart; MUC read + /// state rides the same self-addressed envelope and never enters the room + /// archive. + func syncReadState(_ marker: ReadStateSync.Envelope) { + guard let client, client.state == .connected(), let account else { return } + let message = Message() + message.to = JID(account.normalizedJID) + message.type = .chat + message.id = marker.id + message.body = Self.readMarkerBody + addOriginID(marker.id, to: message) + message.addChild(ReadStateSync.payloadElement(marker: marker)) + client.context.writer.write(message, writeCompleted: nil) + Logger(subsystem: "Luma", category: "read-sync") + .info("sent read-marker id=\(marker.id) message=\(marker.messageID) conversation=\(marker.conversationJID)") + } + + /// Sends an XEP-0333 `` chat marker so the peer's client can + /// show the message as read. Markers are never sent to MUC rooms + /// (XEP-0333 forbids them there); the read state of outgoing MUC messages + /// only changes when a member's client sends a marker anyway or when + /// another of the user's devices broadcasts a read-marker sync message. + func sendDisplayedMarker(messageID: String, to peerJID: String) { + guard let client, client.state == .connected() else { return } + let message = Message() + message.to = JID(peerJID) + message.type = .chat + message.chatMarkers = .displayed(id: messageID) + client.context.writer.write(message, writeCompleted: nil) + } + + private static let readMarkerBody = "Сообщение прочитано" + /// Detects OMEMO 2 (`urn:xmpp:omemo:2`) payloads, which the pinned /// MartinOMEMO version cannot decrypt (it only speaks the legacy /// `eu.siacs.conversations.axolotl` namespace). @@ -2105,6 +2142,17 @@ final class XMPPService { } .store(in: &cancellables) + // XEP-0333 chat markers. Martin republishes markers found on live + // stanzas, Carbons copies and archived messages through one publisher, + // so `` markers the server carbon-copies to the user's + // other devices reach this handler there as well. + client.module(.chatMarkers).markersPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] received in + self?.handleChatMarker(received) + } + .store(in: &cancellables) + // Do not enqueue every archived stanza onto DispatchQueue.main. Martin // publishes all results before the final IQ callback, so a locked inbox // can receive the whole page on the parser queue and hand it to the @@ -2182,6 +2230,41 @@ final class XMPPService { } } + /// XEP-0333 chat markers: `` marks an outgoing message as read, + /// `` as delivered. MUC markers are best effort — few clients + /// send them, but the room stanza-id maps them to the right outgoing + /// message. + private func handleChatMarker(_ received: ChatMarkersModule.ChatMarkerReceived) { + let message = received.message + guard message.type != .error else { return } + switch received.marker { + case .displayed(let id): + guard let conversationJID = markerConversationJID(for: message) else { return } + eventHandler?(.read(conversationJID: conversationJID, messageID: id)) + case .received(let id): + eventHandler?(.delivered(messageID: id)) + case .acknowledged: + break + } + } + + /// Bare JID of the conversation a marker references. Peer markers arrive + /// with the peer in `from`; our own displayed markers carbon-copied from + /// another device carry our JID in `from` and the peer in `to`. Groupchat + /// markers reference the room. + private func markerConversationJID(for message: Message) -> String? { + if message.type == .groupchat { + return message.from?.bareJid.stringValue + } + if let from = message.from?.bareJid { + if let client, from == client.userBareJid { + return message.to?.bareJid.stringValue ?? from.stringValue + } + return from.stringValue + } + return message.to?.bareJid.stringValue + } + /// Runs OMEMO decryption off the main actor and resumes with the raw /// result. Kept as a thin wrapper so `handle` keeps its existing switch /// logic and only the expensive `decode` call leaves the main thread. @@ -2244,6 +2327,15 @@ final class XMPPService { eventHandler?(.callHistorySync(sync)) return } + // Read-marker service messages sync read receipts between the user's + // devices the same way: Carbons deliver them live, MAM replays them + // after reinstalls. They never become ordinary chat messages. + if let marker = ReadStateSync.envelope(from: message) { + Logger(subsystem: "Luma", category: "read-sync") + .info("received read-marker id=\(marker.id) message=\(marker.messageID) conversation=\(marker.conversationJID)") + eventHandler?(.readState(marker)) + return + } guard message.type != .error, message.type != .groupchat, let client, diff --git a/Tests/ChatMessageDeliveryTests.swift b/Tests/ChatMessageDeliveryTests.swift new file mode 100644 index 0000000..740f482 --- /dev/null +++ b/Tests/ChatMessageDeliveryTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import Luma + +final class ChatMessageDeliveryTests: XCTestCase { + func testMergeKeepsTheStrongestState() { + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .sending), .sent) + XCTAssertEqual(ChatMessage.Delivery.sending.merged(with: .sent), .sent) + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .delivered), .delivered) + XCTAssertEqual(ChatMessage.Delivery.delivered.merged(with: .read), .read) + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .sent), .read) + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .delivered), .read) + } + + func testMAMReplayNeverDowngradesReadState() { + // The archived copy of an outgoing message carries `.sent`; the local + // copy that received the peer's displayed marker must stay `.read`. + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .sent), .read) + XCTAssertEqual(ChatMessage.Delivery.delivered.merged(with: .sent), .delivered) + } + + func testFailedIsSticky() { + XCTAssertEqual(ChatMessage.Delivery.failed.merged(with: .sent), .failed) + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .failed), .failed) + } + + func testReadMessagesRemainEditableAndRetractable() { + let message = ChatMessage( + conversationID: "bob@example.org", + senderJID: "alice@example.org", + body: "привет", + direction: .outgoing, + delivery: .read, + security: .plaintext + ) + XCTAssertTrue(message.canBeEdited) + XCTAssertTrue(message.canBeRetracted) + } +} diff --git a/Tests/ReadStateSyncTests.swift b/Tests/ReadStateSyncTests.swift new file mode 100644 index 0000000..830bebc --- /dev/null +++ b/Tests/ReadStateSyncTests.swift @@ -0,0 +1,73 @@ +import Martin +import XCTest +@testable import Luma + +final class ReadStateSyncTests: XCTestCase { + private func makePayloadMessage(id: String = "marker-42") -> Message { + let message = Message() + message.id = id + let payload = Element(name: "read-marker", xmlns: ReadStateSync.namespace) + payload.addChild(Element(name: "conversation", cdata: "Bob@Example.org")) + payload.addChild(Element(name: "id", cdata: "msg-uuid")) + payload.addChild(Element(name: "stanza", cdata: "stanza-7")) + payload.addChild(Element(name: "when", cdata: "2026-09-14T12:00:00Z")) + message.addChild(payload) + return message + } + + func testEnvelopeParsesPayload() throws { + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: makePayloadMessage())) + XCTAssertEqual(envelope.id, "marker-42") + XCTAssertEqual(envelope.conversationJID, "bob@example.org") + XCTAssertEqual(envelope.messageID, "msg-uuid") + XCTAssertEqual(envelope.stanzaID, "stanza-7") + XCTAssertEqual( + envelope.timestamp, + ISO8601DateFormatter().date(from: "2026-09-14T12:00:00Z") + ) + } + + func testEnvelopePrefersOriginID() throws { + let message = makePayloadMessage() + let origin = Element(name: "origin-id", xmlns: "urn:xmpp:sid:0") + origin.setAttribute("id", value: "marker-uuid") + message.addChild(origin) + XCTAssertEqual(ReadStateSync.envelope(from: message)?.id, "marker-uuid") + } + + func testEnvelopeIgnoresOrdinaryMessages() { + let message = Message() + message.body = "hello" + XCTAssertNil(ReadStateSync.envelope(from: message)) + } + + func testEnvelopeAllowsMissingStanzaID() throws { + let message = Message() + message.id = "marker-1" + let payload = Element(name: "read-marker", xmlns: ReadStateSync.namespace) + payload.addChild(Element(name: "conversation", cdata: "bob@example.org")) + payload.addChild(Element(name: "id", cdata: "msg-uuid")) + message.addChild(payload) + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: message)) + XCTAssertNil(envelope.stanzaID) + } + + func testPayloadRoundTripPreservesReadDetails() throws { + let marker = ReadStateSync.Envelope( + id: "marker-1", + conversationJID: "Room@Conference.example.org", + messageID: "msg-uuid", + stanzaID: "stanza-7", + timestamp: Date(timeIntervalSince1970: 1_700_000_000) + ) + let message = Message() + message.id = marker.id + message.addChild(ReadStateSync.payloadElement(marker: marker)) + + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: message)) + XCTAssertEqual(envelope.id, "marker-1") + XCTAssertEqual(envelope.conversationJID, "room@conference.example.org") + XCTAssertEqual(envelope.messageID, "msg-uuid") + XCTAssertEqual(envelope.stanzaID, "stanza-7") + } +}