git » sdk » commit 47b99c0

Allow setting and viewing rich presence

author Stephen Paul Weber
2026-04-27 19:11:13 UTC
committer Stephen Paul Weber
2026-04-27 19:33:48 UTC
parent fa710d55f31612a2edcec5b709c791f61b8b8e74

Allow setting and viewing rich presence

borogove/Chat.hx +7 -2
borogove/Client.hx +38 -6
borogove/Participant.hx +19 -0
borogove/persistence/IDB.js +3 -0
borogove/persistence/Sqlite.hx +3 -2
npm/index.ts +1 -0
test/TestAll.hx +2 -0
test/TestClient.hx +66 -0
test/TestParticipant.hx +45 -0
test/TestStatus.hx +30 -0
test/idb.spec.ts +35 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 109b231..8c12a87 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -74,6 +74,8 @@ abstract class Chat {
 	@:allow(borogove)
 	private var presence:Map<String, Presence> = [];
 	private var trusted:Bool = false;
+	@:allow(borogove)
+	public var status(default, null):Status = new Status("", "");
 	/**
 		ID of this Chat
 	**/
@@ -1785,7 +1787,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 				placeholderUri,
 				false,
 				roles,
-				jid,
+				trueJid == null ? jid : JID.parse(trueJid),
 				trueJid == null ? null : new AvailableChat(trueJid, nick ?? "", '$trueJid (via ${displayName})', new Caps("", [], [], []))
 			);
 		}
@@ -2223,6 +2225,7 @@ class SerializedChat {
 	public final displayName:Null<String>;
 	public final uiState:UiState;
 	public final isBlocked:Bool;
+	public final status:Status;
 	public final extensions:String;
 	public final readUpToId:Null<String>;
 	public final readUpToBy:Null<String>;
@@ -2234,7 +2237,7 @@ class SerializedChat {
 	public final notifyMention: Bool;
 	public final notifyReply: Bool;
 
-	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) {
+	public function new(chatId: String, trusted: Bool, isBookmarked: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, status: Status, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, threads: StringMapNullableKey, disco: Null<Caps>, omemoContactDeviceIDs: Array<Int>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.isBookmarked = isBookmarked;
@@ -2243,6 +2246,7 @@ class SerializedChat {
 		this.displayName = displayName;
 		this.uiState = uiState ?? Open;
 		this.isBlocked = isBlocked ?? false;
+		this.status = status;
 		this.extensions = extensions ?? "<extensions xmlns='urn:app:bookmarks:1' />";
 		this.readUpToId = readUpToId;
 		this.readUpToBy = readUpToBy;
@@ -2280,6 +2284,7 @@ class SerializedChat {
 		chat.setNotificationsInternal(filterN, mention, notifyReply);
 		if (displayName != null && displayName != "") chat.displayName = displayName;
 		if (avatarSha1 != null) chat.setAvatarSha1(avatarSha1);
+		chat.status = status;
 		chat.setTrusted(trusted);
 		for (resource => p in presence) {
 			chat.setPresence(resource, p);
diff --git a/borogove/Client.hx b/borogove/Client.hx
index f5d2159..323254f 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -80,6 +80,7 @@ class Client extends EventEmitter {
 			"urn:xmpp:receipts",
 			"urn:xmpp:avatar:metadata+notify",
 			"http://jabber.org/protocol/nick+notify",
+			"http://jabber.org/protocol/activity+notify",
 			"urn:xmpp:bookmarks:1+notify",
 			"urn:xmpp:mds:displayed:0+notify",
 #if !NO_JINGLE
@@ -650,11 +651,22 @@ class Client extends EventEmitter {
 			final fromBare = JID.parse(pubsubEvent.getFrom()).asBare();
 			final isOwnAccount = fromBare.asString() == accountId();
 			final pubsubNode = pubsubEvent.getNode();
+			final chat = getChat(fromBare.asString());
 
 			if (isOwnAccount && pubsubNode == "http://jabber.org/protocol/nick" && pubsubEvent.getItems().length > 0) {
 				updateDisplayName(pubsubEvent.getItems()[0].getChildText("nick", "http://jabber.org/protocol/nick"));
 			}
 
+			if (chat != null && pubsubNode == "http://jabber.org/protocol/activity" && pubsubEvent.getItems().length > 0) {
+				final activity = pubsubEvent.getItems()[0].getChild("activity", "http://jabber.org/protocol/activity");
+				if (activity != null) {
+					chat.status = new Status(activity.getChild("undefined")?.getChildText("emoji", "https://ns.borogove.dev/") ?? "", activity.getChildText("text") ?? "");
+					persistence.storeChats(accountId(), [chat]);
+					this.trigger("chats/update", [chat]);
+					if (isOwnAccount) sendPresence();
+				}
+			}
+
 			if (isOwnAccount && pubsubNode == "urn:xmpp:mds:displayed:0" && pubsubEvent.getItems().length > 0) {
 				for (item in pubsubEvent.getItems()) {
 					if (item.attr.get("id") != null) {
@@ -844,6 +856,24 @@ class Client extends EventEmitter {
 		);
 	}
 
+	public function setStatus(status: Status, expires: Int = 86400, publicAccess: Bool = false) {
+		publishWithOptions(
+			new Stanza("iq", { type: "set" })
+				.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
+				.tag("publish", { node: "http://jabber.org/protocol/activity" })
+				.tag("item", { id: ID.unique() })
+				.addChild(status.toStanza()),
+			new Stanza("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#type" }).textTag("value", "http://jabber.org/protocol/activity").up()
+				.tag("field", { "var": "pubsub#deliver_payloads" }).textTag("value", "true").up()
+				.tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up()
+				.tag("field", { "var": "pubsub#max_items" }).textTag("value", "1").up()
+				.tag("field", { "var": "pubsub#item_expire" }).textTag("value", Std.string(expires)).up()
+				.tag("field", { "var": "pubsub#access_model" }).textTag("value", publicAccess ? "open" : "presence").up(),
+		);
+	}
+
 	private function updateDisplayName(fn: String) {
 		if (fn == null || fn == "" || fn == displayName()) return false;
 		_displayName = fn;
@@ -1615,12 +1645,14 @@ class Client extends EventEmitter {
 
 	@:allow(borogove)
 	private function sendPresence(?to: String, ?augment: (Stanza)->Stanza) {
-		sendStanza(
-			(augment ?? (s)->s)(
-				caps.addC(new Stanza("presence", to == null ? {} : { to: to }))
-					.textTag("nick", displayName(), { xmlns: "http://jabber.org/protocol/nick" })
-			)
-		);
+		final stanza = caps.addC(new Stanza("presence", to == null ? {} : { to: to }))
+			.textTag("nick", displayName(), { xmlns: "http://jabber.org/protocol/nick" });
+
+		final status = getChat(accountId())?.status;
+		final statusText = status?.toString() ?? "";
+		if (statusText != "") stanza.textTag("status", statusText);
+
+		sendStanza((augment ?? (s)->s)(stanza));
 	}
 
 #if !NO_JINGLE
diff --git a/borogove/Participant.hx b/borogove/Participant.hx
index 9225da7..661eeae 100644
--- a/borogove/Participant.hx
+++ b/borogove/Participant.hx
@@ -81,4 +81,23 @@ class Participant {
 			client.sendQuery(get);
 		});
 	}
+
+	/**
+		Load the participant's status
+
+		@param client connected client used to send the profile query
+		@returns Promise resolving to the participant status
+	**/
+	public function status(client: Client): Promise<Status> {
+		return new Promise((resolve, reject) -> {
+			final get = new PubsubGet(jid.asString(), "http://jabber.org/protocol/activity");
+			get.onFinished(() -> {
+				final item = get.getResult()[0];
+
+				final activity = item?.getChild("activity", "http://jabber.org/protocol/activity");
+				resolve(new Status(activity?.getChild("undefined")?.getChildText("emoji", "https://ns.borogove.dev/") ?? "", activity?.getChildText("text") ?? ""));
+			});
+			client.sendQuery(get);
+		});
+	}
 }
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index a92a7c1..10b874c 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -16,6 +16,7 @@ import {
 	borogove_ReactionUpdate,
 	borogove_SerializedChat,
 	borogove_Stanza,
+	borogove_Status,
 	FractionalIndexing_between,
 	FractionalIndexing_BASE_95_DIGITS
 } from "./borogove.js";
@@ -445,6 +446,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 					isBookmarked: chat.isBookmarked,
 					avatarSha1: chat.avatarSha1,
 					presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, p.toString()])),
+					status: chat.status,
 					displayName: chat.displayName,
 					uiState: chat.uiState,
 					isBlocked: chat.isBlocked,
@@ -479,6 +481,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				r.displayName,
 				r.uiState,
 				r.isBlocked,
+				new borogove_Status(r.status?.emoji ?? "", r.status?.text ?? ""),
 				r.extensions,
 				r.readUpToId,
 				r.readUpToBy,
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 4f76925..e241790 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -308,6 +308,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 						Type.getClassName(Type.getClass(chat)).split(".").pop(),
 						chat.notificationsFiltered(), chat.notifyMention(), chat.notifyReply(),
 						chat.isBookmarked, Json.stringify({
+							status: { emoji: chat.status.emoji, text: chat.status.text },
 							threads: {
 								final t: DynamicAccess<String> = {};
 								for (id => s in chat.threads) t.set(id ?? "", s);
@@ -347,14 +348,14 @@ class Sqlite implements Persistence implements KeyValueStore {
 					}
 				}
 
-				final metaJson: { ?threads: Null<DynamicAccess<String>> } = Json.parse(row.meta);
+				final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }> } = Json.parse(row.meta);
 				final threadsMap: StringMapNullableKey = new StringMapNullableKey();
 				for (thread => subject in metaJson.threads ?? {}) {
 					threadsMap.set(thread == "" ? null : thread, subject);
 				}
 
 				// FIXME: Empty OMEMO contact device ids hardcoded in next line
-				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, [], Reflect.field(row, "class")));
+				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, [], Reflect.field(row, "class")));
 			}
 			return chats;
 		});
diff --git a/npm/index.ts b/npm/index.ts
index 26a701f..c344da5 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -39,6 +39,7 @@ export {
     borogove_Reaction as Reaction,
     borogove_Register as Register,
     borogove_SerializedChat as SerializedChat,
+    borogove_Status as Status,
 } from "./borogove.js";
 export type {
     borogove_FormSection as FormSection,
diff --git a/test/TestAll.hx b/test/TestAll.hx
index af8f144..6c8dc1f 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -23,6 +23,8 @@ class TestAll {
 			new TestSortId(),
 			new TestHtml(),
 			new TestChat(),
+			new TestStatus(),
+			new TestParticipant(),
 		]);
 	}
 }
diff --git a/test/TestClient.hx b/test/TestClient.hx
index 0b48b24..cfa08aa 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -12,12 +12,78 @@ import borogove.JID;
 import borogove.Message;
 import borogove.ModerationAction;
 import borogove.Stanza;
+import borogove.Status;
 import borogove.persistence.Dummy;
 
 using Lambda;
 
 @:access(borogove)
 class TestClient extends utest.Test {
+	public function testSetStatus(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			final s = stanza.toString();
+			if (stanza.name == "iq" && s.indexOf("http://jabber.org/protocol/activity") != -1) {
+				Assert.isTrue(s.indexOf("😊") != -1);
+				Assert.isTrue(s.indexOf("feeling good") != -1);
+				async.done();
+				return EventHandled;
+			}
+			return EventUnhandled;
+		});
+
+		client.setStatus(new Status("😊", "feeling good"));
+	}
+
+	public function testReceiveStatusUpdate(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final friendJid = "friend@example.com";
+		client.getDirectChat(friendJid);
+
+		client.addChatsUpdatedListener(chats -> {
+			final friendChat = chats.find(c -> c.chatId == friendJid);
+			if (friendChat != null && friendChat.status.text == "working hard") {
+				Assert.equals("💻", friendChat.status.emoji);
+				Assert.equals("working hard", friendChat.status.text);
+				async.done();
+			}
+		});
+
+		client.stream.onStanza(
+			new Stanza("message", { xmlns: "jabber:client", from: friendJid })
+				.tag("event", { xmlns: "http://jabber.org/protocol/pubsub#event" })
+				.tag("items", { node: "http://jabber.org/protocol/activity" })
+				.tag("item")
+				.tag("activity", { xmlns: "http://jabber.org/protocol/activity" })
+					.textTag("text", "working hard")
+					.tag("undefined")
+						.textTag("emoji", "💻", { xmlns: "https://ns.borogove.dev/" })
+		);
+	}
+
+	public function testPresenceIncludesStatus() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = client.getDirectChat("test@example.com");
+		chat.status = new Status("🚀", "zooming");
+
+		var presenceSent = false;
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "presence" && stanza.attr.get("to") == null) {
+				Assert.equals("🚀 zooming", stanza.getChildText("status"));
+				presenceSent = true;
+				return EventHandled;
+			}
+			return EventUnhandled;
+		});
+
+		client.sendPresence();
+		Assert.isTrue(presenceSent);
+	}
+
 	public function testAccountId() {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
diff --git a/test/TestParticipant.hx b/test/TestParticipant.hx
new file mode 100644
index 0000000..60bfb82
--- /dev/null
+++ b/test/TestParticipant.hx
@@ -0,0 +1,45 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+import borogove.Client;
+import borogove.Participant;
+import borogove.JID;
+import borogove.Stanza;
+import borogove.persistence.Dummy;
+
+@:access(borogove)
+class TestParticipant extends utest.Test {
+	public function testStatus(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final participant = new Participant("Friend", null, "", false, [], JID.parse("friend@example.com"), null);
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.attr.get("type") == "get") {
+				final pubsub = stanza.getChild("pubsub", "http://jabber.org/protocol/pubsub");
+				final items = pubsub?.getChild("items");
+				if (items?.attr.get("node") == "http://jabber.org/protocol/activity") {
+					final reply = new Stanza("iq", { type: "result", id: stanza.attr.get("id"), from: "friend@example.com", xmlns: "jabber:client" })
+						.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
+							.tag("items", { node: "http://jabber.org/protocol/activity" })
+								.tag("item")
+									.tag("activity", { xmlns: "http://jabber.org/protocol/activity" })
+										.textTag("text", "chilling")
+										.tag("undefined")
+											.textTag("emoji", "😎", { xmlns: "https://ns.borogove.dev/" })
+						.up().up().up().up();
+					client.stream.onStanza(reply);
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		participant.status(client).then(status -> {
+			Assert.equals("😎", status.emoji);
+			Assert.equals("chilling", status.text);
+			async.done();
+		});
+	}
+}
diff --git a/test/TestStatus.hx b/test/TestStatus.hx
new file mode 100644
index 0000000..2bb157a
--- /dev/null
+++ b/test/TestStatus.hx
@@ -0,0 +1,30 @@
+package test;
+
+import utest.Assert;
+import borogove.Status;
+import borogove.Stanza;
+
+class TestStatus extends utest.Test {
+	public function testToString() {
+		Assert.equals("", new Status("", "").toString());
+		Assert.equals("😊", new Status("😊", "").toString());
+		Assert.equals("feeling good", new Status("", "feeling good").toString());
+		Assert.equals("😊 feeling good", new Status("😊", "feeling good").toString());
+	}
+
+	public function testToStanza() {
+		final s1 = new Status("😊", "feeling good").toStanza();
+		Assert.equals("activity", s1.name);
+		Assert.equals("http://jabber.org/protocol/activity", s1.attr.get("xmlns"));
+		Assert.equals("feeling good", s1.getChildText("text"));
+		Assert.equals("😊", s1.getChild("undefined")?.getChildText("emoji", "https://ns.borogove.dev/"));
+
+		final s2 = new Status("", "just text").toStanza();
+		Assert.isNull(s2.getChild("undefined")?.getChildText("emoji", "https://ns.borogove.dev/"));
+		Assert.equals("just text", s2.getChildText("text"));
+
+		final s3 = new Status("🚀", "").toStanza();
+		Assert.equals("🚀", s3.getChild("undefined")?.getChildText("emoji", "https://ns.borogove.dev/"));
+		Assert.isNull(s3.getChildText("text"));
+	}
+}
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
index 807f022..7a5ebf6 100644
--- a/test/idb.spec.ts
+++ b/test/idb.spec.ts
@@ -865,3 +865,38 @@ test("hydrate message with incomplete replyToMessage", async ({ page }) => {
 	expect(result.hasReply).toBe(true);
 	expect(result.replyServerId).toBe("parent");
 });
+
+test("storeChats and getChats with status", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = await borogove.persistence.MediaStoreCache("snikket_status");
+		const persistence = await borogove.persistence.IDB("snikket_status", mediaStore);
+
+		const chat = Object.create(borogove.DirectChat.prototype);
+		chat.chatId = "hatter@example.com";
+		chat.displayName = "The Mad Hatter";
+		chat.trusted = true;
+		chat.presence = new Map();
+		chat.threads = new Map();
+		chat.status = new borogove.Status("🎩", "Time for tea!");
+
+		await persistence.storeChats("alice@example.com", [chat]);
+		const chats = await persistence.getChats("alice@example.com");
+		return {
+			length: chats.length,
+			chatId: chats[0]?.chatId,
+			statusEmoji: chats[0]?.status?.emoji,
+			statusText: chats[0]?.status?.text,
+		};
+	}, code);
+
+	expect(result.length).toBe(1);
+	expect(result.chatId).toBe("hatter@example.com");
+	expect(result.statusEmoji).toBe("🎩");
+	expect(result.statusText).toBe("Time for tea!");
+});