git » sdk » commit 1a35af9

Iniital bob-custom-emoji support

author Stephen Paul Weber
2024-10-11 03:03:05 UTC
committer Stephen Paul Weber
2024-10-11 03:08:41 UTC
parent bf5fc8bf7a755f3f7cf2caf56682e7f65e7631ad

Iniital bob-custom-emoji support

Store and use XHTML-IM, replacing any BoB-URIs with ni:/// URIs (or
optionally /.well-known/ni/ paths). Fetch content for bob items when
message first comes in.

Open questions: how to trigger retry for failed fetch?
TODO: don't fetch from untrusted JID and thus reveal presence.
Question: how to trigger retry for fetch after permissions of JID
change?
Question: how to let UI know we have the image now and it can try to
fetch the data again/redraw?

snikket/Chat.hx +3 -0
snikket/ChatMessage.hx +43 -0
snikket/Client.hx +35 -0
snikket/Message.hx +5 -0
snikket/Stanza.hx +21 -0

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index da276f0..9eecdb6 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -891,6 +891,9 @@ class Channel extends Chat {
 			for (m in messageList.messages) {
 				switch (m) {
 					case ChatMessageStanza(message):
+						for (hash in message.inlineHashReferences()) {
+							client.fetchMediaByHash([hash], [message.from]);
+						}
 						promises.push(new thenshim.Promise((resolve, reject) -> {
 							persistence.storeMessage(client.accountId(), message, resolve);
 						}));
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index be2691d..e4ec44c 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -205,10 +205,53 @@ class ChatMessage {
 		Reflect.setField(this, "localId", null);
 	}
 
+	@:allow(snikket)
+	private function inlineHashReferences(): Array<Hash> {
+		final result = [];
+		final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml");
+		if (htmlBody != null) {
+			htmlBody.traverse(child -> {
+				if (child.name == "img") {
+					final src = child.attr.get("src");
+					if (src != null) {
+						final hash = Hash.fromUri(src);
+						if (hash != null) {
+							final x:Hash = hash;
+							result.push(x);
+						}
+					}
+					return true;
+				}
+				return false;
+			});
+		}
+
+		return result;
+	}
+
 	/**
 		Get HTML version of the message body
+
+		WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately!
 	**/
 	public function html():String {
+		final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml");
+		if (htmlBody != null) {
+			return htmlBody.getChildren().map(el -> el.traverse(child -> {
+				if (child.name == "img") {
+					final src = child.attr.get("src");
+					if (src != null) {
+						final hash = Hash.fromUri(src);
+						if (hash != null) {
+							child.attr.set("src", hash.toUri());
+						}
+					}
+					return true;
+				}
+				return false;
+			}).serialize()).join("");
+		}
+
 		final codepoints = StringUtil.codepointArray(text ?? "");
 		// TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle?
 		final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter(
diff --git a/snikket/Client.hx b/snikket/Client.hx
index bc1362c..4102aa9 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -16,6 +16,7 @@ import snikket.EventHandler;
 import snikket.PubsubEvent;
 import snikket.Stream;
 import snikket.jingle.Session;
+import snikket.queries.BoB;
 import snikket.queries.DiscoInfoGet;
 import snikket.queries.DiscoItemsGet;
 import snikket.queries.ExtDiscoGet;
@@ -200,6 +201,9 @@ class Client extends EventEmitter {
 			final message = Message.fromStanza(stanza, this.jid);
 			switch (message.parsed) {
 				case ChatMessageStanza(chatMessage):
+					for (hash in chatMessage.inlineHashReferences()) {
+						fetchMediaByHash([hash], [chatMessage.from]);
+					}
 					var chat = getChat(chatMessage.chatId());
 					if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId());
 					if (chat != null) {
@@ -1011,6 +1015,37 @@ class Client extends EventEmitter {
 		stream.sendStanza(new Stanza("inactive", { xmlns: "urn:xmpp:csi:0" }));
 	}
 
+	@:allow(snikket)
+	private function fetchMediaByHash(hashes: Array<Hash>, counterparts: Array<JID>) {
+		// TODO: only for counterparts who can infer our presence
+		// So MUCs, roster entires, anyone we've sent a message to in the past (from this client?)
+		if (hashes.length < 1 || counterparts.length < 1) return thenshim.Promise.reject("no counterparts left");
+		return fetchMediaByHashOneCounterpart(hashes, counterparts[0]).then(x -> x, (_) -> fetchMediaByHash(hashes, counterparts.slice(1)));
+	}
+
+	private function fetchMediaByHashOneCounterpart(hashes: Array<Hash>, counterpart: JID) {
+		if (hashes.length < 1) return thenshim.Promise.reject("no hashes left");
+
+		return new thenshim.Promise((resolve, reject) ->
+			persistence.hasMedia(hashes[0].algorithm, hashes[0].hash, resolve)
+		).then (has -> {
+			if (has) return thenshim.Promise.resolve(null);
+
+			return new thenshim.Promise((resolve, reject) -> {
+				final q = BoB.forHash(counterpart.asString(), hashes[0]);
+				q.onFinished(() -> {
+					final r = q.getResult();
+					if (r == null) {
+						reject("bad or no result from BoB query");
+					} else {
+						persistence.storeMedia(r.type, r.bytes.getData(), () -> resolve(null));
+					}
+				});
+				sendQuery(q);
+			}).then(x -> x, (_) -> fetchMediaByHashOneCounterpart(hashes.slice(1), counterpart));
+		});
+	}
+
 	@:allow(snikket)
 	private function chatActivity(chat: Chat, trigger = true) {
 		if (chat.uiState == Closed) {
diff --git a/snikket/Message.hx b/snikket/Message.hx
index 1c7a079..d9dfb51 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -180,6 +180,11 @@ class Message {
 			msg.payloads.push(unstyled);
 		}
 
+		final html = stanza.getChild("html", "http://jabber.org/protocol/xhtml-im");
+		if (html != null) {
+			msg.payloads.push(html);
+		}
+
 		final reply = stanza.getChild("reply", "urn:xmpp:reply:0");
 		if (reply != null) {
 			final replyToJid = reply.attr.get("to");
diff --git a/snikket/Stanza.hx b/snikket/Stanza.hx
index d29c324..ba95bf6 100644
--- a/snikket/Stanza.hx
+++ b/snikket/Stanza.hx
@@ -15,6 +15,7 @@ typedef NodeList = Array<Node>;
 private interface NodeInterface {
 	public function serialize():String;
 	public function clone():NodeInterface;
+	public function traverse(f: (Stanza)->Bool):NodeInterface;
 }
 
 class TextNode implements NodeInterface {
@@ -32,6 +33,10 @@ class TextNode implements NodeInterface {
 	public function clone():TextNode {
 		return new TextNode(this.content);
 	}
+
+	public function traverse(f: (Stanza)->Bool) {
+		return this;
+	}
 }
 
 @:expose
@@ -206,6 +211,13 @@ class Stanza implements NodeInterface {
 		return allTags()[0];
 	}
 
+	public function getChildren():Array<NodeInterface> {
+		return children.map(child -> switch(child) {
+			case Element(el): el;
+			case CData(text): text;
+		});
+	}
+
 	public function getChild(?name:Null<String>, ?xmlns:Null<String>):Null<Stanza> {
 		var ourXmlns = this.attr.get("xmlns");
 		/*
@@ -294,6 +306,15 @@ class Stanza implements NodeInterface {
 			case _: null;
 		};
 	}
+
+	public function traverse(f: (Stanza)->Bool) {
+		if (!f(this)) {
+			for (child in allTags()) {
+				child.traverse(f);
+			}
+		}
+		return this;
+	}
 }
 
 enum IqRequestType {