git » sdk » commit 964b416

Allow setting rich message body from HTML

author Stephen Paul Weber
2024-10-16 17:53:31 UTC
committer Stephen Paul Weber
2024-10-16 17:53:56 UTC
parent 3837fb91d0d3f2f297a4c8106211d2d4051c7217

Allow setting rich message body from HTML

README.md +1 -0
cpp.hxml +1 -0
js.hxml +1 -0
snikket/ChatMessage.hx +47 -1
snikket/Util.hx +6 -0
snikket/XEP0393.hx +73 -0
test.hxml +1 -0
test/TestAll.hx +4 -1
test/TestChatMessage.hx +35 -0

diff --git a/README.md b/README.md
index ac28b92..7a53a1c 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Working towards simplicity in developing Snikket-compatible apps.
     haxelib install tink_http
     haxelib install sha
     haxelib install thenshim
+    haxelib install HtmlParser
     make
 
 # JavaScript
diff --git a/cpp.hxml b/cpp.hxml
index 1b039f6..98ef8d6 100644
--- a/cpp.hxml
+++ b/cpp.hxml
@@ -4,6 +4,7 @@
 --library tink_http
 --library sha
 --library thenshim
+--library HtmlParser
 
 HaxeCBridge
 snikket.Client
diff --git a/js.hxml b/js.hxml
index 05d4971..2d892c0 100644
--- a/js.hxml
+++ b/js.hxml
@@ -4,6 +4,7 @@
 --library tink_http
 --library sha
 --library thenshim
+--library HtmlParser
 
 snikket.Client
 snikket.Push
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index e4ec44c..253f918 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -18,6 +18,8 @@ import snikket.StringUtil;
 import snikket.XEP0393;
 import snikket.EmojiUtil;
 import snikket.Message;
+import snikket.Stanza;
+import snikket.Util;
 
 @:expose
 @:nullSafety(Strict)
@@ -133,7 +135,7 @@ class ChatMessage {
 	**/
 	@:allow(snikket)
 	public var versions (default, null): Array<ChatMessage> = [];
-	@:allow(snikket)
+	@:allow(snikket, test)
 	private var payloads: Array<Stanza> = [];
 
 	/**
@@ -265,6 +267,50 @@ class ChatMessage {
 		return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map((s) -> s.toString()).join("") : StringTools.htmlEscape(body);
 	}
 
+	/**
+		Set rich text using an HTML string
+		Also sets the plain text body appropriately
+	**/
+	public function setHtml(html: String) {
+		final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
+		final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
+		htmlEl.addChild(body);
+		final nodes = htmlparser.HtmlParser.run(html, true);
+		for (node in nodes) {
+			final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+			if (el != null && (el.name == "html" || el.name == "body")) {
+				for (inner in el.nodes) {
+					body.addDirectChild(htmlToNode(inner));
+				}
+			} else {
+				body.addDirectChild(htmlToNode(node));
+			}
+		}
+		final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html");
+		if (htmlIdx >= 0) payloads.splice(htmlIdx, 1);
+		payloads.push(htmlEl);
+		text = XEP0393.render(body);
+	}
+
+	private function htmlToNode(node: htmlparser.HtmlNode) {
+		final txt = Util.downcast(node, htmlparser.HtmlNodeText);
+		if (txt != null) {
+			return CData(new TextNode(txt.toText()));
+		}
+		final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+		if (el != null) {
+			final s = new Stanza(el.name, {});
+			for (attr in el.attributes) {
+				s.attr.set(attr.name, attr.value);
+			}
+			for (child in el.nodes) {
+				s.addDirectChild(htmlToNode(child));
+			}
+			return Element(s);
+		}
+		throw "node was neither text nor element?";
+	}
+
 	/**
 		The ID of the Chat this message is associated with
 	**/
diff --git a/snikket/Util.hx b/snikket/Util.hx
new file mode 100644
index 0000000..9f18718
--- /dev/null
+++ b/snikket/Util.hx
@@ -0,0 +1,6 @@
+package snikket;
+
+// Std.downcast doesn't play well with null safety
+function downcast<T, S>(value: T, c: Class<S>): Null<S> {
+	return cast Std.downcast(cast value, cast c);
+}
diff --git a/snikket/XEP0393.hx b/snikket/XEP0393.hx
index ebf9133..a2b923b 100644
--- a/snikket/XEP0393.hx
+++ b/snikket/XEP0393.hx
@@ -14,6 +14,79 @@ class XEP0393 {
 		return blocks;
 	}
 
+	public static function render(xhtml: Stanza) {
+		if (xhtml.name == "br") {
+			return "\n";
+		}
+
+		if (xhtml.name == "img") {
+			return xhtml.attr.get("alt") ?? "";
+		}
+
+		final s = new StringBuf();
+
+		if (xhtml.name == "pre") {
+			s.add("\n```\n");
+		}
+
+		if (xhtml.name == "b" || xhtml.name == "strong") {
+			s.add("*");
+		}
+
+		if (xhtml.name == "i" || xhtml.name == "em") {
+			s.add("_");
+		}
+
+		if (xhtml.name == "s" || xhtml.name == "del") {
+			s.add("~");
+		}
+
+		if (xhtml.name == "tt") {
+			s.add("`");
+		}
+
+		for (child in xhtml.children) {
+			s.add(renderNode(child));
+		}
+
+		if (xhtml.name == "b" || xhtml.name == "strong") {
+			s.add("*");
+		}
+
+		if (xhtml.name == "i" || xhtml.name == "em") {
+			s.add("_");
+		}
+
+		if (xhtml.name == "s" || xhtml.name == "del") {
+			s.add("~");
+		}
+
+		if (xhtml.name == "tt") {
+			s.add("`");
+		}
+
+		if (xhtml.name == "blockquote" || xhtml.name == "p" || xhtml.name == "div" || xhtml.name == "pre") {
+			s.add("\n");
+		}
+
+		if (xhtml.name == "pre") {
+			s.add("```\n");
+		}
+
+		if (xhtml.name == "blockquote") {
+			return ~/^/gm.replace(s.toString(), "> ");
+		}
+
+		return s.toString();
+	}
+
+	public static function renderNode(xhtml: Node) {
+		return switch (xhtml) {
+			case Element(c): render(c);
+			case CData(c): c.content;
+		};
+	}
+
 	public static function parseSpans(styled: String) {
 		final spans = [];
 		var start = 0;
diff --git a/test.hxml b/test.hxml
index 926f792..04efff2 100644
--- a/test.hxml
+++ b/test.hxml
@@ -1,6 +1,7 @@
 --library haxe-strings
 --library hsluv
 --library utest
+--library HtmlParser
 
 --run
 test.TestAll
diff --git a/test/TestAll.hx b/test/TestAll.hx
index f8b324b..2f215fa 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -5,6 +5,9 @@ import utest.ui.Report;
 
 class TestAll {
 	public static function main() {
-		utest.UTest.run([new TestSessionDescription()]);
+		utest.UTest.run([
+			new TestSessionDescription(),
+			new TestChatMessage()
+		]);
 	}
 }
diff --git a/test/TestChatMessage.hx b/test/TestChatMessage.hx
new file mode 100644
index 0000000..904dc09
--- /dev/null
+++ b/test/TestChatMessage.hx
@@ -0,0 +1,35 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+import snikket.ChatMessage;
+
+class TestChatMessage extends utest.Test {
+	public function testConvertHtmlToXHTML() {
+		final msg = new ChatMessage();
+		msg.setHtml("Hello <div><img src='hai'><br>");
+		Assert.equals(
+			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
+			msg.payloads[0].toString()
+		);
+	}
+
+	public function testConvertHtmlToText() {
+		final msg = new ChatMessage();
+		msg.setHtml("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you");
+		Assert.equals(
+			"> Hello\n> you\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```\n",
+			msg.text
+		);
+	}
+
+	public function testConvertHtmlToXHTMLIgnoresBody() {
+		final msg = new ChatMessage();
+		msg.setHtml("<body>Hello <div><img src='hai'><br></body>");
+		Assert.equals(
+			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
+			msg.payloads[0].toString()
+		);
+	}
+
+}