git » sdk » commit 5aad464

Allow publishing updated profile

author Stephen Paul Weber
2025-12-16 15:00:21 UTC
committer Stephen Paul Weber
2025-12-16 15:00:33 UTC
parent 0c31c6ad0fb426b6fe16b9b762a554e259c688d5

Allow publishing updated profile

We do mutations including move directly on a ProfileBuider with made up
internal ids to make sure we can know which one we mean to reference.
this allows us to keep any extensions or items we don't understand
intact so long as they are not edited.

borogove/Client.hx +32 -10
borogove/Participant.hx +5 -5
borogove/Profile.hx +147 -35
borogove/Stanza.hx +17 -0
npm/index.ts +1 -0

diff --git a/borogove/Client.hx b/borogove/Client.hx
index be701a8..c42cba7 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -15,6 +15,7 @@ import borogove.EncryptionPolicy;
 #if !NO_OMEMO
 import borogove.OMEMO;
 #end
+import borogove.Profile;
 import borogove.PubsubEvent;
 import borogove.Stream;
 import borogove.Util;
@@ -756,21 +757,42 @@ class Client extends EventEmitter {
 	}
 
 	/**
-		Set the current display name for this account on the server
+		Set the current profile for this account on the server
 
-		@param display name to set (ignored if empty or NULL)
+		@param profile to set
+		@param publicAccess set the access for the profile to public
 	**/
-	public function setDisplayName(displayName: String) {
-		if (displayName == null || displayName == "" || displayName == this.displayName()) return;
+	public function setProfile(profile: ProfileBuilder, publicAccess: Bool) {
+		final fn = profile.build().items.find(item -> item.key == "fn");
+		if (fn != null) {
+			final fnText = fn.text()[0];
+			if (fnText != null && fnText != "" && fnText != this.displayName()) {
+				stream.sendIq(
+					new Stanza("iq", { type: "set" })
+						.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
+						.tag("publish", { node: "http://jabber.org/protocol/nick" })
+						.tag("item")
+						.textTag("nick", fnText, { xmlns: "http://jabber.org/protocol/nick" })
+						.up().up().up(),
+					(response) -> { }
+				);
+			}
+		}
 
-		stream.sendIq(
+		publishWithOptions(
 			new Stanza("iq", { type: "set" })
 				.tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" })
-				.tag("publish", { node: "http://jabber.org/protocol/nick" })
-				.tag("item")
-				.textTag("nick", displayName, { xmlns: "http://jabber.org/protocol/nick" })
-				.up().up().up(),
-			(response) -> { }
+				.tag("publish", { node: "urn:xmpp:vcard4" })
+				.tag("item", { id: ID.long() })
+				.addChild(profile.buildStanza()),
+			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#title" }).textTag("value", "Profile").up()
+				.tag("field", { "var": "pubsub#type" }).textTag("value", "urn:ietf:params:xml:ns:vcard-4.0").up()
+				.tag("field", { "var": "pubsub#deliver_payloads" }).textTag("value", "false").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#access_model" }).textTag("value", publicAccess ? "open" : "presence").up(),
 		);
 	}
 
diff --git a/borogove/Participant.hx b/borogove/Participant.hx
index a700638..a870db6 100644
--- a/borogove/Participant.hx
+++ b/borogove/Participant.hx
@@ -35,12 +35,12 @@ class Participant {
 			final get = new PubsubGet(jid.asString(), "urn:xmpp:vcard4");
 			get.onFinished(() -> {
 				final item = get.getResult()[0];
-				final vcard = item?.getChild("vcard", "urn:ietf:params:xml:ns:vcard-4.0");
-				if (vcard == null) {
-					resolve(new Profile(new Stanza("vcard")));
-				} else {
-					resolve(new Profile(vcard));
+				final fromItem = item?.getChild("vcard", "urn:ietf:params:xml:ns:vcard-4.0");
+				final vcard = fromItem == null ? new Stanza("vcard", { xmlns: "urn:ietf:params:xml:ns:vcard-4.0" }) : fromItem;
+				if (!vcard.hasChild("fn")) {
+					vcard.insertChild(0, new Stanza("fn").textTag("text", displayName));
 				}
+				resolve(new Profile(vcard));
 			});
 			client.sendQuery(get);
 		});
diff --git a/borogove/Profile.hx b/borogove/Profile.hx
index dca49e1..bc4d65d 100644
--- a/borogove/Profile.hx
+++ b/borogove/Profile.hx
@@ -1,5 +1,8 @@
 package borogove;
 
+import haxe.ds.ReadOnlyArray;
+using Lambda;
+
 import borogove.Stanza;
 
 #if cpp
@@ -13,40 +16,20 @@ import HaxeCBridge;
 @:build(HaxeSwiftBridge.expose())
 #end
 class Profile {
+	@:allow(borogove.ProfileBuilder)
 	private final vcard: Stanza;
 
-	@:allow(borogove)
-	private function new(vcard: Stanza) {
-		this.vcard = vcard;
-	}
-
-	/**
-		Get a property from the profile
-
-		@param key what property to get
-		@returns the property value
-	**/
-	public function get(key: String): Array<ProfileItem> {
-		return vcard.allTags(key).map(child -> new ProfileItem(child));
-	}
-
 	/**
-		List the regular properties which can be represented by a ProfileItem
+		All items in the profile
 	**/
-	public function properties(): Array<String> {
-		final names = vcard.allTags().map(el -> el.name);
-		final result = [];
-		final seen: Map<String, Bool> = [];
-
-		for (name in names) {
-			// Compound properties that don't follow the normal rules
-			if (seen[name] != true && !["n", "adr", "gender"].contains(name)) {
-				seen[name] = true;
-				result.push(name);
-			}
-		}
+	public final items: ReadOnlyArray<ProfileItem>;
 
-		return result;
+	@:allow(borogove)
+	private function new(vcard: Stanza, ?items: ReadOnlyArray<ProfileItem>) {
+		this.vcard = vcard;
+		this.items = items != null ? items : vcard.allTags().filter(el ->
+			TYPES[el.name] != null // remove unknown or compound property
+		).map(child -> new ProfileItem(child, child.name + "/" + ID.short()));
 	}
 }
 
@@ -57,20 +40,22 @@ class Profile {
 @:build(HaxeSwiftBridge.expose())
 #end
 class ProfileItem {
+	public final id: String;
+	public final key: String;
+
+	@:allow(borogove.ProfileBuilder)
 	private final item: Stanza;
 
 	@:allow(borogove.Profile)
-	private function new(item: Stanza) {
+	private function new(item: Stanza, id: String) {
 		this.item = item;
-	}
-
-	public function key() {
-		return item.name;
+		this.id = id;
+		this.key = item.name;
 	}
 
 	public function parameters(): Array<ProfileItem> {
 		final params = item.getChild("parameters")?.allTags() ?? [];
-		return params.map(param -> new ProfileItem(param));
+		return params.map(param -> new ProfileItem(param, id + "/" + ID.short()));
 	}
 
 	public function text(): Array<String> {
@@ -107,3 +92,130 @@ class ProfileItem {
 		return item.allTags("language-tag").map(s -> s.getText());
 	}
 }
+
+final TYPES = [
+	"source" => "uri",
+	"kind" => "text",
+	"fn" => "text",
+	"nickname" => "text", // text list is allowed
+	"photo" => "uri",
+	"bday" => "date", // spec says date and/or time or text
+	"anniversary" => "date", // same as bday
+	"tel" => "uri", // spec says text is allowed for compatibility but SHOULD be URI
+	"email" => "text",
+	"impp" => "uri",
+	"lang" => "language-tag",
+	"tz" => "text", // spec allows utc-offset NOT RECOMMENDED and URI
+	"geo" => "uri",
+	"title" => "text",
+	"role" => "text",
+	"logo" => "uri",
+	"org" => "text", // text list says spec. non-xml spec says structured
+	"member" => "uri",
+	"related" => "uri", // spec says text is allowed
+	"categories" => "text", // text list is allowed
+	"note" => "text",
+	"prodid" => "text",
+	"rev" => "timestamp",
+	"sound" => "uri",
+	"uid" => "uri", // MAY be text
+	"url" => "uri",
+	"version" => "text", // always 4 for now...
+	"key" => "uri", // spec says may be text
+	"fburl" => "uri",
+	"caladruri" => "uri",
+	"caluri" => "uri",
+	"pronouns" => "text",
+];
+
+@:expose
+@:nullSafety(Strict)
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class ProfileBuilder {
+	private final vcard: Stanza;
+	private var items: Array<ProfileItem> = [];
+
+	public function new(profile: Profile) {
+		vcard = profile.vcard.clone();
+		final els = vcard.allTags().filter(el ->
+			// Compound properties that don't follow the normal item rules
+			!["n", "adr", "gender"].contains(el.name)
+		);
+		for (item in profile.items) {
+			final el = els.shift();
+			if (el == null || el.name != item.key) throw "els/items mismatch";
+			items.push(new ProfileItem(el, item.id));
+		}
+	}
+
+	/**
+		Add a new field to this profile
+	**/
+	public function add(k: String, v: String) {
+		final type = TYPES[k];
+		if (type != null) {
+			final el = new Stanza(k).textTag(type, v);
+			vcard.addChild(el);
+			items.push(new ProfileItem(el, k + "/" + ID.short()));
+		} else {
+			throw 'Unknown profile property ${k}';
+		}
+	}
+
+	/**
+		Set the value of an existing field on this profile
+	**/
+	public function set(id: String, v: String) {
+		final parts = id.split("/");
+		final k = parts[0];
+		final prop = items.find(item -> item.id == id)?.item;
+		if (prop == null) throw 'prop not found for ${id}';
+
+		final type = TYPES[k];
+		if (type != null) {
+			prop.removeChildren();
+			prop.textTag(type, v);
+		} else {
+			throw 'Unknown profile property ${k}';
+		}
+	}
+
+	/**
+		Move a profile item
+
+		@param id the item to move
+		@param moveTo the item currently in the position where it should move to
+	**/
+	public function move(id: String, moveTo: String) {
+		final move = items.find(item -> item.id == id);
+		if (move == null) throw 'item ${id} not found';
+
+		final idx = items.findIndex(item -> item.id == moveTo);
+		remove(id);
+		items.insert(idx, move);
+		vcard.insertChild(idx, move.item);
+	}
+
+	/**
+		Remove a field from this profile
+	**/
+	public function remove(id: String) {
+		final prop = items.find(item -> item.id == id);
+		if (prop == null) return;
+
+		items = items.filter(item -> item.id != id);
+		vcard.removeChild(prop.item);
+	}
+
+	public function build() {
+		return new Profile(vcard.clone(), items.array());
+	}
+
+	@:allow(borogove)
+	private function buildStanza() {
+		return vcard.clone();
+	}
+}
diff --git a/borogove/Stanza.hx b/borogove/Stanza.hx
index d7d043d..49b2c6f 100644
--- a/borogove/Stanza.hx
+++ b/borogove/Stanza.hx
@@ -207,6 +207,12 @@ class Stanza {
 		return this;
 	}
 
+	public function insertChild(idx: Int, stanza:Stanza) {
+		serialized = null;
+		this.last_added.children.insert(idx, Element(stanza));
+		return this;
+	}
+
 	public function addChild(stanza:Stanza) {
 		serialized = null;
 		this.last_added.children.push(Element(stanza));
@@ -390,6 +396,17 @@ class Stanza {
 		);
 	}
 
+	public function removeChild(remove: Stanza) {
+		children = children.filter((child) -> {
+			switch(child) {
+				case Element(c):
+					return remove != c;
+				default:
+					return true;
+			}
+		});
+	}
+
 	public function removeChildren(?name: String, ?xmlns_:String):Void {
 		serialized = null;
 
diff --git a/npm/index.ts b/npm/index.ts
index b8db24a..f1aa81a 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -32,6 +32,7 @@ export {
     borogove_Participant as Participant,
     borogove_Persistence as Persistence,
     borogove_Profile as Profile,
+    borogove_ProfileBuilder as ProfileBuilder,
     borogove_ProfileItem as ProfileItem,
     borogove_Push as Push,
     borogove_Reaction as Reaction,