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")
+ }
+}