added .read state
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt
2026-09-14 22:22:15 +07:00
parent 299f78de36
commit 16c9fab509
9 changed files with 513 additions and 19 deletions
+20 -1
View File
@@ -37,7 +37,8 @@ XEP-0045-комнатами в отдельном разделе «Группо
собственных дополнительных устройств; при выключенном отправляется обычный
`<body>` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
fallback на plaintext. UI сразу показывает optimistic message и обновляет
состояние по результату записи/receipt.
состояние по результату записи/receipt; XEP-0333 `<displayed/>`-маркер
собеседника переводит исходящее сообщение в состояние «прочитано».
## OMEMO 2
@@ -218,6 +219,24 @@ trickle ICE candidates накапливаются до успешного нач
clientID карточки на исходном устройстве. Пропущенный входящий звонок
увеличивает счётчик непрочитанных на каждом устройстве.
Состояние «прочитано» (`.read`) синхронизируется между устройствами тем же
каналом. Устройство, получившее от собеседника `<displayed/>`-маркер
(XEP-0333) или, для MUC, маркер от участника комнаты, переводит исходящее
сообщение в `.read` и отправляет собственному bare JID служебное сообщение с
`<read-marker xmlns='https://luma.chat/read-marker'>` (чат, origin-id, stanza-id
сообщения). Carbons доставляют его на остальные устройства, MAM архивирует —
поэтому свежая установка и повторные прогоны архива сходятся к одному
состоянию, даже когда маркер приходит раньше самого сообщения (MAM отдаёт
станзы от новых к старым): маркер ждёт в `pendingReadMarkers` до upsert
сообщения. Для комнат маркер адресован собственному bare JID и ссылается на
stanza-id из MUC-MAM, так что состояние прочтения не попадает в архив
комнаты. При открытии 1:1-чата Luma отправляет собеседнику `<displayed/>` для
последнего входящего сообщения (в 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-инфраструктуры.
+21 -9
View File
@@ -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 = "<group>"; };
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = "<group>"; };
9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplySwipePolicy.swift; sourceTree = "<group>"; };
9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = "<group>"; };
A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneWatchBridge.swift; sourceTree = "<group>"; };
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataTests.swift; sourceTree = "<group>"; };
A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = "<group>"; };
@@ -458,6 +463,7 @@
BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaWatchApp.swift; sourceTree = "<group>"; };
C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDraft.swift; sourceTree = "<group>"; };
C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSDPOrderingTests.swift; sourceTree = "<group>"; };
C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSyncTests.swift; sourceTree = "<group>"; };
C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicyTests.swift; sourceTree = "<group>"; };
C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = "<group>"; };
C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = "<group>"; };
@@ -474,20 +480,21 @@
D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = "<group>"; };
D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = "<group>"; };
D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = "<group>"; };
D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageDeliveryTests.swift; sourceTree = "<group>"; };
D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = "<group>"; };
D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = "<group>"; };
D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = "<group>"; };
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = "<group>"; };
DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSync.swift; sourceTree = "<group>"; };
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 = "<group>"; };
E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = "<group>"; };
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = "<group>"; };
EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = "<group>"; };
EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; 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 = "<group>"; };
F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = "<group>"; };
F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = "<group>"; };
F4A6FCCE22DA85B845A81D42 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = "<group>"; };
F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslChallengeModule.swift; sourceTree = "<group>"; };
F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = "<group>"; };
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = "<group>"; };
@@ -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 */ = {
+28 -1
View File
@@ -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
+144 -6
View File
@@ -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<Void, Never>] = [:]
private var remoteTypingExpiryTasks: [String: Task<Void, Never>] = [:]
@@ -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 `<displayed/>` 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
}
+23 -2
View File
@@ -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? {
+74
View File
@@ -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
/// <displayed/> marker arrived there) it sends a message to its own bare JID
/// carrying a `<read-marker xmlns='https://luma.chat/read-marker'>` 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
}()
}
+92
View File
@@ -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 `<read-marker/>` 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 `<displayed/>` 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 `<displayed/>` 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: `<displayed/>` marks an outgoing message as read,
/// `<received/>` 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,
+38
View File
@@ -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)
}
}
+73
View File
@@ -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")
}
}