| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-11 02:50:38 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-11 03:36:47 UTC |
| parent | 5c5334cd01d16fa76e9063c887317c06f5a275a5 |
| borogove/Chat.hx | +6 | -8 |
| borogove/ChatMessage.hx | +5 | -5 |
| borogove/ChatMessageBuilder.hx | +26 | -14 |
| borogove/Html.hx | +22 | -0 |
| borogove/Notification.hx | +1 | -1 |
| borogove/calls/Session.hx | +1 | -1 |
| test/TestChatMessageBuilder.hx | +28 | -3 |
| test/TestHtml.hx | +19 | -11 |
diff --git a/borogove/Chat.hx b/borogove/Chat.hx index 50e08a5..8d0337c 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -251,12 +251,12 @@ abstract class Chat { toSend.localId = ID.unique(); reaction.render( (text) -> { - toSend.text = text.replace("\u{fe0f}", ""); + toSend.setBody(Html.text(text.replace("\u{fe0f}", ""))); return ""; }, (text, uri) -> { final hash = Hash.fromUri(uri); - toSend.setHtml( + toSend.setBody( new Html([Element(new Stanza("img", { alt: text, src: hash == null ? uri : hash.bobUri() }))], null) ); return ""; @@ -539,7 +539,8 @@ abstract class Chat { case MessageCall: lastMessage.isIncoming() ? "Incoming Call" : "Outgoing Call"; default: - lastMessage.text.split("\n").find(line -> !~/(^[ \n]*$)|(^>)/.match(line)) ?? lastMessage.text ?? ""; + final txt = lastMessage.body().toPlainText() ?? ""; + txt.split("\n").find(line -> !~/(^[ \n]*$)|(^>)/.match(line)) ?? txt; } } @@ -1115,8 +1116,7 @@ class DirectChat extends Chat { if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId"; final correct = m.reply(); correct.localId = ID.unique(); - correct.setHtml(new Html([], null)); - correct.text = null; + correct.setBody(null); final fakeEnvelope = new ChatMessageBuilder(); fakeEnvelope.localId = reaction.envelopeId; @@ -1845,9 +1845,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId"; final correct = m.reply(); correct.localId = ID.unique(); - correct.setHtml(new Html([], null)); - correct.text = null; - + correct.setBody(null); final fakeEnvelope = new ChatMessageBuilder(); fakeEnvelope.localId = reaction.envelopeId; diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index e9e52f5..affe8c9 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -201,12 +201,12 @@ class ChatMessage { #end /** - Body text of this message or NULL + Raw body text of this message or NULL **/ - public final text: Null<String>; + private final text: Null<String>; /** - Language code for the body text + Language code for the body **/ public final lang: Null<String>; @@ -397,13 +397,13 @@ class ChatMessage { } /** - The HTML version of the message body + HTML representation of the message body WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately! @param sender optionally specify the full details of the sender **/ - public function html(sender: Null<Participant> = null):Html { + public function body(sender: Null<Participant> = null):Html { return new Html(htmlBody(), sender); } diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx index ac3e730..bb17666 100644 --- a/borogove/ChatMessageBuilder.hx +++ b/borogove/ChatMessageBuilder.hx @@ -103,10 +103,11 @@ class ChatMessageBuilder { /** Body text of this message or NULL **/ - public var text: Null<String> = null; + @:allow(borogove.Message) + private var text: Null<String> = null; /** - Language code for the body text + Language code for the body **/ public var lang: Null<String> = null; @@ -198,15 +199,16 @@ class ChatMessageBuilder { this.threadId = params?.threadId; this.attachments = params?.attachments ?? []; this.reactions = params?.reactions ?? ([] : Map<String, Array<Reaction>>); - this.text = params?.text; this.lang = params?.lang; this.direction = params?.direction ?? MessageSent; this.status = params?.status ?? MessagePending; this.versions = params?.versions ?? []; this.payloads = params?.payloads ?? []; this.encryption = params?.encryption; + final text = params?.text; + if (text != null) setBody(Html.text(text)); final html = params?.html; - if (html != null) setHtml(html); + if (html != null) setBody(html); } #end @@ -268,21 +270,31 @@ class ChatMessageBuilder { } /** - Set rich text using HTML - - Also sets the plain text body appropriately + Set body from Html @param html rich text body to attach to the message **/ - public function setHtml(html: Html) { - 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); - body.addChildNodes(html.xml); + public function setBody(html: Null<Html>) { 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 = html.toPlainText(); + + final unstyledIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled"); + if (unstyledIdx >= 0) payloads.splice(unstyledIdx, 1); + + if (html == null) { + text = null; + } else { + if (html.isPlainText()) { + payloads.push(new Stanza("unstyled", { xmlns: "urn:xmpp:styling:0" })); + } else { + 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); + body.addChildNodes(html.xml); + payloads.push(htmlEl); + } + text = html.toPlainText(); + } } /** diff --git a/borogove/Html.hx b/borogove/Html.hx index e90f403..df22eb6 100644 --- a/borogove/Html.hx +++ b/borogove/Html.hx @@ -3,6 +3,7 @@ package borogove; import haxe.DynamicAccess; import haxe.ds.ReadOnlyArray; using StringTools; +using Lambda; import borogove.Stanza; @@ -128,6 +129,27 @@ class Html { throw "node was neither text nor element?"; } + @:allow(borogove) + private function isPlainText() { + // Don't use our own reduce because we want to check the raw nodes + return !xml.map(item -> switch (item) { + case Element(el): + el.reduce( + (st, kids) -> { + final attrs = st.attr.keys(); + + if (["div", "span", "p", "br"].contains(st.name)) { + return attrs.length < 1 && !kids.exists(plain -> !plain); + } + + return false; + }, + txt -> true + ); + case CData(txt): true; + }).exists(plain -> !plain); + } + /** Walk the HTML tree to produce a new value **/ diff --git a/borogove/Notification.hx b/borogove/Notification.hx index fe91a4e..36d2527 100644 --- a/borogove/Notification.hx +++ b/borogove/Notification.hx @@ -88,7 +88,7 @@ class Notification { } return new Notification( m.type == MessageCall ? "Incoming Call" : "New Message", - m.text, + m.body().toPlainText(), m.account(), m.chatId(), m.senderId, diff --git a/borogove/calls/Session.hx b/borogove/calls/Session.hx index 9409794..438e5a9 100644 --- a/borogove/calls/Session.hx +++ b/borogove/calls/Session.hx @@ -60,7 +60,7 @@ private function mkCallMessage(to: JID, client: Client, event: Stanza) { m.sender = m.from.asBare(); m.replyTo = [m.sender]; m.direction = MessageSent; - m.text = "call " + event.name; + m.setBody(Html.text("call " + event.name)); m.timestamp = Date.format(std.Date.now()); m.payloads.push(event); m.localId = ID.unique(); diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx index 8a9980f..9da21d1 100644 --- a/test/TestChatMessageBuilder.hx +++ b/test/TestChatMessageBuilder.hx @@ -10,7 +10,7 @@ import borogove.ChatMessageBuilder; class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToXHTML() { final msg = new ChatMessageBuilder(); - msg.setHtml(Html.fromString("Hello <div><img src='hai'><br>")); + msg.setBody(Html.fromString("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() @@ -19,7 +19,7 @@ class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToText() { final msg = new ChatMessageBuilder(); - msg.setHtml(Html.fromString("<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")); + msg.setBody(Html.fromString("<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```", msg.text @@ -28,11 +28,36 @@ class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToXHTMLIgnoresBody() { final msg = new ChatMessageBuilder(); - msg.setHtml(Html.fromString("<body>Hello <div><img src='hai'><br></body>")); + msg.setBody(Html.fromString("<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() ); } + public function testSetBodyNull() { + final msg = new ChatMessageBuilder(); + msg.setBody(Html.text("hello")); + Assert.equals("hello", msg.text); + msg.setBody(null); + Assert.isNull(msg.text); + Assert.equals(0, msg.payloads.length); + } + + public function testSetBodyPlainText() { + final msg = new ChatMessageBuilder(); + msg.setBody(Html.text("hello")); + Assert.equals("hello", msg.text); + Assert.equals("<unstyled xmlns=\"urn:xmpp:styling:0\"/>", msg.payloads[0].toString()); + } + + public function testConstructor() { + final msgText = new ChatMessageBuilder({ text: "hello" }); + Assert.equals("hello", msgText.text); + Assert.equals("<unstyled xmlns=\"urn:xmpp:styling:0\"/>", msgText.payloads[0].toString()); + + final msgHtml = new ChatMessageBuilder({ html: Html.fromString("<b>hello</b>") }); + Assert.equals("*hello*", msgHtml.text); + Assert.equals("<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><b>hello</b></body></html>", msgHtml.payloads[0].toString()); + } } diff --git a/test/TestHtml.hx b/test/TestHtml.hx index c72c3a1..c2c495d 100644 --- a/test/TestHtml.hx +++ b/test/TestHtml.hx @@ -16,10 +16,10 @@ class TestHtml extends utest.Test { msg.to = JID.parse("alice@example.com"); msg.from = JID.parse("hatter@example.com"); msg.sender = msg.from; - msg.setHtml(Html.fromString("Hello <div class='sup&2'><img src='hai'><br><p></p>")); + msg.setBody(Html.fromString("Hello <div class='sup&2'><img src='hai'><br><p></p>")); Assert.equals( "Hello <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div>", - msg.build().html().toString() + msg.build().body().toString() ); } @@ -29,10 +29,10 @@ class TestHtml extends utest.Test { msg.to = JID.parse("alice@example.com"); msg.from = JID.parse("hatter@example.com"); msg.sender = msg.from; - msg.setHtml(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>")); + msg.setBody(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>")); Assert.equals( "<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />", - msg.build().html().toString() + msg.build().body().toString() ); } @@ -48,7 +48,7 @@ class TestHtml extends utest.Test { Assert.equals( "<div class=\"action\"><div>hatter says hello</div></div>", - msg.build().html(participant).toString() + msg.build().body(participant).toString() ); } @@ -58,13 +58,13 @@ class TestHtml extends utest.Test { msg.to = JID.parse("alice@example.com"); msg.from = JID.parse("hatter@example.com"); msg.sender = msg.from; - msg.setHtml(Html.fromString("/me says <div class='sup&2'><img src='hai'><br><p></p>")); + 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); Assert.equals( "<div class=\"action\">hatter says <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div></div>", - msg.build().html(participant).toString() + msg.build().body(participant).toString() ); } @@ -74,10 +74,10 @@ class TestHtml extends utest.Test { msg.to = JID.parse("alice@example.com"); msg.from = JID.parse("hatter@example.com"); msg.sender = msg.from; - msg.setHtml(Html.fromString("<a onclick='alert();'>hello</a>")); + msg.setBody(Html.fromString("<a onclick='alert();'>hello</a>")); Assert.equals( "<a>hello</a>", - msg.build().html().toString() + msg.build().body().toString() ); } @@ -87,10 +87,18 @@ class TestHtml extends utest.Test { msg.to = JID.parse("alice@example.com"); msg.from = JID.parse("hatter@example.com"); msg.sender = msg.from; - msg.setHtml(Html.fromString("<style>hai</style><script>hai</script>hai")); + msg.setBody(Html.fromString("<style>hai</style><script>hai</script>hai")); Assert.equals( "hai", - msg.build().html().toString() + msg.build().body().toString() ); } + + public function testIsPlainText() { + Assert.isTrue(Html.text("hello").isPlainText()); + Assert.isTrue(Html.fromString("<div>hello</div>").isPlainText()); + Assert.isTrue(Html.fromString("<p>hello</p><p>world</p>").isPlainText()); + Assert.isTrue(Html.fromString("hello<br>world").isPlainText()); + Assert.isFalse(Html.fromString("hello <b>world</b>").isPlainText()); + } }