| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-27 19:11:13 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-27 19:33:48 UTC |
| parent | fa710d55f31612a2edcec5b709c791f61b8b8e74 |
| 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!"); +});