fix read state in self-chat and stop leaking read-marker into it (#18)
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

Self-chat never showed Read: the outgoing message was promoted by
sendDisplayedMarkerIfNeeded, but a later XEP-0184 <received/> receipt
or a LMM correction overwrote .read with .delivered/.sent directly,
bypassing Delivery.merged. Mark self-addressed outgoing messages read
as soon as the server accepts them and merge every weaker status into
the ladder instead of overwriting.

The self-addressed read-marker carried a plaintext <body>, so clients
that do not know our namespace (Conversations, Monal) rendered it as a
"Сообщение прочитано" bubble in the self-chat. Service stanzas now ride
on their payload element only.

Refs #18
This commit is contained in:
wt committed 2026-09-18 12:46:36 +07:00
1 parent 8a2af872d6
commit ade7efea1a
3 files changed
+51 -10

No files matched your search

+8
View File
@@ -421,6 +421,14 @@ grep -q 'syncReadState' Sources/Shared/Models/AppModel.swift || {
echo "Read receipts must be synced to the user's other devices" echo "Read receipts must be synced to the user's other devices"
exit 1 exit 1
} }
grep -q 'delivery = \$0.delivery.merged(with: .delivered)' Sources/Shared/Models/AppModel.swift || {
echo "Delivery receipts must never downgrade an already read message"
exit 1
}
if grep -q 'message.body = Self.readMarkerBody' Sources/Shared/XMPP/XMPPService.swift; then
echo "Read-marker service stanzas must not carry a plaintext body (it leaks into self-chat on other clients)"
exit 1
fi
grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || { grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || {
echo "XEP-0333 displayed markers must mark messages as read" echo "XEP-0333 displayed markers must mark messages as read"
exit 1 exit 1
+39 -7
View File
@@ -1071,10 +1071,13 @@ final class AppModel: ObservableObject {
isGroup: isGroup isGroup: isGroup
) )
updateMessage(id: id) { updateMessage(id: id) {
$0.delivery = .sent $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint $0.encryptionFingerprint = fingerprint
} }
let conversationID = peer.lowercased() let conversationID = peer.lowercased()
if conversationID == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
localTypingPauseTasks[conversationID]?.cancel() localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil localTypingPauseTasks[conversationID] = nil
localChatStateByConversation[conversationID] = .active localChatStateByConversation[conversationID] = .active
@@ -1117,9 +1120,12 @@ final class AppModel: ObservableObject {
isGroup: isGroup isGroup: isGroup
) )
updateMessage(id: id) { updateMessage(id: id) {
$0.delivery = .sent $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint $0.encryptionFingerprint = fingerprint
} }
if peer.lowercased() == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
} catch { } catch {
updateMessage(id: id) { $0.delivery = .failed } updateMessage(id: id) { $0.delivery = .failed }
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -1161,9 +1167,12 @@ final class AppModel: ObservableObject {
replacingMessageID: previous.clientID replacingMessageID: previous.clientID
) )
updateMessage(id: id) { updateMessage(id: id) {
$0.delivery = .sent $0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint $0.encryptionFingerprint = fingerprint
} }
if previous.conversationID == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
} catch { } catch {
correctionReceiptTargets.removeValue(forKey: correctionID) correctionReceiptTargets.removeValue(forKey: correctionID)
if let currentIndex = messages.firstIndex(where: { $0.clientID == id }), if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
@@ -1511,10 +1520,13 @@ final class AppModel: ObservableObject {
isGroup: isGroup isGroup: isGroup
) )
updateMessage(id: id) { updateMessage(id: id) {
$0.delivery = .sent $0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL $0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint $0.encryptionFingerprint = result.fingerprint
} }
if peer.lowercased() == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
sentID = id sentID = id
} catch { } catch {
if didInsertPending { if didInsertPending {
@@ -1565,10 +1577,13 @@ final class AppModel: ObservableObject {
isGroup: message.isGroupMessage isGroup: message.isGroupMessage
) )
updateMessage(id: message.clientID) { updateMessage(id: message.clientID) {
$0.delivery = .sent $0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL $0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint $0.encryptionFingerprint = result.fingerprint
} }
if message.conversationID == account?.normalizedJID {
markMessageAsReadIfOutgoing(id: message.clientID)
}
} catch { } catch {
updateMessage(id: message.clientID) { $0.delivery = .failed } updateMessage(id: message.clientID) { $0.delivery = .failed }
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -2209,7 +2224,13 @@ final class AppModel: ObservableObject {
Task { try? await avatarCache.store(data, for: normalized) } Task { try? await avatarCache.store(data, for: normalized) }
case .delivered(let messageID): case .delivered(let messageID):
let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID
updateMessage(id: targetID) { $0.delivery = .delivered } // A `<received/>` receipt can arrive after the chat was already
// opened (or after an XEP-0333 marker marked it read). Merge with
// the monotonic ladder instead of overwriting, so `.read` is never
// downgraded back to `.delivered`.
updateMessage(id: targetID) {
$0.delivery = $0.delivery.merged(with: .delivered)
}
case .read(let conversationID, let messageID): case .read(let conversationID, let messageID):
// Peer-originated read receipt (XEP-0333 displayed marker): apply // Peer-originated read receipt (XEP-0333 displayed marker): apply
// it locally and broadcast it to the user's other devices through // it locally and broadcast it to the user's other devices through
@@ -2531,6 +2552,17 @@ final class AppModel: ObservableObject {
} }
} }
/// Self-chat has no peer that could answer with a `<displayed/>` marker,
/// so an outgoing message counts as read the moment the server accepted
/// it. `markRead` keeps `.failed` messages untouched and never re-syncs
/// the state to other devices (cameFromPeer == false).
private func markMessageAsReadIfOutgoing(id: String) {
guard let account,
let index = messageIndex(id: id, conversationID: account.normalizedJID)
else { return }
markRead(at: index, cameFromPeer: false)
}
/// Sends an XEP-0333 `<displayed/>` marker for the newest incoming 1:1 /// 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 /// 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 /// read. MUC rooms never get markers (XEP-0333); deduped per message so
@@ -2752,7 +2784,7 @@ final class AppModel: ObservableObject {
messages[index].security = envelope.security messages[index].security = envelope.security
messages[index].encryptionFingerprint = envelope.fingerprint messages[index].encryptionFingerprint = envelope.fingerprint
if envelope.isOutgoing { if envelope.isOutgoing {
messages[index].delivery = .sent messages[index].delivery = messages[index].delivery.merged(with: .sent)
} }
updateConversationPreview(for: messages[index], incrementUnread: false) updateConversationPreview(for: messages[index], incrementUnread: false)
} }
+4 -3
View File
@@ -829,7 +829,10 @@ final class XMPPService {
message.to = JID(account.normalizedJID) message.to = JID(account.normalizedJID)
message.type = .chat message.type = .chat
message.id = marker.id message.id = marker.id
message.body = Self.readMarkerBody // No <body>: this is a service stanza for the user's own devices only.
// A plaintext body would leak into the self-chat and be rendered as a
// normal message by clients that do not know our read-marker namespace
// (Conversations, Monal), so the marker rides solely on its payload.
addOriginID(marker.id, to: message) addOriginID(marker.id, to: message)
message.addChild(ReadStateSync.payloadElement(marker: marker)) message.addChild(ReadStateSync.payloadElement(marker: marker))
client.context.writer.write(message, writeCompleted: nil) client.context.writer.write(message, writeCompleted: nil)
@@ -851,8 +854,6 @@ final class XMPPService {
client.context.writer.write(message, writeCompleted: nil) client.context.writer.write(message, writeCompleted: nil)
} }
private static let readMarkerBody = "Сообщение прочитано"
/// Detects OMEMO 2 (`urn:xmpp:omemo:2`) payloads, which the pinned /// Detects OMEMO 2 (`urn:xmpp:omemo:2`) payloads, which the pinned
/// MartinOMEMO version cannot decrypt (it only speaks the legacy /// MartinOMEMO version cannot decrypt (it only speaks the legacy
/// `eu.siacs.conversations.axolotl` namespace). /// `eu.siacs.conversations.axolotl` namespace).