| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-22 17:48:08 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-22 18:03:20 UTC |
| parent | 3e8dacb8934079dd52e791d7f2308015336e175f |
| borogove/Chat.hx | +73 | -3 |
| borogove/Participant.hx | +13 | -1 |
| borogove/Presence.hx | +7 | -1 |
| borogove/Role.hx | +49 | -0 |
| test/TestChat.hx | +101 | -0 |
| test/TestHtml.hx | +2 | -2 |
| test/TestPresence.hx | +18 | -0 |
diff --git a/borogove/Chat.hx b/borogove/Chat.hx index dd126c7..e4780fb 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -225,6 +225,23 @@ abstract class Chat { **/ abstract public function getParticipants():Array<String>; + /** + Roles the current user can assign to the target participant + **/ + public function availableRoles(participantId: String):Array<Role> { + return []; + } + + /** + Add a role to a participant + **/ + public function addRole(participantId: String, role: Role) { } + + /** + Remove a role from a participant + **/ + public function removeRole(participantId: String, role: Role) { } + /** Get the details for one participant in this Chat @@ -999,6 +1016,7 @@ class DirectChat extends Chat { chat.getPhoto(), chat.getPlaceholder(), chat.chatId == client.accountId(), + [], // No roles in direct chat JID.parse(participantId), new AvailableChat(participantId, chat.getDisplayName(), "", new Caps("", [], [], [])) ); @@ -1698,6 +1716,14 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H @HaxeCBridge.noemit // on superclass as abstract public function getParticipantDetails(participantId:String): Participant { + final jid = JID.parse(participantId); + final nick = jid.resource; + final ppresence = presence[nick]; + final roles = ppresence?.hats ?? []; + if (ppresence?.mucUser != null) { + final affRole = Role.forAffiliation(ppresence.mucUser.affiliation); + if (affRole != null) roles.unshift(affRole); + } if (participantId == getFullJid().asString()) { final chat = client.getDirectChat(client.accountId(), false); return new Participant( @@ -1705,25 +1731,69 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H chat.getPhoto(), chat.getPlaceholder(), true, + roles, JID.parse(chat.chatId), new AvailableChat(chat.chatId, chat.getDisplayName(), "", new Caps("", [], [], [])) ); } else { - final jid = JID.parse(participantId); - final nick = jid.resource; final placeholderUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0)); - final ppresence = presence[nick]; return new Participant( nick ?? "", ppresence?.avatarHash?.toUri(), placeholderUri, false, + roles, jid, ppresence?.mucUser?.jid == null ? null : new AvailableChat(ppresence.mucUser.jid.asBare().asString(), nick ?? "", "", new Caps("", [], [], [])) ); } } + override public function availableRoles(participantId: String):Array<Role> { + if (_nickInUse == null) return []; + + final p = presence[_nickInUse]; + if (p?.mucUser == null) return []; + + // TODO: this should get their affiliation from the list not using presence + // once we are fetching the affiliation list + // That's probably true everywhere we use affiliation + final pjid = JID.parse(participantId); + final pnick = pjid.resource; + final ppresence = presence[pnick]; + if (ppresence?.mucUser == null) return []; + if (ppresence?.mucUser?.jid == null) return []; + + if (p.mucUser.affiliation == "owner") { + return ["owner", "admin", "member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff)); + } + + if (p.mucUser.affiliation == "admin") { + if (ppresence.mucUser.affiliation == "owner") return []; + + return ["member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff)); + } + + return []; + } + + override public function addRole(participantId: String, role: Role) { + final pjid = JID.parse(participantId); + final pnick = pjid.resource; + final ppresence = presence[pnick]; + if (ppresence?.mucUser?.jid == null) return; + + final iq = new Stanza("iq", { type: "set", to: chatId }) + .tag("query", { xmlns: "http://jabber.org/protocol/muc#admin" }) + .textTag("item", "", { affiliation: role.id, jid: ppresence.mucUser.jid.asBare().asString() }); + stream.sendIq(iq, (response) -> {}); + } + + override public function removeRole(participantId: String, role: Role) { + // For affiliation-backed roles we remove them by adding affiliation of none + addRole(participantId, new Role("none", "")); + } + @HaxeCBridge.noemit // on superclass as abstract public function getMessagesBefore(before: Null<ChatMessage>):Promise<Array<ChatMessage>> { if (before != null && before.chatId() != chatId) throw "Cannot look before from a different chat"; diff --git a/borogove/Participant.hx b/borogove/Participant.hx index a339123..28ee677 100644 --- a/borogove/Participant.hx +++ b/borogove/Participant.hx @@ -1,6 +1,7 @@ package borogove; import thenshim.Promise; +import haxe.ds.ReadOnlyArray; import borogove.Chat; import borogove.queries.PubsubGet; @@ -20,30 +21,41 @@ class Participant { Display name to show for this participant **/ public final displayName: String; + /** Avatar URI for this participant, or null when none is known **/ public final photoUri: Null<String>; + /** Fallback avatar URI to use when no photo is available **/ public final placeholderUri: String; + /** True when this participant is the connected account **/ public final isSelf: Bool; + /** Chat metadata for this participant when it is available as a direct Chat **/ public final chat: Null<AvailableChat>; + + /** + Roles this participant has in the Chat + **/ + public final roles: ReadOnlyArray<Role>; + private final jid: JID; @:allow(borogove) - private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, jid: JID, chat: Null<AvailableChat>) { + private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, roles: Array<Role>, jid: JID, chat: Null<AvailableChat>) { this.displayName = displayName; this.photoUri = photoUri; this.placeholderUri = placeholderUri; this.isSelf = isSelf; + this.roles = roles; this.chat = chat; this.jid = jid; } diff --git a/borogove/Presence.hx b/borogove/Presence.hx index 05dde65..a537e20 100644 --- a/borogove/Presence.hx +++ b/borogove/Presence.hx @@ -1,7 +1,8 @@ package borogove; -import borogove.MucUser; import borogove.Hash; +import borogove.MucUser; +import borogove.Role; @:nullSafety(StrictThreaded) @:forward(toString) @@ -10,6 +11,7 @@ abstract Presence(Stanza) from Stanza to Stanza { public var capsNode(get, never): Null<String>; public var ver(get, never): Null<String>; public var mucUser(get, never): Null<MucUser>; + public var hats(get, never): Null<Array<Role>>; public var avatarHash(get, never): Null<Hash>; public function new(caps: Null<Caps>, mucUser: Null<MucUser>, avatarHash: Null<Hash>): Presence { @@ -37,6 +39,10 @@ abstract Presence(Stanza) from Stanza to Stanza { return this.getChild("x", "http://jabber.org/protocol/muc#user"); } + private inline function get_hats() { + return (this.getChild("hats", "urn:xmpp:hats:0")?.allTags("hat") ?? []).map(hat -> new Role(hat.attr.get("uri") ?? "", hat.attr.get("title") ?? "")); + } + private inline function get_avatarHash() { final avatarSha1Hex = this.findText("{vcard-temp:x:update}x/photo#"); return avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex); diff --git a/borogove/Role.hx b/borogove/Role.hx new file mode 100644 index 0000000..a0b0af4 --- /dev/null +++ b/borogove/Role.hx @@ -0,0 +1,49 @@ +package borogove; + +import borogove.Color; + +@:expose +@:nullSafety(Strict) +#if cpp +@:build(HaxeCBridge.expose()) +@:build(HaxeSwiftBridge.expose()) +#end +class Role { + // A role is the unification of XMPP affiliations and hats + // importantly, it is *not* and XMPP MUC role + + /** + Unique id for the role + **/ + public final id: String; + + /** + Human readable name for the role + **/ + public final title: String; + + @:allow(borogove) + private function new(id: String, title: String) { + this.id = id; + this.title = title; + } + + @:allow(borogove) + private static function forAffiliation(aff: String) { + final title = switch (aff) { + case "outcast": "Banned"; + case "member": "Member"; + case "admin": "Admin"; + case "owner": "Owner"; + default: return null; + } + return new Role(aff, title); + } + + /** + Suggested color to use when displaying this Role + **/ + public function color() { + return Color.forString(id); + } +} diff --git a/test/TestChat.hx b/test/TestChat.hx index 6ae661d..c88e3c3 100644 --- a/test/TestChat.hx +++ b/test/TestChat.hx @@ -351,4 +351,105 @@ class TestChat extends utest.Test { channel.prepareIncomingMessage(builder, stanza); Assert.isTrue(builder.syncPoint, "Message SHOULD have syncPoint if inSync"); } + + public function testGetParticipantDetailsWithRoles() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + + final stanza = Stanza.parse('<presence from="channel@example.com/other"> + <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="admin" role="participant"/></x> + <hats xmlns="urn:xmpp:hats:0"> + <hat uri="http://example.com/custom" title="Custom Role"/> + </hats> + </presence>'); + chat.presence.set("other", stanza); + + final details = chat.getParticipantDetails("channel@example.com/other"); + Assert.equals(2, details.roles.length); + Assert.equals("admin", details.roles[0].id); + Assert.equals("Admin", details.roles[0].title); + Assert.equals("http://example.com/custom", details.roles[1].id); + Assert.equals("Custom Role", details.roles[1].title); + } + + public function testAvailableRoles() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + chat._nickInUse = "me"; + + // I am owner + final myPresence = Stanza.parse('<presence from="channel@example.com/me"> + <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="owner" role="moderator"/></x> + </presence>'); + chat.presence.set("me", myPresence); + + // Other is member + final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> + <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> + </presence>'); + chat.presence.set("other", otherPresence); + + final roles = chat.availableRoles("channel@example.com/other"); + final ids = roles.map(r -> r.id); + Assert.contains("owner", ids); + Assert.contains("admin", ids); + Assert.contains("outcast", ids); + Assert.isFalse(ids.contains("member"), "Should not include current role"); + } + + public function testAddRole(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + + final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> + <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> + </presence>'); + chat.presence.set("other", otherPresence); + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "iq" && stanza.attr.get("type") == "set") { + final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin"); + if (query != null) { + final item = query.getChild("item"); + Assert.equals("admin", item.attr.get("affiliation")); + Assert.equals("other@example.com", item.attr.get("jid")); + async.done(); + return EventHandled; + } + } + return EventUnhandled; + }); + + chat.addRole("channel@example.com/other", borogove.Role.forAffiliation("admin")); + } + + public function testRemoveRole(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = new Channel(client, client.stream, persistence, "channel@example.com"); + + final otherPresence = Stanza.parse('<presence from="channel@example.com/other"> + <x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x> + </presence>'); + chat.presence.set("other", otherPresence); + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "iq" && stanza.attr.get("type") == "set") { + final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin"); + if (query != null) { + final item = query.getChild("item"); + Assert.equals("none", item.attr.get("affiliation")); + Assert.equals("other@example.com", item.attr.get("jid")); + async.done(); + return EventHandled; + } + } + return EventUnhandled; + }); + + chat.removeRole("channel@example.com/other", borogove.Role.forAffiliation("member")); + } } diff --git a/test/TestHtml.hx b/test/TestHtml.hx index c2c495d..9047cfa 100644 --- a/test/TestHtml.hx +++ b/test/TestHtml.hx @@ -44,7 +44,7 @@ class TestHtml extends utest.Test { msg.sender = msg.from; msg.text = "/me says hello"; - final participant = new Participant("hatter", null, "", false, msg.from, null); + final participant = new Participant("hatter", null, "", false, [], msg.from, null); Assert.equals( "<div class=\"action\"><div>hatter says hello</div></div>", @@ -60,7 +60,7 @@ class TestHtml extends utest.Test { msg.sender = msg.from; msg.setBody(Html.fromString("/me says <div class='sup&2'><img src='hai'><br><p></p>")); - final participant = new Participant("hatter", null, "", false, msg.from, null); + final participant = new Participant("hatter", null, "", false, [], msg.from, null); Assert.equals( "<div class=\"action\">hatter says <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div></div>", diff --git a/test/TestPresence.hx b/test/TestPresence.hx index b1c8b1e..8d1e3d0 100644 --- a/test/TestPresence.hx +++ b/test/TestPresence.hx @@ -23,6 +23,24 @@ class TestPresence extends utest.Test { Assert.equals("deadbeef", presence.avatarHash.toHex()); } + public function testHats() { + final stanza = Stanza.parse('<presence from="user@example.com/res"> + <hats xmlns="urn:xmpp:hats:0"> + <hat uri="http://example.com/hats/1" title="Hat 1"/> + <hat uri="http://example.com/hats/2" title="Hat 2"/> + </hats> + </presence>'); + final presence: Presence = stanza; + + Assert.notNull(presence.hats); + Assert.equals(2, presence.hats.length); + Assert.equals("http://example.com/hats/1", presence.hats[0].id); + Assert.equals("Hat 1", presence.hats[0].title); + Assert.equals("#8b7600", presence.hats[0].color()); + Assert.equals("http://example.com/hats/2", presence.hats[1].id); + Assert.equals("Hat 2", presence.hats[1].title); + } + public function testNew() { final caps = new Caps("http://example.com", [], [], []); final presence = new Presence(caps, null, Hash.fromHex("sha-1", "deadbeef"));