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

+39 -7
View File
@@ -1071,10 +1071,13 @@ final class AppModel: ObservableObject {
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
let conversationID = peer.lowercased()
if conversationID == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
localChatStateByConversation[conversationID] = .active
@@ -1117,9 +1120,12 @@ final class AppModel: ObservableObject {
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
if peer.lowercased() == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
} catch {
updateMessage(id: id) { $0.delivery = .failed }
errorMessage = error.localizedDescription
@@ -1161,9 +1167,12 @@ final class AppModel: ObservableObject {
replacingMessageID: previous.clientID
)
updateMessage(id: id) {
$0.delivery = .sent
$0.delivery = $0.delivery.merged(with: .sent)
$0.encryptionFingerprint = fingerprint
}
if previous.conversationID == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
} catch {
correctionReceiptTargets.removeValue(forKey: correctionID)
if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
@@ -1511,10 +1520,13 @@ final class AppModel: ObservableObject {
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
if peer.lowercased() == account.normalizedJID {
markMessageAsReadIfOutgoing(id: id)
}
sentID = id
} catch {
if didInsertPending {
@@ -1565,10 +1577,13 @@ final class AppModel: ObservableObject {
isGroup: message.isGroupMessage
)
updateMessage(id: message.clientID) {
$0.delivery = .sent
$0.delivery = $0.delivery.merged(with: .sent)
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
if message.conversationID == account?.normalizedJID {
markMessageAsReadIfOutgoing(id: message.clientID)
}
} catch {
updateMessage(id: message.clientID) { $0.delivery = .failed }
errorMessage = error.localizedDescription
@@ -2209,7 +2224,13 @@ final class AppModel: ObservableObject {
Task { try? await avatarCache.store(data, for: normalized) }
case .delivered(let 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):
// Peer-originated read receipt (XEP-0333 displayed marker): apply
// 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
/// 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
@@ -2752,7 +2784,7 @@ final class AppModel: ObservableObject {
messages[index].security = envelope.security
messages[index].encryptionFingerprint = envelope.fingerprint
if envelope.isOutgoing {
messages[index].delivery = .sent
messages[index].delivery = messages[index].delivery.merged(with: .sent)
}
updateConversationPreview(for: messages[index], incrementUnread: false)
}