Поправить отображения состояния read у сообщения #18

Closed
opened 2026-09-18 03:14:40 +00:00 by wt · 0 comments
Owner

Что происходит сейчас

У тебя уже реализована почти вся инфраструктура XEP-0333:

<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:

client.module(.chatMarkers).markersPublisher
    .receive(on: DispatchQueue.main)
    .sink { [weak self] received in
        self?.handleChatMarker(received)
    }

А сам обработчик:

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

Там структура:

struct Envelope: Equatable, Sendable {
    let id: String
    let conversationJID: String
    let messageID: String
    let stanzaID: String?
    let timestamp: Date
}

И payload:

<read-marker xmlns="https://luma.chat/read-marker">
    <conversation>...</conversation>
    <id>...</id>
    <stanza>...</stanza>
    <when>...</when>
</read-marker>

Это сделано специально для синхронизации прочитанного между собственными устройствами через Carbons/MAM.

Отправляется он в XMPPService.syncReadState():

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

И вот здесь появляется твоя строка:

private static let readMarkerBody = "Сообщение прочитано"

То есть "Сообщение прочитано" — это не UI-текст статуса. Это body специального служебного XMPP-сообщения, которое приложение само отправляет себе.


3. Где используется pendingReadMarkers

В AppModel.swift.

Когда <displayed/> приходит раньше самого сообщения:

private var pendingReadMarkers:
    [String: PendingReadMarker] = [:]

Это сделано из-за порядка MAM: read-marker может прийти раньше сообщения, которое он помечает.

Маркер складывается через:

private func stashPendingReadMarker(
    _ envelope: ReadStateSync.Envelope,
    cameFromPeer: Bool
)

Он индексирует его по messageID и stanzaID.

Когда сообщение появляется:

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

Вот здесь:

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.

Сейчас:

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

И:

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

Поэтому при:

message.delivery == .read

UI автоматически покажет:

✓ Read

Тут ничего менять не нужно.


6. Почему именно self-chat ломается

Вот здесь находится главная причина.

В AppModel.sendDisplayedMarkerIfNeeded() сейчас есть:

guard let account else { return }

let normalized = conversationID.lowercased()

guard normalized != account.normalizedJID else { return }

То есть приложение явно запрещает отправлять <displayed/> в self-chat.

Дальше код работает только для обычного чата:

guard let latest = messages
    .filter({
        $0.conversationID == normalized
        && $0.direction == .incoming
    })
    ...

Для self-chat это тоже принципиальная проблема: там собеседник и твой аккаунт — один и тот же bare JID.

В XMPP коде outgoing определяется так:

let ownJID = client.userBareJID
let outgoing = sender == ownJID

Поэтому сообщение self-chat является outgoing, что правильно.

Но sendDisplayedMarkerIfNeeded() ожидает incoming сообщение и дополнительно запрещает conversation, равный собственному JID.

Получается:

обычный чат:

incoming message
       ↓
send <displayed/>
       ↓
peer
       ↓
.read
       ↓
Read


self-chat:

outgoing message
       ↓
guard normalized != ownJID  ← STOP
       ↓
.read никогда не устанавливается

Вот почему именно self-chat отличается.


7. А откуда берётся "Сообщение прочитано"

Вот это тоже важная часть.

Когда peer присылает <displayed/>, markRead() делает:

xmpp.syncReadState(...)

syncReadState() отправляет служебное сообщение самому себе:

message.to = JID(account.normalizedJID)
message.body = "Сообщение прочитано"
message.addChild(
    ReadStateSync.payloadElement(marker: marker)
)

Это должно быть невидимым для обычного чата.

В XMPPService.handle(...) уже есть правильная попытка:

if let marker = ReadStateSync.envelope(from: message) {
    eventHandler?(.readState(marker))
    return
}

То есть задумка правильная: служебное сообщение должно перехватиться и не попасть в MessageEnvelope.

Но если у тебя реально в истории появляется:

Сообщение прочитано

значит конкретный экземпляр этого self-addressed stanza всё-таки проходит обычный message pipeline вместо ReadStateSync pipeline. Особенно это может проявляться при MAM/replay.

И это надо исправить на уровне XMPP, а не в MessageBubble.


Точный patch

Я бы сделал два изменения.

PATCH №1 — self-chat должен локально помечать свои сообщения как Read

Файл:

Sources/Shared/Models/AppModel.swift

Функция:

private func sendDisplayedMarkerIfNeeded(
    for conversationID: String
)

Сейчас там логика рассчитана только на peer.

Я бы заменил функцию целиком на такую:

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:

Я → себе: Hello

delivery:
.sent

открываем self-chat

        ↓

markRead(...)

        ↓

delivery:
.read

        ↓

MessageBubble

        ↓

✓ Read

И главное — мы не отправляем <displayed/> самому себе. Это не нужно.


PATCH №2 — служебный "Сообщение прочитано" никогда не должен становиться ChatMessage

Файл:

Sources/Shared/XMPP/XMPPService.swift

Функция:

private func handle(
    message: Message,
    timestamp: Date,
    archiveID: String?,
    isArchived: Bool = false,
    archivedPeerJID: BareJID? = nil
) async

В начале функции у тебя уже есть:

if let sync = CallHistorySync.envelope(from: message) {
    ...
    return
}

Сразу после этого блока должен находиться read-state guard.

Сделай его таким:

// 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 действительно вызывает:

await self.handle(
    message: stanza.message,
    timestamp: stanza.timestamp,
    archiveID: stanza.archiveID,
    isArchived: true,
    archivedPeerJID: ...
)

Так что именно здесь должен отрабатываться ReadStateSync до создания MessageEnvelope.


Я бы ещё сделал одну маленькую защиту

В ReadStateSync.envelope(from:) можно дополнительно проверять, что сообщение действительно адресовано самому пользователю.

Сейчас:

static func envelope(from message: Message) -> Envelope? {
    guard let element = message.element.findChild(
        name: "read-marker",
        xmlns: namespace
    ) else {
        return nil
    }

    ...
}

Я бы не стал пока менять это, потому что payload namespace уже является достаточно сильным идентификатором служебного сообщения.

Сначала лучше исправить два места выше.


Итоговая схема после patch

Будет так.

Обычный чат

Bob отправил:
"Привет"

          ↓
Alice открывает чат

          ↓
XEP-0333
<displayed id="origin-id"/>

          ↓
XMPPService.handleChatMarker()

          ↓
AppModel.consume(.read)

          ↓
applyReadState()

          ↓
markRead()

          ↓
message.delivery = .read

          ↓
MessageBubble

✓ Read

Self-chat

Я → себе:
"Тест"

delivery = .sent

          ↓
открываем self-chat

          ↓
sendDisplayedMarkerIfNeeded()

conversation == own JID
          ↓
self-chat branch

          ↓
markRead(..., cameFromPeer: false)

          ↓
delivery = .read

          ↓
MessageBubble

✓ Read

Read-state sync между устройствами

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 уже полностью поддержан:

case .read:
    return "checkmark.circle.fill"

и:

case .read:
    return "Read"

ChatView.swift

Тоже пока ничего не менять. ChatView получает те же ChatMessage, а bubble уже умеет реагировать на delivery.


Самая важная причина

У тебя не отсутствует реализация Read.

Она уже реализована.

Баг находится именно в том, что self-chat попадает в отдельный случай:

guard normalized != account.normalizedJID else { return }

и поэтому никогда не проходит через markRead().

А "Сообщение прочитано" — это внутреннее служебное сообщение ReadStateSync, которое должно оставаться транспортным и не превращаться в обычный ChatMessage. Архитектура репозитория это уже предполагает, но этот путь нужно сделать абсолютно непроницаемым для обычного message pipeline.

Если применить именно эти два изменения, ожидаемое состояние для self-chat будет:

┌──────────────────────────────┐
│ Я                            │
│                              │
│ Привет                       │
│ 12:41              ✓ Read    │
└──────────────────────────────┘

а не отдельная строка Сообщение прочитано.

### Что происходит сейчас У тебя уже реализована почти вся инфраструктура 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 │ └──────────────────────────────┘ ``` а не отдельная строка `Сообщение прочитано`.
wt added the Kind/Bug
Priority
High
2
labels 2026-09-18 03:14:40 +00:00
wt self-assigned this 2026-09-18 03:14:41 +00:00
wt added the
Reviewed
Confirmed
1
label 2026-09-18 06:30:33 +00:00
wt added reference develop 2026-09-18 06:30:38 +00:00
wt closed this issue 2026-09-18 06:30:47 +00:00
Sign in to join this conversation.