То есть задумка правильная: служебное сообщение должно перехватиться и не попасть в MessageEnvelope.
Но если у тебя реально в истории появляется:
Сообщение прочитано
значит конкретный экземпляр этого self-addressed stanza всё-таки проходит обычный message pipeline вместо ReadStateSync pipeline. Особенно это может проявляться при MAM/replay.
И это надо исправить на уровне XMPP, а не в MessageBubble.
Точный patch
Я бы сделал два изменения.
PATCH №1 — self-chat должен локально помечать свои сообщения как Read
privatefuncsendDisplayedMarkerIfNeeded(forconversationID:String){guardletaccountelse{return}letnormalized=conversationID.lowercased()// Self-chat: there is no peer to send <displayed/> to.// Messages sent to our own bare JID are already read by this device// when the conversation is opened.ifnormalized==account.normalizedJID{letoutgoingMessages=messages.indices.filter{indexinletmessage=messages[index]returnmessage.conversationID==normalized&&message.direction==.outgoing&&message.delivery!=.failed&&!message.isRetracted&&message.kind!=.system}forindexinoutgoingMessages{markRead(at:index,cameFromPeer:false)}return}// Normal 1:1 chat: send XEP-0333 <displayed/> for the newest// incoming message.guardletlatest=messages.filter({$0.conversationID==normalized&&$0.direction==.incoming&&!$0.isRetracted&&$0.kind!=.system}).max(by:{if$0.timestamp!=$1.timestamp{return$0.timestamp<$1.timestamp}return$0.clientID<$1.clientID})else{return}guardlastDisplayedMarkerByConversation[normalized]!=latest.clientIDelse{return}lastDisplayedMarkerByConversation[normalized]=latest.clientIDxmpp.sendDisplayedMarker(messageID:latest.clientID,to:normalized)}
Сразу после этого блока должен находиться read-state guard.
Сделай его таким:
// Read-marker service messages are transport messages only.// They must never become ordinary ChatMessage objects.ifletmarker=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}
У тебя похожий код уже есть — поэтому не нужно создавать второй обработчик. Нужно убедиться, что он находится в общем handle(...), который вызывается и для live сообщений, и для MAM.
А "Сообщение прочитано" — это внутреннее служебное сообщение ReadStateSync, которое должно оставаться транспортным и не превращаться в обычный ChatMessage. Архитектура репозитория это уже предполагает, но этот путь нужно сделать абсолютно непроницаемым для обычного message pipeline.
Если применить именно эти два изменения, ожидаемое состояние для self-chat будет:
### Что происходит сейчас
У тебя уже реализована почти вся инфраструктура XEP-0333:
```text
<displayed id="..."/>
↓
XMPPService.handleChatMarker()
↓
Event.read(conversationJID, messageID)
↓
AppModel.consume(.read)
↓
applyReadState(...)
↓
markRead(...)
↓
message.delivery = .read
↓
MessageBubble → иконка + "Read"
```
`MessageBubble` уже умеет показывать `.read`: `checkmark.circle.fill` + `Read`. То есть **UI переделывать не надо**.
Проблема именно в обработке self-chat и read-state sync.
---
## 1. Где принимается `<displayed/>`
В `Sources/Shared/XMPP/XMPPService.swift`.
Там подписка на Martin:
```swift
client.module(.chatMarkers).markersPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] received in
self?.handleChatMarker(received)
}
```
А сам обработчик:
```swift
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
}
}
```
То есть `<displayed/>` принимается правильно.
---
# 2. Где формируется `ReadStateSync`
Файл:
`Sources/Shared/XMPP/ReadStateSync.swift`
Там структура:
```swift
struct Envelope: Equatable, Sendable {
let id: String
let conversationJID: String
let messageID: String
let stanzaID: String?
let timestamp: Date
}
```
И payload:
```xml
<read-marker xmlns="https://luma.chat/read-marker">
<conversation>...</conversation>
<id>...</id>
<stanza>...</stanza>
<when>...</when>
</read-marker>
```
Это сделано специально для синхронизации прочитанного между собственными устройствами через Carbons/MAM.
Отправляется он в `XMPPService.syncReadState()`:
```swift
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)
}
```
И вот здесь появляется твоя строка:
```swift
private static let readMarkerBody = "Сообщение прочитано"
```
То есть **"Сообщение прочитано" — это не UI-текст статуса. Это body специального служебного XMPP-сообщения**, которое приложение само отправляет себе.
---
# 3. Где используется `pendingReadMarkers`
В `AppModel.swift`.
Когда `<displayed/>` приходит раньше самого сообщения:
```swift
private var pendingReadMarkers:
[String: PendingReadMarker] = [:]
```
Это сделано из-за порядка MAM: read-marker может прийти раньше сообщения, которое он помечает.
Маркер складывается через:
```swift
private func stashPendingReadMarker(
_ envelope: ReadStateSync.Envelope,
cameFromPeer: Bool
)
```
Он индексирует его по `messageID` и `stanzaID`.
Когда сообщение появляется:
```swift
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 })
...
markRead(
at: index,
cameFromPeer: pending.cameFromPeer
)
}
```
То есть сама логика сопоставления `clientID/originID/stanzaID` уже хорошая.
---
# 4. Где реально ставится `.read`
Вот здесь:
```swift
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,
...
)
)
}
```
То есть **`.read` уже устанавливается именно на исходном сообщении**, а не должен быть отдельным сообщением.
Это важный момент: архитектура у тебя в этом месте правильная.
---
# 5. Где появляется `Read`
В `Sources/Shared/UI/Components/MessageBubble.swift`.
Сейчас:
```swift
private var deliveryIcon: String {
switch message.delivery {
case .sending:
return "clock"
case .sent:
return "checkmark"
case .delivered:
return "checkmark.circle.dotted"
case .failed:
return "exclamationmark.circle.fill"
case .read:
return "checkmark.circle.fill"
}
}
```
И:
```swift
private var deliveryText: String {
switch message.delivery {
case .sending:
return "Sending..."
case .sent:
return "Sended"
case .delivered:
return "Delivered"
case .failed:
return "Error"
case .read:
return "Read"
}
}
```
Поэтому при:
```swift
message.delivery == .read
```
UI **автоматически** покажет:
```text
✓ Read
```
Тут ничего менять не нужно.
---
# 6. Почему именно self-chat ломается
Вот здесь находится главная причина.
В `AppModel.sendDisplayedMarkerIfNeeded()` сейчас есть:
```swift
guard let account else { return }
let normalized = conversationID.lowercased()
guard normalized != account.normalizedJID else { return }
```
То есть приложение **явно запрещает отправлять `<displayed/>` в self-chat**.
Дальше код работает только для обычного чата:
```swift
guard let latest = messages
.filter({
$0.conversationID == normalized
&& $0.direction == .incoming
})
...
```
Для self-chat это тоже принципиальная проблема: там собеседник и твой аккаунт — один и тот же bare JID.
В XMPP коде outgoing определяется так:
```swift
let ownJID = client.userBareJID
let outgoing = sender == ownJID
```
Поэтому сообщение self-chat является `outgoing`, что правильно.
Но `sendDisplayedMarkerIfNeeded()` ожидает **incoming** сообщение и дополнительно запрещает conversation, равный собственному JID.
Получается:
```text
обычный чат:
incoming message
↓
send <displayed/>
↓
peer
↓
.read
↓
Read
self-chat:
outgoing message
↓
guard normalized != ownJID ← STOP
↓
.read никогда не устанавливается
```
Вот почему именно self-chat отличается.
---
# 7. А откуда берётся "Сообщение прочитано"
Вот это тоже важная часть.
Когда peer присылает `<displayed/>`, `markRead()` делает:
```swift
xmpp.syncReadState(...)
```
`syncReadState()` отправляет **служебное сообщение самому себе**:
```swift
message.to = JID(account.normalizedJID)
message.body = "Сообщение прочитано"
message.addChild(
ReadStateSync.payloadElement(marker: marker)
)
```
Это должно быть невидимым для обычного чата.
В `XMPPService.handle(...)` уже есть правильная попытка:
```swift
if let marker = ReadStateSync.envelope(from: message) {
eventHandler?(.readState(marker))
return
}
```
То есть задумка правильная: служебное сообщение должно перехватиться и **не попасть в `MessageEnvelope`**.
Но если у тебя реально в истории появляется:
```text
Сообщение прочитано
```
значит конкретный экземпляр этого self-addressed stanza всё-таки проходит обычный message pipeline вместо `ReadStateSync` pipeline. Особенно это может проявляться при MAM/replay.
И это надо исправить на уровне XMPP, а не в `MessageBubble`.
---
# Точный patch
Я бы сделал **два изменения**.
## PATCH №1 — self-chat должен локально помечать свои сообщения как Read
Файл:
```text
Sources/Shared/Models/AppModel.swift
```
Функция:
```swift
private func sendDisplayedMarkerIfNeeded(
for conversationID: String
)
```
Сейчас там логика рассчитана только на peer.
Я бы заменил функцию целиком на такую:
```swift
private func sendDisplayedMarkerIfNeeded(
for conversationID: String
) {
guard let account else { return }
let normalized = conversationID.lowercased()
// Self-chat: there is no peer to send <displayed/> to.
// Messages sent to our own bare JID are already read by this device
// when the conversation is opened.
if normalized == account.normalizedJID {
let outgoingMessages = messages.indices.filter { index in
let message = messages[index]
return message.conversationID == normalized
&& message.direction == .outgoing
&& message.delivery != .failed
&& !message.isRetracted
&& message.kind != .system
}
for index in outgoingMessages {
markRead(at: index, cameFromPeer: false)
}
return
}
// Normal 1:1 chat: send XEP-0333 <displayed/> for the newest
// incoming message.
guard let latest = messages
.filter({
$0.conversationID == normalized
&& $0.direction == .incoming
&& !$0.isRetracted
&& $0.kind != .system
})
.max(by: {
if $0.timestamp != $1.timestamp {
return $0.timestamp < $1.timestamp
}
return $0.clientID < $1.clientID
})
else {
return
}
guard lastDisplayedMarkerByConversation[normalized]
!= latest.clientID
else {
return
}
lastDisplayedMarkerByConversation[normalized] = latest.clientID
xmpp.sendDisplayedMarker(
messageID: latest.clientID,
to: normalized
)
}
```
### Что это даст
В self-chat:
```text
Я → себе: Hello
delivery:
.sent
открываем self-chat
↓
markRead(...)
↓
delivery:
.read
↓
MessageBubble
↓
✓ Read
```
И главное — мы **не отправляем `<displayed/>` самому себе**. Это не нужно.
---
# PATCH №2 — служебный `"Сообщение прочитано"` никогда не должен становиться ChatMessage
Файл:
```text
Sources/Shared/XMPP/XMPPService.swift
```
Функция:
```swift
private func handle(
message: Message,
timestamp: Date,
archiveID: String?,
isArchived: Bool = false,
archivedPeerJID: BareJID? = nil
) async
```
В начале функции у тебя уже есть:
```swift
if let sync = CallHistorySync.envelope(from: message) {
...
return
}
```
Сразу **после этого блока** должен находиться read-state guard.
Сделай его таким:
```swift
// Read-marker service messages are transport messages only.
// They must never become ordinary ChatMessage objects.
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
}
```
У тебя похожий код уже есть — поэтому **не нужно создавать второй обработчик**. Нужно убедиться, что он находится в общем `handle(...)`, который вызывается и для live сообщений, и для MAM.
По текущему коду MAM действительно вызывает:
```swift
await self.handle(
message: stanza.message,
timestamp: stanza.timestamp,
archiveID: stanza.archiveID,
isArchived: true,
archivedPeerJID: ...
)
```
Так что именно здесь должен отрабатываться `ReadStateSync` до создания `MessageEnvelope`.
---
# Я бы ещё сделал одну маленькую защиту
В `ReadStateSync.envelope(from:)` можно дополнительно проверять, что сообщение действительно адресовано самому пользователю.
Сейчас:
```swift
static func envelope(from message: Message) -> Envelope? {
guard let element = message.element.findChild(
name: "read-marker",
xmlns: namespace
) else {
return nil
}
...
}
```
Я бы **не стал пока менять это**, потому что payload namespace уже является достаточно сильным идентификатором служебного сообщения.
Сначала лучше исправить два места выше.
---
# Итоговая схема после patch
Будет так.
### Обычный чат
```text
Bob отправил:
"Привет"
↓
Alice открывает чат
↓
XEP-0333
<displayed id="origin-id"/>
↓
XMPPService.handleChatMarker()
↓
AppModel.consume(.read)
↓
applyReadState()
↓
markRead()
↓
message.delivery = .read
↓
MessageBubble
✓ Read
```
### Self-chat
```text
Я → себе:
"Тест"
delivery = .sent
↓
открываем self-chat
↓
sendDisplayedMarkerIfNeeded()
conversation == own JID
↓
self-chat branch
↓
markRead(..., cameFromPeer: false)
↓
delivery = .read
↓
MessageBubble
✓ Read
```
### Read-state sync между устройствами
```text
Device A
↓
peer прочитал сообщение
↓
.delivery = .read
↓
syncReadState()
↓
self-addressed XMPP message
↓
<read-marker .../>
↓
Device B
↓
ReadStateSync.envelope()
↓
.readState
↓
applyReadState()
↓
.delivery = .read
```
И **`"Сообщение прочитано"` в истории не появляется**.
---
## Что менять НЕ надо
### `ReadStateSync.swift`
Оставить как есть. Его модель хорошая.
### `MessageBubble.swift`
Оставить как есть. `.read` уже полностью поддержан:
```swift
case .read:
return "checkmark.circle.fill"
```
и:
```swift
case .read:
return "Read"
```
### `ChatView.swift`
Тоже пока ничего не менять. `ChatView` получает те же `ChatMessage`, а bubble уже умеет реагировать на `delivery`.
---
### Самая важная причина
У тебя не отсутствует реализация Read.
**Она уже реализована.**
Баг находится именно в том, что self-chat попадает в отдельный случай:
```swift
guard normalized != account.normalizedJID else { return }
```
и поэтому никогда не проходит через `markRead()`.
А `"Сообщение прочитано"` — это внутреннее служебное сообщение `ReadStateSync`, которое должно оставаться транспортным и не превращаться в обычный `ChatMessage`. Архитектура репозитория это уже предполагает, но этот путь нужно сделать абсолютно непроницаемым для обычного message pipeline.
Если применить именно эти два изменения, ожидаемое состояние для self-chat будет:
```text
┌──────────────────────────────┐
│ Я │
│ │
│ Привет │
│ 12:41 ✓ Read │
└──────────────────────────────┘
```
а не отдельная строка `Сообщение прочитано`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Что происходит сейчас
У тебя уже реализована почти вся инфраструктура XEP-0333:
MessageBubbleуже умеет показывать.read:checkmark.circle.fill+Read. То есть UI переделывать не надо.Проблема именно в обработке self-chat и read-state sync.
1. Где принимается
<displayed/>В
Sources/Shared/XMPP/XMPPService.swift.Там подписка на Martin:
А сам обработчик:
То есть
<displayed/>принимается правильно.2. Где формируется
ReadStateSyncФайл:
Sources/Shared/XMPP/ReadStateSync.swiftТам структура:
И payload:
Это сделано специально для синхронизации прочитанного между собственными устройствами через Carbons/MAM.
Отправляется он в
XMPPService.syncReadState():И вот здесь появляется твоя строка:
То есть "Сообщение прочитано" — это не UI-текст статуса. Это body специального служебного XMPP-сообщения, которое приложение само отправляет себе.
3. Где используется
pendingReadMarkersВ
AppModel.swift.Когда
<displayed/>приходит раньше самого сообщения:Это сделано из-за порядка MAM: read-marker может прийти раньше сообщения, которое он помечает.
Маркер складывается через:
Он индексирует его по
messageIDиstanzaID.Когда сообщение появляется:
То есть сама логика сопоставления
clientID/originID/stanzaIDуже хорошая.4. Где реально ставится
.readВот здесь:
То есть
.readуже устанавливается именно на исходном сообщении, а не должен быть отдельным сообщением.Это важный момент: архитектура у тебя в этом месте правильная.
5. Где появляется
ReadВ
Sources/Shared/UI/Components/MessageBubble.swift.Сейчас:
И:
Поэтому при:
UI автоматически покажет:
Тут ничего менять не нужно.
6. Почему именно self-chat ломается
Вот здесь находится главная причина.
В
AppModel.sendDisplayedMarkerIfNeeded()сейчас есть:То есть приложение явно запрещает отправлять
<displayed/>в self-chat.Дальше код работает только для обычного чата:
Для self-chat это тоже принципиальная проблема: там собеседник и твой аккаунт — один и тот же bare JID.
В XMPP коде outgoing определяется так:
Поэтому сообщение self-chat является
outgoing, что правильно.Но
sendDisplayedMarkerIfNeeded()ожидает incoming сообщение и дополнительно запрещает conversation, равный собственному JID.Получается:
Вот почему именно self-chat отличается.
7. А откуда берётся "Сообщение прочитано"
Вот это тоже важная часть.
Когда peer присылает
<displayed/>,markRead()делает:syncReadState()отправляет служебное сообщение самому себе:Это должно быть невидимым для обычного чата.
В
XMPPService.handle(...)уже есть правильная попытка:То есть задумка правильная: служебное сообщение должно перехватиться и не попасть в
MessageEnvelope.Но если у тебя реально в истории появляется:
значит конкретный экземпляр этого self-addressed stanza всё-таки проходит обычный message pipeline вместо
ReadStateSyncpipeline. Особенно это может проявляться при MAM/replay.И это надо исправить на уровне XMPP, а не в
MessageBubble.Точный patch
Я бы сделал два изменения.
PATCH №1 — self-chat должен локально помечать свои сообщения как Read
Файл:
Функция:
Сейчас там логика рассчитана только на peer.
Я бы заменил функцию целиком на такую:
Что это даст
В self-chat:
И главное — мы не отправляем
<displayed/>самому себе. Это не нужно.PATCH №2 — служебный
"Сообщение прочитано"никогда не должен становиться ChatMessageФайл:
Функция:
В начале функции у тебя уже есть:
Сразу после этого блока должен находиться read-state guard.
Сделай его таким:
У тебя похожий код уже есть — поэтому не нужно создавать второй обработчик. Нужно убедиться, что он находится в общем
handle(...), который вызывается и для live сообщений, и для MAM.По текущему коду MAM действительно вызывает:
Так что именно здесь должен отрабатываться
ReadStateSyncдо созданияMessageEnvelope.Я бы ещё сделал одну маленькую защиту
В
ReadStateSync.envelope(from:)можно дополнительно проверять, что сообщение действительно адресовано самому пользователю.Сейчас:
Я бы не стал пока менять это, потому что payload namespace уже является достаточно сильным идентификатором служебного сообщения.
Сначала лучше исправить два места выше.
Итоговая схема после patch
Будет так.
Обычный чат
Self-chat
Read-state sync между устройствами
И
"Сообщение прочитано"в истории не появляется.Что менять НЕ надо
ReadStateSync.swiftОставить как есть. Его модель хорошая.
MessageBubble.swiftОставить как есть.
.readуже полностью поддержан:и:
ChatView.swiftТоже пока ничего не менять.
ChatViewполучает те жеChatMessage, а bubble уже умеет реагировать наdelivery.Самая важная причина
У тебя не отсутствует реализация Read.
Она уже реализована.
Баг находится именно в том, что self-chat попадает в отдельный случай:
и поэтому никогда не проходит через
markRead().А
"Сообщение прочитано"— это внутреннее служебное сообщениеReadStateSync, которое должно оставаться транспортным и не превращаться в обычныйChatMessage. Архитектура репозитория это уже предполагает, но этот путь нужно сделать абсолютно непроницаемым для обычного message pipeline.Если применить именно эти два изменения, ожидаемое состояние для self-chat будет:
а не отдельная строка
Сообщение прочитано.