git » sdk » commit fabd981

XEP0393 and erase sims fallback urls

author Stephen Paul Weber
2023-11-20 14:39:47 UTC
committer Stephen Paul Weber
2023-11-20 14:39:47 UTC
parent 3af152b76f71c5e9a9f3dbfe5e6da22dc87c85bb

XEP0393 and erase sims fallback urls

xmpp/ChatMessage.hx +29 -0
xmpp/Stanza.hx +17 -4
xmpp/StringUtil.hx +25 -0
xmpp/XEP0393.hx +127 -0
xmpp/persistence/browser.js +2 -0
xmpp/streams/XmppJsStream.hx +1 -1

diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx
index d98f19e..211a6fe 100644
--- a/xmpp/ChatMessage.hx
+++ b/xmpp/ChatMessage.hx
@@ -5,6 +5,8 @@ using Lambda;
 
 import xmpp.JID;
 import xmpp.Identicon;
+import xmpp.StringUtil;
+import xmpp.XEP0393;
 
 enum MessageDirection {
 	MessageReceived;
@@ -52,6 +54,7 @@ class ChatMessage {
 	public var direction: MessageDirection = MessageReceived;
 	public var status: MessageStatus = MessagePending;
 	public var versions: Array<ChatMessage> = [];
+	public var payloads: Array<Stanza> = [];
 
 	public function new() { }
 
@@ -170,6 +173,15 @@ class ChatMessage {
 
 		if (msg.text == null && msg.attachments.length < 1) return null;
 
+		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
+			msg.payloads.push(fallback);
+		}
+
+		final unstyled = stanza.getChild("unstyled", "urn:xmpp:styling:0");
+		if (unstyled != null) {
+			msg.payloads.push(unstyled);
+		}
+
 		return msg;
 	}
 
@@ -197,6 +209,23 @@ class ChatMessage {
 		return this.timestamp = timestamp;
 	}
 
+	public function html():String {
+		var body = text ?? "";
+		// TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle?
+		final fallback = payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:fallback:0" && (p.attr.get("for") == "jabber:x:oob" || p.attr.get("for") == "urn:xmpp:sims:1"));
+		if (fallback != null) {
+			final bodyFallback = fallback.getChild("body");
+			if (bodyFallback != null) {
+				final codepoints = StringUtil.codepointArray(body);
+				final start = Std.parseInt(bodyFallback.attr.get("start") ?? "0") ?? 0;
+				final end = Std.parseInt(bodyFallback.attr.get("end") ?? Std.string(codepoints.length)) ?? codepoints.length;
+				codepoints.splice(start, (end - start));
+				body = codepoints.join("");
+			}
+		}
+		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);
+	}
+
 	public function chatId():String {
 		if (isIncoming()) {
 			return replyTo.map((r) -> r.asBare().asString()).join("\n");
diff --git a/xmpp/Stanza.hx b/xmpp/Stanza.hx
index 8916de4..2d189aa 100644
--- a/xmpp/Stanza.hx
+++ b/xmpp/Stanza.hx
@@ -18,14 +18,15 @@ private interface NodeInterface {
 }
 
 class TextNode implements NodeInterface {
-	private var content(default, null):String = "";
+	public var content(default, null):String = "";
 
 	public function new (content:String) {
 		this.content = content;
 	}
 
 	public function serialize():String {
-		return content;
+		// NOTE: using STringTools.htmlEscape breaks things if this is one half of a surrogate pair in an adjacent cdata
+		return StringTools.replace(StringTools.replace(StringTools.replace(content, "&", "&amp;"), "<", "&lt;"), ">", "&gt;");
 	}
 
 	public function clone():TextNode {
@@ -33,6 +34,7 @@ class TextNode implements NodeInterface {
 	}
 }
 
+@:expose
 class Stanza implements NodeInterface {
 	public var name(default, null):String = null;
 	public var attr(default, null):DynamicAccess<String> = null;
@@ -73,6 +75,10 @@ class Stanza implements NodeInterface {
 		return this.serialize();
 	}
 
+	public static function parse(s:String):Stanza {
+		return fromXml(Xml.parse(s));
+	}
+
 	public static function fromXml(el:Xml):Stanza {
 		if(el.nodeType == XmlType.Document) {
 			return fromXml(el.firstElement());
@@ -132,6 +138,13 @@ class Stanza implements NodeInterface {
 		return this;
 	}
 
+	public function addChildNodes(children:Iterable<Node>) {
+		for (child in children) {
+			addDirectChild(child);
+		}
+		return this;
+	}
+
 	public function addChild(stanza:Stanza) {
 		this.last_added.children.push(Element(stanza));
 		return this;
@@ -179,7 +192,7 @@ class Stanza implements NodeInterface {
 			.filter((child) -> child.match(CData(_)))
 			.map(function (child:Node) {
 				return switch(child) {
-					case CData(c): c.serialize();
+					case CData(c): c.content;
 					case _: null;
 				};
 			});
@@ -273,7 +286,7 @@ class Stanza implements NodeInterface {
 			return null;
 		}
 		return switch(result) {
-			case CData(textNode): textNode.serialize();
+			case CData(textNode): textNode.content;
 			case _: null;
 		};
 	}
diff --git a/xmpp/StringUtil.hx b/xmpp/StringUtil.hx
new file mode 100644
index 0000000..b479a35
--- /dev/null
+++ b/xmpp/StringUtil.hx
@@ -0,0 +1,25 @@
+package xmpp;
+
+class StringUtil {
+	@:access(StringTools)
+	public static function codepointArray(s: String) {
+		final result = [];
+		var offset = 0;
+		while (offset < s.length) {
+		#if utf16
+			final c = StringTools.utf16CodePointAt(s, offset);
+			if (c >= StringTools.MIN_SURROGATE_CODE_POINT) {
+				result.push(s.substr(offset, 2));
+				offset++;
+			} else {
+				result.push(s.substr(offset, 1));
+			}
+			offset++;
+		#else
+			result.push(s.substr(offset, 1));
+			offset++;
+		#end
+		}
+		return result;
+	}
+}
diff --git a/xmpp/XEP0393.hx b/xmpp/XEP0393.hx
new file mode 100644
index 0000000..2d5aa5b
--- /dev/null
+++ b/xmpp/XEP0393.hx
@@ -0,0 +1,127 @@
+package xmpp;
+
+import xmpp.Stanza;
+
+class XEP0393 {
+	public static function parse(styled: String) {
+		final blocks = [];
+		while (styled.length > 0) {
+			final result = parseBlock(styled);
+			styled = result.rest;
+			blocks.push(result.block);
+		}
+		return blocks;
+	}
+
+	public static function parseSpans(styled: String) {
+		final spans = [];
+		var start = 0;
+		while (start < styled.length) {
+			if (StringTools.isSpace(styled, start + 1)) {
+				// The opening styling directive MUST NOT be followed by a whitespace character
+				spans.push(CData(new TextNode(styled.substr(start, 2))));
+				start += 2;
+			} else if (start != 0 && !StringTools.isSpace(styled, start - 1)) {
+				// The opening styling directive MUST be located at the beginning of the parent block, after a whitespace character, or after a different opening styling directive.
+				spans.push(CData(new TextNode(styled.charAt(start))));
+				start++;
+			} else if (styled.charAt(start) == "*") {
+				final parsed = parseSpan("strong", "*", styled, start);
+				spans.push(parsed.span);
+				start = parsed.end;
+			} else if (styled.charAt(start) == "_") {
+				final parsed = parseSpan("em", "_", styled, start);
+				spans.push(parsed.span);
+				start = parsed.end;
+			} else if (styled.charAt(start) == "~") {
+				final parsed = parseSpan("s", "~", styled, start);
+				spans.push(parsed.span);
+				start = parsed.end;
+			} else if (styled.charAt(start) == "`") {
+				var end = start + 1;
+				while (end < styled.length && styled.charAt(end) != "`") {
+					if (StringTools.isSpace(styled, end)) end++; // the closing styling directive MUST NOT be preceeded by a whitespace character
+					end++;
+				}
+				spans.push(Element(new Stanza("tt").text(styled.substr(start + 1, (end - start - 1)))));
+				start = end + 1;
+			} else {
+				spans.push(CData(new TextNode(styled.charAt(start))));
+				start++;
+			}
+		}
+		return spans;
+	}
+
+	public static function parseSpan(tagName: String, marker: String, styled: String, start: Int) {
+		var end = start + 1;
+		while (end < styled.length && styled.charAt(end) != marker) {
+			if (StringTools.isSpace(styled, end)) end++; // the closing styling directive MUST NOT be preceeded by a whitespace character
+			end++;
+		}
+		return { span: Element(new Stanza(tagName).addChildNodes(parseSpans(styled.substr(start + 1, (end - start - 1))))), end: end + 1 };
+	}
+
+	public static function parseBlock(styled: String) {
+		if (styled.charAt(0) == ">") {
+			return parseQuote(styled);
+		} else if (styled.substr(0, 3) == "```") {
+			return parsePreformatted(styled);
+		} else {
+			var end = 0;
+			while (end < styled.length && styled.charAt(end) != "\n") end++;
+			if (end < styled.length && styled.charAt(end) == "\n") end++;
+			return { block: new Stanza("div").addChildNodes(parseSpans(styled.substr(0, end))), rest: styled.substr(end) };
+		}
+	}
+
+	public static function parseQuote(styled: String) {
+		final lines = [];
+		var line = "";
+		var end = 1; // Skip leading >
+		var spaceAfter = 0;
+		while (end < styled.length) {
+			if (styled.charAt(end) != "\n" && StringTools.isSpace(styled, end)) end++;
+			while (end < styled.length && styled.charAt(end) != "\n") {
+				line += styled.charAt(end);
+				end++;
+			}
+			if (end < styled.length && styled.charAt(end) == "\n") {
+				end++;
+			}
+			lines.push(line+"\n");
+			line = "";
+			if (styled.charAt(end) == ">") {
+				end++;
+			} else {
+				break;
+			}
+		}
+
+		return { block: new Stanza("blockquote").addChildren(parse(lines.join(""))), rest: styled.substr(end) };
+	}
+
+
+	public static function parsePreformatted(styled: String) {
+		final lines = [];
+		var line = null;
+		var end = 0;
+		while (end < styled.length) {
+			while (end < styled.length && styled.charAt(end) != "\n") {
+				if (line != null) line += styled.charAt(end);
+				end++;
+			}
+			if (end < styled.length && styled.charAt(end) == "\n") {
+				end++;
+			}
+			if (line != null) lines.push(line+"\n");
+			line = "";
+			if (styled.substr(end, 4) == "```\n" || styled.substr(end) == "```") {
+				end += 4;
+				break;
+			}
+		}
+
+		return { block: new Stanza("pre").text(lines.join("")), rest: styled.substr(end) };
+	}
+}
diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js
index 0ff0673..6d36446 100644
--- a/xmpp/persistence/browser.js
+++ b/xmpp/persistence/browser.js
@@ -100,6 +100,7 @@ exports.xmpp.persistence = {
 					message.status = message.serverId ? xmpp.MessageStatus.MessageDeliveredToServer : xmpp.MessageStatus.MessagePending;
 			}
 			message.versions = (value.versions || []).map(hydrateMessage);
+			message.payloads = (value.payloads || []).map(xmpp.Stanza.parse);
 			return message;
 		}
 
@@ -121,6 +122,7 @@ exports.xmpp.persistence = {
 				direction: message.direction.toString(),
 				status: message.status.toString(),
 				versions: message.versions.map((m) => serializeMessage(account, m)),
+				payloads: message.payloads.map((p) => p.toString()),
 			}
 		}
 
diff --git a/xmpp/streams/XmppJsStream.hx b/xmpp/streams/XmppJsStream.hx
index b0810e1..1cc95b2 100644
--- a/xmpp/streams/XmppJsStream.hx
+++ b/xmpp/streams/XmppJsStream.hx
@@ -214,7 +214,7 @@ class XmppJsStream extends GenericStream {
 			for(child in el.children) {
 				switch(child) {
 					case Element(stanza): xml.append(convertFromStanza(stanza));
-					case CData(text): xml.append(text.serialize());
+					case CData(text): xml.append(text.content);
 				};
 			}
 		}