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 committed 2026-09-14 22:22:15 +07:00
1 parent 299f78de36
commit 16c9fab509
9 files changed
+513 -19

No files matched your search

+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,