| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-11-20 14:39:47 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-11-20 14:39:47 UTC |
| parent | 3af152b76f71c5e9a9f3dbfe5e6da22dc87c85bb |
| 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, "&", "&"), "<", "<"), ">", ">"); } 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); }; } }