git » sdk » commit 58b04ca

Switch to XEP-0490 for read/unread info

author Stephen Paul Weber
2024-06-25 15:47:58 UTC
committer Stephen Paul Weber
2024-06-25 15:47:58 UTC
parent a1e29028fd052cd8b301faf69c38e2513d862122

Switch to XEP-0490 for read/unread info

snikket/Chat.hx +88 -31
snikket/Client.hx +74 -26
snikket/persistence/Sqlite.hx +8 -2
snikket/persistence/browser.js +4 -0

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index 6d8228a..a5822e8 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -51,15 +51,20 @@ abstract class Chat {
 	private var extensions: Stanza;
 	private var _unreadCount = 0;
 	private var lastMessage: Null<ChatMessage>;
+	private var readUpToId: Null<String>;
+	@:allow(snikket)
+	private var readUpToBy: Null<String>;
 
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) {
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
 		this.client = client;
 		this.stream = stream;
 		this.persistence = persistence;
 		this.chatId = chatId;
 		this.uiState = uiState;
 		this.extensions = extensions ?? new Stanza("extensions", { xmlns: "urn:xmpp:bookmarks:1" });
+		this.readUpToId = readUpToId;
+		this.readUpToBy = readUpToBy;
 		this.displayName = chatId;
 	}
 
@@ -189,8 +194,7 @@ abstract class Chat {
 		An ID of the last message displayed to the user
 	**/
 	public function readUpTo() {
-		final displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0");
-		return displayed?.attr?.get("id");
+		return readUpToId;
 	}
 
 	/**
@@ -377,6 +381,68 @@ abstract class Chat {
 	public function videoTracks(): Array<MediaStreamTrack> {
 		return jingleSessions.flatMap((session) -> session.videoTracks());
 	}
+
+	@:allow(snikket)
+	private function markReadUpToId(upTo: String, upToBy: String, ?callback: ()->Void) {
+		if (upTo == null) return;
+
+		readUpToId = upTo;
+		readUpToBy = upToBy;
+		persistence.storeChat(client.accountId(), this);
+		persistence.getMessages(client.accountId(), chatId, null, null, (messages) -> {
+			var i = messages.length;
+			while (--i >= 0) {
+				if (messages[i].serverId == readUpToId) break;
+			}
+			if (i > 0) _unreadCount = messages.length - (i + 1);
+			if (callback != null) callback();
+		});
+	}
+
+	private function publishMds() {
+		stream.sendIq(
+			new Stanza("iq", { type: "set" })
+				.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
+				.tag("publish", { node: "urn:xmpp:mds:displayed:0" })
+				.tag("item", { id: chatId })
+				.tag("displayed", { xmlns: "urn:xmpp:mds:displayed:0"})
+				.tag("stanza-id", { xmlns: "urn:xmpp:sid:0", id: readUpTo(), by: readUpToBy })
+				.up().up().up()
+				.tag("publish-options")
+				.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+				.tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up()
+				.tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up()
+				.tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up()
+				.tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up()
+				.tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up()
+				.up().up(),
+			(response) -> {
+				if (response.attr.get("type") == "error") {
+					final preconditionError = response.getChild("error")?.getChild("precondition-not-met", "http://jabber.org/protocol/pubsub#errors");
+					if (preconditionError != null) {
+						// publish options failed, so force them to be right, what a silly workflow
+						stream.sendIq(
+							new Stanza("iq", { type: "set" })
+								.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub#owner" })
+								.tag("configure", { node: "urn:xmpp:mds:displayed:0" })
+								.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+								.tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up()
+								.tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up()
+								.tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up()
+								.tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up()
+								.tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up()
+								.up().up().up(),
+							(response) -> {
+								if (response.attr.get("type") == "result") {
+									publishMds();
+								}
+							}
+						);
+					}
+				}
+			}
+		);
+	}
 }
 
 @:expose
@@ -386,8 +452,8 @@ abstract class Chat {
 #end
 class DirectChat extends Chat {
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null) {
-		super(client, stream, persistence, chatId, uiState, extensions);
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
+		super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy);
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -523,7 +589,6 @@ class DirectChat extends Chat {
 	public function markReadUpTo(message: ChatMessage) {
 		if (readUpTo() == message.localId || readUpTo() == message.serverId) return;
 		final upTo = message.localId ?? message.serverId;
-		_unreadCount = 0; // TODO
 		if (upTo == null) return; // Can't mark as read with no id
 		for (recipient in getParticipants()) {
 			// TODO: extended addressing when relevant
@@ -535,15 +600,10 @@ class DirectChat extends Chat {
 			client.sendStanza(stanza);
 		}
 
-		var displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0");
-		if (displayed == null) {
-			displayed = new Stanza("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo });
-			extensions.addChild(displayed);
-		} else {
-			displayed.attr.set("id", upTo);
-		}
-		persistence.storeChat(client.accountId(), this);
-		client.trigger("chats/update", [this]);
+		markReadUpToId(message.serverId, message.serverIdBy, () -> {
+			publishMds();
+			client.trigger("chats/update", [this]);
+		});
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -581,8 +641,8 @@ class Channel extends Chat {
 	private var inSync = true;
 
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions = null, ?disco: Caps) {
-		super(client, stream, persistence, chatId, uiState, extensions);
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) {
+		super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy);
 		if (disco != null) this.disco = disco;
 	}
 
@@ -876,7 +936,6 @@ class Channel extends Chat {
 	public function markReadUpTo(message: ChatMessage) {
 		if (readUpTo() == message.serverId) return;
 		final upTo = message.serverId;
-		_unreadCount = 0; // TODO
 		if (upTo == null) return; // Can't mark as read with no id
 		final stanza = new Stanza("message", { to: chatId, id: ID.long(), type: "groupchat" })
 			.tag("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo }).up();
@@ -885,16 +944,10 @@ class Channel extends Chat {
 		}
 		client.sendStanza(stanza);
 
-		var displayed = extensions.getChild("displayed", "urn:xmpp:chat-markers:0");
-		if (displayed == null) {
-			displayed = new Stanza("displayed", { xmlns: "urn:xmpp:chat-markers:0", id: upTo });
-			extensions.addChild(displayed);
-		} else {
-			displayed.attr.set("id", upTo);
-		}
-		persistence.storeChat(client.accountId(), this);
-		bookmark(); // TODO: what if not previously bookmarked?
-		client.trigger("chats/update", [this]);
+		markReadUpToId(upTo, message.serverIdBy, () -> {
+			publishMds();
+			client.trigger("chats/update", [this]);
+		});
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -1004,10 +1057,12 @@ class SerializedChat {
 	public final displayName:Null<String>;
 	public final uiState:String;
 	public final extensions:String;
+	public final readUpToId:Null<String>;
+	public final readUpToBy:Null<String>;
 	public final disco:Null<Caps>;
 	public final klass:String;
 
-	public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<String>, extensions: Null<String>, disco: Null<Caps>, klass: String) {
+	public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<String>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, disco: Null<Caps>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.avatarSha1 = avatarSha1;
@@ -1015,6 +1070,8 @@ class SerializedChat {
 		this.displayName = displayName;
 		this.uiState = uiState ?? "Open";
 		this.extensions = extensions ?? "<extensions xmlns='urn:app:bookmarks:1' />";
+		this.readUpToId = readUpToId;
+		this.readUpToBy = readUpToBy;
 		this.disco = disco;
 		this.klass = klass;
 	}
@@ -1029,9 +1086,9 @@ class SerializedChat {
 		final extensionsStanza = Stanza.fromXml(Xml.parse(extensions));
 
 		final chat = if (klass == "DirectChat") {
-			new DirectChat(client, stream, persistence, chatId, uiStateEnum, extensionsStanza);
+			new DirectChat(client, stream, persistence, chatId, uiStateEnum, extensionsStanza, readUpToId, readUpToBy);
 		} else if (klass == "Channel") {
-			final channel = new Channel(client, stream, persistence, chatId, uiStateEnum, extensionsStanza);
+			final channel = new Channel(client, stream, persistence, chatId, uiStateEnum, extensionsStanza, readUpToId, readUpToBy);
 			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]);
 			channel;
 		} else {
diff --git a/snikket/Client.hx b/snikket/Client.hx
index 15fcaed..3adb2ca 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -56,6 +56,8 @@ class Client extends EventEmitter {
 			"http://jabber.org/protocol/caps",
 			"urn:xmpp:avatar:metadata+notify",
 			"http://jabber.org/protocol/nick+notify",
+			"urn:xmpp:bookmarks:1+notify",
+			"urn:xmpp:mds:displayed:0+notify",
 			"urn:xmpp:jingle-message:0",
 			"urn:xmpp:jingle:1",
 			"urn:xmpp:jingle:apps:dtls:0",
@@ -225,6 +227,23 @@ class Client extends EventEmitter {
 				updateDisplayName(pubsubEvent.getItems()[0].getChildText("nick", "http://jabber.org/protocol/nick"));
 			}
 
+			if (pubsubEvent != null && pubsubEvent.getFrom() != null && JID.parse(pubsubEvent.getFrom()).asBare().asString() == accountId() && pubsubEvent.getNode() == "urn:xmpp:mds:displayed:0" && pubsubEvent.getItems().length > 0) {
+				for (item in pubsubEvent.getItems()) {
+					if (item.attr.get("id") != null) {
+						final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0");
+						final chat = getChat(item.attr.get("id"));
+						if (chat == null) {
+							startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
+						} else {
+							chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"), () -> {
+								persistence.storeChat(accountId(), chat);
+								this.trigger("chats/update", [chat]);
+							});
+						}
+					}
+				}
+			}
+
 			return EventUnhandled; // Allow others to get this event as well
 		});
 
@@ -941,45 +960,74 @@ class Client extends EventEmitter {
 		sendQuery(rosterGet);
 	}
 
+	private function startChatWith(jid: String, handleCaps: (Caps)->UiState, handleChat: (Chat)->Void) {
+		final discoGet = new DiscoInfoGet(jid);
+		discoGet.onFinished(() -> {
+			final resultCaps = discoGet.getResult();
+			if (resultCaps == null) {
+				final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
+				if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") {
+					final chat = getDirectChat(jid, false);
+					handleChat(chat);
+					persistence.storeChat(accountId(), chat);
+				}
+			} else {
+				persistence.storeCaps(resultCaps);
+				final uiState = handleCaps(resultCaps);
+				if (resultCaps.isChannel(jid)) {
+					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, null, resultCaps);
+					handleChat(chat);
+					chats.unshift(chat);
+					persistence.storeChat(accountId(), chat);
+				} else {
+					final chat = getDirectChat(jid, false);
+					handleChat(chat);
+					persistence.storeChat(accountId(), chat);
+				}
+			}
+		});
+		sendQuery(discoGet);
+	}
+
 	// This is called right before we're going to trigger for all chats anyway, so don't bother with single triggers
 	private function bookmarksGet(callback: ()->Void) {
+		final mdsGet = new PubsubGet(null, "urn:xmpp:mds:displayed:0");
+		mdsGet.onFinished(() -> {
+			for (item in mdsGet.getResult()) {
+				if (item.attr.get("id") != null) {
+					final upTo = item.getChild("displayed", "urn:xmpp:mds:displayed:0")?.getChild("stanza-id", "urn:xmpp:sid:0");
+					final chat = getChat(item.attr.get("id"));
+					if (chat == null) {
+						startChatWith(item.attr.get("id"), (caps) -> Closed, (chat) -> chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by")));
+					} else {
+						chat.markReadUpToId(upTo.attr.get("id"), upTo.attr.get("by"));
+						persistence.storeChat(accountId(), chat);
+					}
+				}
+			}
+		});
+		sendQuery(mdsGet);
+
 		final pubsubGet = new PubsubGet(null, "urn:xmpp:bookmarks:1");
 		pubsubGet.onFinished(() -> {
 			for (item in pubsubGet.getResult()) {
 				if (item.attr.get("id") != null) {
 					final chat = getChat(item.attr.get("id"));
 					if (chat == null) {
-						final discoGet = new DiscoInfoGet(item.attr.get("id"));
-						discoGet.onFinished(() -> {
-							final resultCaps = discoGet.getResult();
-							if (resultCaps == null) {
-								final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
-								if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") {
-									final chat = getDirectChat(item.attr.get("id"), false);
-									chat.updateFromBookmark(item);
-									persistence.storeChat(accountId(), chat);
-								}
-							} else {
-								persistence.storeCaps(resultCaps);
-								final identity = resultCaps.identities[0];
+						startChatWith(
+							item.attr.get("id"),
+							(caps) -> {
+								final identity = caps.identities[0];
 								final conf = item.getChild("conference", "urn:xmpp:bookmarks:1");
 								if (conf.attr.get("name") == null) {
 									conf.attr.set("name", identity?.name);
 								}
-								if (resultCaps.isChannel(item.attr.get("id"))) {
-									final uiState = (conf.attr.get("autojoin") == "1" || conf.attr.get("autojoin") == "true") ? Open : Closed;
-									final chat = new Channel(this, this.stream, this.persistence, item.attr.get("id"), uiState, null, resultCaps);
-									chat.updateFromBookmark(item);
-									chats.unshift(chat);
-									persistence.storeChat(accountId(), chat);
-								} else {
-									final chat = getDirectChat(item.attr.get("id"), false);
-									chat.updateFromBookmark(item);
-									persistence.storeChat(accountId(), chat);
-								}
+								return (conf.attr.get("autojoin") == "1" || conf.attr.get("autojoin") == "true" || !caps.isChannel(item.attr.get("id"))) ? Open : Closed;
+							},
+							(chat) -> {
+								chat.updateFromBookmark(item);
 							}
-						});
-						sendQuery(discoGet);
+						);
 					} else {
 						chat.updateFromBookmark(item);
 						persistence.storeChat(accountId(), chat);
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index e929542..579a84b 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -59,6 +59,8 @@ class Sqlite implements Persistence {
 				fn TEXT,
 				ui_state TEXT,
 				extensions TEXT,
+				read_up_to_id TEXT,
+				read_up_to_by TEXT,
 				class TEXT NOT NULL,
 				PRIMARY KEY (account_id, chat_id)
 			);");
@@ -125,6 +127,10 @@ class Sqlite implements Persistence {
 		q.add(",");
 		db.addValue(q, chat.extensions);
 		q.add(",");
+		db.addValue(q, chat.readUpTo());
+		q.add(",");
+		db.addValue(q, chat.readUpToBy);
+		q.add(",");
 		db.addValue(q, Type.getClassName(Type.getClass(chat)).split(".").pop());
 		q.add(");");
 		db.request(q.toString());
@@ -135,12 +141,12 @@ class Sqlite implements Persistence {
 		// TODO: presence
 		// TODO: disco
 		final q = new StringBuf();
-		q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, extensions, class FROM chats WHERE account_id=");
+		q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, extensions, read_up_to_id, read_up_to_by, class FROM chats WHERE account_id=");
 		db.addValue(q, accountId);
 		final result = db.request(q.toString());
 		final chats = [];
 		for (row in result) {
-			chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.extensions, null, Reflect.field(row, "class")));
+			chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.extensions, row.read_up_to_id, row.read_up_to_by, null, Reflect.field(row, "class")));
 		}
 		callback(chats);
 	}
diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js
index ba0075e..a18fcfa 100644
--- a/snikket/persistence/browser.js
+++ b/snikket/persistence/browser.js
@@ -186,6 +186,8 @@ const browser = (dbname) => {
 				displayName: chat.displayName,
 				uiState: chat.uiState,
 				extensions: chat.extensions?.toString(),
+				readUpToId: chat.readUpToId,
+				readUpToBy: chat.readUpToBy,
 				disco: chat.disco,
 				class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat")
 			});
@@ -207,6 +209,8 @@ const browser = (dbname) => {
 					r.displayName,
 					r.uiState,
 					r.extensions,
+					r.readUpToId,
+					r.readUpToBy,
 					r.disco,
 					r.class
 				)));