git » sdk » commit 93072d4

walkHtml and update the macro to support params

author Stephen Paul Weber
2026-03-31 16:21:10 UTC
committer Stephen Paul Weber
2026-04-03 02:34:23 UTC
parent ed4a968d6e6fd46791836a878168e177667722eb

walkHtml and update the macro to support params

HaxeCBridge.hx +100 -9
HaxeSwiftBridge.hx +19 -5
borogove/ChatMessage.hx +55 -38
borogove/Html.hx +81 -0
borogove/Stanza.hx +7 -0
browserjs.hxml +1 -0
cpp.hxml +1 -0
nodejs.hxml +1 -0
npm/index.ts +1 -0
test/TestAll.hx +1 -0
test/TestHtml.hx +95 -0

diff --git a/HaxeCBridge.hx b/HaxeCBridge.hx
index 70ee58a..5d2bd0e 100644
--- a/HaxeCBridge.hx
+++ b/HaxeCBridge.hx
@@ -209,6 +209,14 @@ class HaxeCBridge {
 			if (field.access.contains(APublic) && !field.access.contains(AOverride) && !field.meta.exists((m) -> m.name == "HaxeCBridge.noemit")) {
 				switch field.kind {
 				case FFun(fun):
+					final funParams = fun.params ?? [];
+					final funArgs = fun.args.map(arg -> ({
+						name: arg.name,
+						opt: arg.opt,
+						type: eraseTypeParams(arg.type, funParams),
+						value: arg.value,
+						meta: arg.meta
+					}));
 					var wrapper = {
 						name: field.name + "__fromC",
 						doc: field.doc,
@@ -216,15 +224,15 @@ class HaxeCBridge {
 						access: field.access.filter(a -> a != AAbstract),
 						kind: null,
 						pos: field.pos,
-						ret: fun.ret
+						ret: eraseTypeParams(fun.ret, funParams)
 					};
-					var wrap = field.access.contains(AAbstract);
+					var wrap = field.access.contains(AAbstract) || funParams.length > 0;
 					var args = [];
 					var passArgs = [];
 					var outPtr = false;
 					var promisify = [];
 					var promisifyE = [];
-					for (arg in fun.args) {
+					for (arg in funArgs) {
 						switch Context.toComplexType(TypeTools.followWithAbstracts(Context.resolveType(arg.type, Context.currentPos()), false)) {
 						case TFunction(taargs, aret):
 							wrap = true;
@@ -265,11 +273,13 @@ class HaxeCBridge {
 							if (gcFree.contains(arg.name)) lambdabody.push(macro cpp.NativeGc.exitGCFreeZone());
 							switch (aret) {
 								case TPath(_.sub => "Void"):
+								case TPath(p) if (p.sub == "Opaque"):
+									lambdabody.push(macro return ret.toDynamic());
 								default:
-								lambdabody.push(macro return ret);
+									lambdabody.push(macro return ret);
 							}
 							final lambdafargs = aargs.mapi((i, a) ->  {name: "a" + i, meta: null, opt: false, type: null, value: null});
-							passArgs.push({expr: EFunction(null, { args: lambdafargs, expr: macro $b{lambdabody} }), pos: field.pos});
+							passArgs.push({expr: EFunction(FArrow, { args: lambdafargs, expr: macro $b{lambdabody} }), pos: field.pos});
 						case TPath(path) if (path.name == "Array"):
 							wrap = true;
 							final isString = switch path.params[0] {
@@ -336,14 +346,22 @@ class HaxeCBridge {
 						} else {
 							[field.name];
 						}
+						var retExpr = macro $p{pth}($a{passArgs});
+						if (wrapper.ret != null) {
+							switch (wrapper.ret) {
+								case TPath(p) if (p.sub == "Opaque"):
+									retExpr = macro cast Opaque.fromDynamic(($retExpr : Any));
+								default:
+							}
+						}
 						final expr = if (outPtr) {
 							macro { final out = $p{pth}($a{passArgs}); if (outPtr != null) { cpp.Pointer.fromRaw(outPtr).set_ref(out); } return out.length; };
 						} else if (promisify.length > 0) {
 							macro if (handler == null) $p{pth}($a{passArgs}); else $p{pth}($a{passArgs}).then(v->handler($a{promisify}), e->handler($a{promisifyE}));
 						} else {
-							macro return $p{pth}($a{passArgs});
+							macro return $retExpr;
 						}
-						wrapper.kind = FFun({ret: wrapper.ret, params: fun.params, expr: expr, args: args});
+						wrapper.kind = FFun({ret: wrapper.ret, params: [], expr: expr, args: args});
 						fields.insert(insertTo, wrapper);
 						insertTo++;
 						field.meta.push({name: "HaxeCBridge.noemit", params: [{ pos: field.pos, expr: EConst(CString("wrapped")) }], pos: field.pos});
@@ -527,6 +545,11 @@ class HaxeCBridge {
 				switch (pth.name) {
 				case "Int16": "HaxeShortArray";
 				case "ConstCharStar": "HaxeStringArray";
+				case "HaxeCBridge" if (pth.sub == "Opaque"):
+					return {retainType: "Array", args: [
+						TPath({name: "HaxeOpaqueArray", pack: [], params: []}),
+						TPath(nullable ? {name: "PtrDiffT", pack: []} : {name: "SizeT", pack: ["cpp"]})
+					]};
 				default: "HaxeArray";
 				}
 			default:
@@ -683,6 +706,39 @@ class HaxeCBridge {
 		}
 	}
 
+	static function eraseTypeParams(ct: ComplexType, params: Array<TypeParamDecl>): ComplexType {
+		if (params == null || params.length == 0 || ct == null) return ct;
+		final names = params.map(p -> p.name);
+		function loop(ct: ComplexType): ComplexType {
+			return switch (ct) {
+				case TPath({pack: [], name: name}) if (names.contains(name)):
+					macro :HaxeCBridge.Opaque;
+				case TPath(p):
+					TPath({
+						pack: p.pack,
+						name: p.name,
+						sub: p.sub,
+						params: p.params == null ? null : p.params.map(param -> switch (param) {
+							case TPType(t): TPType(loop(t));
+							case TPExpr(_): param;
+						})
+					});
+				case TFunction(args, ret):
+					TFunction(args.map(loop), loop(ret));
+				case TParent(t):
+					TParent(loop(t));
+				case TIntersection(tl):
+					TIntersection(tl.map(loop));
+				case TOptional(t):
+					TOptional(loop(t));
+				case TNamed(n, t):
+					TNamed(n, loop(t));
+				default: ct;
+			}
+		}
+		return loop(ct);
+	}
+
 	static macro function runUserMain() {
 		var mainClassPath = getMainFromHaxeArgs(Sys.args());
 		if (mainClassPath == null) {
@@ -1597,7 +1653,7 @@ class CConverterContext {
 						}
 					}
 				}
-			
+
 			case TType(_.get() => t, params):
 				var keyCType = tryConvertKeyType(type, allowNonTrivial, allowBareFnTypes, pos);
 				if (keyCType != null) {
@@ -1626,7 +1682,7 @@ class CConverterContext {
 				} else {
 					Context.error('Any and Dynamic are not supported as secondary type for C export, use HaxeCBridge.HaxeObject<Any> instead', pos);
 				}
-			
+
 			case TMono(t):
 				Context.error("Explicit type is required when exposing to C", pos);
 
@@ -1652,6 +1708,11 @@ class CConverterContext {
 		Return CType if Type was a key type and null otherwise
 	**/
 	public function tryConvertKeyType(type: Type, allowNonTrivial:Bool, allowBareFnTypes: Bool, pos: Position): Null<CType> {
+		switch (Context.follow(type)) {
+			case TInst(_.get() => {kind: KTypeParameter(_)}, _):
+				return getHaxeObjectCType(type);
+			default:
+		}
 		var base = asBaseType(type);
 		return if (base != null) {
 			switch base {
@@ -2072,6 +2133,18 @@ import sys.thread.Lock;
 import sys.thread.Mutex;
 import sys.thread.Thread;
 
+abstract Opaque(cpp.RawPointer<cpp.Void>) from cpp.RawPointer<cpp.Void> to cpp.RawPointer<cpp.Void> {
+	@:to
+	public inline function toDynamic(): Dynamic {
+		return cpp.Pointer.fromRaw(this);
+	}
+
+	@:from
+	public static inline function fromDynamic(x: Dynamic): Opaque {
+		return (x : cpp.Pointer<cpp.Void>).raw;
+	}
+}
+
 abstract HaxeObject<T>(cpp.RawPointer<cpp.Void>) from cpp.RawPointer<cpp.Void> to cpp.RawPointer<cpp.Void> {
 	public var value(get, never): T;
 
@@ -2122,6 +2195,24 @@ abstract HaxeStringArray<T>(cpp.RawPointer<cpp.ConstCharStar>) from cpp.RawPoint
 	}
 }
 
+abstract HaxeOpaqueArray(cpp.RawPointer<cpp.RawPointer<cpp.Void>>) from cpp.RawPointer<cpp.RawPointer<cpp.Void>> to cpp.RawPointer<cpp.RawPointer<cpp.Void>> {
+	@:from
+	public static function fromNullableArrayT<T>(x: Null<Array<T>>): Null<HaxeOpaqueArray> {
+		if (x == null) return null;
+
+		return fromArrayT(cast x);
+	}
+
+	@:from
+	public static inline function fromArrayT<T>(x: Array<T>): HaxeOpaqueArray {
+		final arr: Array<cpp.SizeT> = cpp.NativeArray.create(x.length);
+		for (i => el in x) {
+			arr[i] = untyped __cpp__('reinterpret_cast<size_t>({0})', Opaque.fromDynamic(el));
+		}
+		return cast HaxeCBridge.retainHaxeArray(arr);
+	}
+}
+
 abstract HaxeArray<T>(cpp.RawPointer<cpp.RawPointer<cpp.Void>>) from cpp.RawPointer<cpp.RawPointer<cpp.Void>> to cpp.RawPointer<cpp.RawPointer<cpp.Void>> {
 	@:from
 	public static inline function fromReadOnlyArrayT<T>(x: haxe.ds.ReadOnlyArray<T>): HaxeArray<HaxeObject<T>> {
diff --git a/HaxeSwiftBridge.hx b/HaxeSwiftBridge.hx
index 62315aa..dfd222f 100644
--- a/HaxeSwiftBridge.hx
+++ b/HaxeSwiftBridge.hx
@@ -232,7 +232,12 @@ class HaxeSwiftBridge {
 					"}()";
 			}
 		case TInst(_.get() => t, params):
-			final wrapper = t.isInterface ? 'Any${t.name}' : t.name;
+			final wrapper = switch (Context.follow(type)) {
+				case TInst(_.get() => {kind: KTypeParameter(_)}, _):
+					"";
+				default:
+					t.isInterface ? 'Any${t.name}' : t.name;
+			};
 			if (canNull) {
 				return "(" + item + ").map({ " + wrapper + "($0) })";
 			} else {
@@ -363,6 +368,15 @@ class HaxeSwiftBridge {
 		if (genAccess) builder.add("public ");
 		builder.add("func ");
 		builder.add(funcName);
+		switch (fld?.kind) {
+		case FFun(func):
+			if (func.params.length > 0) {
+				builder.add("<");
+				builder.add(func.params.map(p -> p.name).join(","));
+				builder.add(">");
+			}
+		default:
+		}
 		builder.add("(");
 		convertArgs(builder, targs, fld?.kind);
 		builder.add(") ");
@@ -402,7 +416,7 @@ class HaxeSwiftBridge {
 				case TAbstract(_.get().name => "Null", [param]): true;
 				default: false;
 				};
-				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
+				switch TypeTools.followWithAbstracts(arg.t, false) {
 				case TInst(_.get().name => "Array", [TInst(_.get().name => "String", _)]):
 				builder.add("with" + (allowNull ? "Optional" : "") + "ArrayOfCStrings(" + arg.name + ") { __" + arg.name + " in ");
 				default:
@@ -419,7 +433,7 @@ class HaxeSwiftBridge {
 				case TAbstract(_.get().name => "Null", [param]): true;
 				default: false;
 				};
-				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
+				switch TypeTools.followWithAbstracts(arg.t, false) {
 				case TFun(fargs, fret):
 					ibuilder.add("{ (");
 					for (i => farg in fargs) {
@@ -474,7 +488,7 @@ class HaxeSwiftBridge {
 			builder.add("let __result = ");
 			builder.add(castToSwift(ibuilder.toString(), finalTret, false, true));
 			for (arg in targs) {
-				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
+				switch TypeTools.followWithAbstracts(arg.t, false) {
 				case TFun(fargs, fret):
 					final contextLifetime = fld.meta.filter(meta -> meta.name == ":HaxeSwiftBridge.contextLifetime").map(meta -> meta.params.map(identToStr)).find(params -> params[0] == arg.name);
 					if (contextLifetime != null) {
@@ -493,7 +507,7 @@ class HaxeSwiftBridge {
 			}
 			builder.add("\n\t\treturn __result");
 			for (arg in targs) {
-				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
+				switch TypeTools.followWithAbstracts(arg.t, false) {
 				case TInst(_.get().name => "Array", [TInst(_.get().name => "String", _)]):
 				builder.add("}");
 				default:
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index aa0d223..6f634cd 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -373,46 +373,12 @@ class ChatMessage {
 		return result;
 	}
 
-	/**
-		Get HTML version 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):String {
+	private function htmlBody(): Array<Node> {
 		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");
-		var htmlSource = "";
-		var isAction = false;
 		if (htmlBody != null) {
-			htmlSource = htmlBody.children.map((el: NodeInterface) -> 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;
-				}
-				final senderP = sender;
-				if (senderP != null && child.getFirstChild() == null) {
-					final txt = child.getText();
-					if (txt.startsWith("/me")) {
-						isAction = true;
-						child.removeChildren();
-						child.text(senderP.displayName + txt.substr(3));
-					}
-				}
-				return false;
-			}).serialize()).join("");
+			return htmlBody.children;
 		} else {
 			var bodyText = text ?? "";
-			if (sender != null && bodyText.startsWith("/me")) {
-				isAction = true;
-				bodyText = sender.displayName + bodyText.substr(3);
-			}
 			final codepoints = StringUtil.codepointArray(bodyText);
 			// 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(
@@ -426,9 +392,60 @@ class ChatMessage {
 				codepoints.splice(fallback.start, (fallback.end - fallback.start));
 			}
 			final body = codepoints.join("");
-			htmlSource = 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);
+			return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map(s -> Element(s)) : [CData(new TextNode(body))];
 		}
-		return isAction ? '<div class="action">${htmlSource}</div>' : htmlSource;
+	}
+
+	/**
+		Walk the HTML version of the message body
+
+		WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately!
+
+		@param f callback taking tag or text, attribute names, attribute values, and transformed children, and returning the transformation of this element or text
+		@param sender optionally specify the full details of the sender
+	**/
+	public function html<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T, sender: Null<Participant> = null):Array<T> {
+		var isAction = false;
+
+		function mkTxt(txt: String) {
+			final senderP = sender;
+			return if (!isAction && txt.startsWith("/me ") && senderP != null) {
+				isAction = true;
+				f(senderP.displayName + txt.substr(3), null, null, null);
+			} else {
+				f(txt, null, null, null);
+			};
+		}
+
+		final fragment = htmlBody().map(item -> switch (item) {
+			case Element(el):
+				el.reduce(
+					(st, kids) -> {
+						// We don't deeply sanitize but we can remove some obvious dumb stuff
+						if (st.name == "style" || st.name == "script") return mkTxt("");
+
+						final keys = st.attr.keys().filter(k -> !k.startsWith("on"));
+						return f(
+							st.name,
+							keys,
+							keys.map(k -> {
+								final v = st.attr.get(k) ?? "";
+								if (st.name == "img" && k == "src" && v != "") {
+									final hash = Hash.fromUri(v);
+									hash == null ? v : hash.toUri();
+								} else {
+									v;
+								}
+							}),
+							kids
+						);
+					},
+					txt -> mkTxt(txt)
+				);
+			case CData(txt):
+				mkTxt(txt.content);
+		});
+		return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment;
 	}
 
 	/**
diff --git a/borogove/Html.hx b/borogove/Html.hx
new file mode 100644
index 0000000..eb78af2
--- /dev/null
+++ b/borogove/Html.hx
@@ -0,0 +1,81 @@
+package borogove;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+@:expose
+@:nullSafety(StrictThreaded)
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class Html {
+	private static final HTML_EMPTY = [
+		"area",
+		"base",
+		"br",
+		"col",
+		"embed",
+		"hr",
+		"img",
+		"input",
+		"link",
+		"meta",
+		"param",
+		"source",
+		"track",
+		"wbr"
+	];
+
+	public static function asString(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<String>>): String {
+		if (attr == null && kids == null) {
+			return StringTools.htmlEscape(tag);
+		} else if (attr != null && attrValue != null) {
+			final el = Xml.createElement(tag);
+			for (i => attr_k in attr) {
+				el.set(attr_k, attrValue[i]);
+			}
+
+			final start = el.toString();
+			final buffer = new StringBuf();
+			buffer.addSub(start, 0, start.length-2);
+
+			if (HTML_EMPTY.contains(tag)) {
+				buffer.add(" />");
+				return buffer.toString();
+			}
+
+			buffer.add(">");
+			if (kids != null) {
+				for (kid in kids) {
+					buffer.add(kid);
+				}
+			}
+
+			buffer.add("</");
+			buffer.add(tag);
+			buffer.add(">");
+			return buffer.toString();
+		}
+
+		throw "Invalid arguments";
+	}
+
+	#if js
+	public static function asDOM(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<js.html.Node>>): js.html.Node {
+		if (attr == null && kids == null) {
+			return js.Browser.document.createTextNode(tag);
+		} else if (attr != null && attrValue != null) {
+			final el = js.Browser.document.createElement(tag);
+			for (i => attr_k in attr) {
+				el.setAttribute(attr_k, attrValue[i]);
+			}
+			if (kids != null) el.append(...kids);
+			return el;
+		}
+
+		throw "Invalid arguments";
+	}
+#end
+}
diff --git a/borogove/Stanza.hx b/borogove/Stanza.hx
index af76f7e..fb9a555 100644
--- a/borogove/Stanza.hx
+++ b/borogove/Stanza.hx
@@ -383,6 +383,13 @@ class Stanza {
 		return this;
 	}
 
+	public function reduce<T>(stanza: (Stanza, Array<T>)->T, text: String->T):T {
+		return stanza(this, children.map(c -> switch (c) {
+			case Element(st): st.reduce(stanza, text);
+			case CData(txt): text(txt.content);
+		}));
+	}
+
 	public function getError():Null<StanzaError> {
 		final errorTag = this.getChild("error");
 		if(errorTag == null) {
diff --git a/browserjs.hxml b/browserjs.hxml
index 7e849e0..08fead8 100644
--- a/browserjs.hxml
+++ b/browserjs.hxml
@@ -14,6 +14,7 @@ borogove.Register
 borogove.Push
 borogove.Version
 borogove.persistence.Sqlite
+borogove.Html
 
 -D analyzer-optimize
 -D js-es=6
diff --git a/cpp.hxml b/cpp.hxml
index 4b0954e..ff2a2a0 100644
--- a/cpp.hxml
+++ b/cpp.hxml
@@ -15,6 +15,7 @@ borogove.Push
 borogove.persistence.Dummy
 borogove.persistence.Sqlite
 borogove.persistence.MediaStoreFS
+borogove.Html
 
 --cpp cpp
 -D analyzer-optimize
diff --git a/nodejs.hxml b/nodejs.hxml
index aca615d..e9108ee 100644
--- a/nodejs.hxml
+++ b/nodejs.hxml
@@ -15,6 +15,7 @@ borogove.Register
 borogove.Push
 borogove.Version
 borogove.persistence.Sqlite
+borogove.Html
 
 -D analyzer-optimize
 -D js-es=6
diff --git a/npm/index.ts b/npm/index.ts
index f18a5ce..26a701f 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -27,6 +27,7 @@ export {
     borogove_FormField as FormField,
     borogove_FormOption as FormOption,
     borogove_Hash as Hash,
+    borogove_Html as Html,
     borogove_Identicon as Identicon,
     borogove_LinkMetadata as LinkMetadata,
     borogove_Notification as Notification,
diff --git a/test/TestAll.hx b/test/TestAll.hx
index a6e8db5..535cc9b 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -18,6 +18,7 @@ class TestAll {
 			new TestUtil(),
 			new TestReaction(),
 			new TestSortId(),
+			new TestHtml(),
 		]);
 	}
 }
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
new file mode 100644
index 0000000..ff4e06a
--- /dev/null
+++ b/test/TestHtml.hx
@@ -0,0 +1,95 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+
+import borogove.ChatMessageBuilder;
+import borogove.JID;
+import borogove.Participant;
+
+@:access(borogove)
+class TestHtml extends utest.Test {
+	public function testHtmlAsString() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.setHtml("Hello <div class='sup&amp;2'><img src='hai'><br><p></p>");
+		Assert.equals(
+			"Hello <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div>",
+			msg.build().html(borogove.Html.asString).join("")
+		);
+	}
+
+	public function testHashRewrite() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.setHtml("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>");
+		Assert.equals(
+			"<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />",
+			msg.build().html(borogove.Html.asString).join("")
+		);
+	}
+
+	public function testXEP0245() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.text = "/me says hello";
+
+		final participant = new Participant("hatter", null, "", false, msg.from, null);
+
+		Assert.equals(
+			"<div class=\"action\"><div>hatter says hello</div></div>",
+			msg.build().html(borogove.Html.asString, participant).join("")
+		);
+	}
+
+	public function testRichXEP0245() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.setHtml("/me says <div class='sup&amp;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&amp;2\"><img src=\"hai\" /><br /><p></p></div></div>",
+			msg.build().html(borogove.Html.asString, participant).join("")
+		);
+	}
+
+	public function testRemoveEventAttr() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.setHtml("<a onclick='alert();'>hello</a>");
+		Assert.equals(
+			"<a>hello</a>",
+			msg.build().html(borogove.Html.asString).join("")
+		);
+	}
+
+	public function testRemoveStyleScript() {
+		final msg = new ChatMessageBuilder();
+		msg.localId = "test";
+		msg.to = JID.parse("alice@example.com");
+		msg.from = JID.parse("hatter@example.com");
+		msg.sender = msg.from;
+		msg.setHtml("<style>hai</style><script>hai</script>hai");
+		Assert.equals(
+			"hai",
+			msg.build().html(borogove.Html.asString).join("")
+		);
+	}
+}