diff --git a/lib/model/code_block.dart b/lib/model/code_block.dart index 34c7c5506b..222981f6e1 100644 --- a/lib/model/code_block.dart +++ b/lib/model/code_block.dart @@ -174,7 +174,6 @@ enum CodeBlockSpanType { CodeBlockSpanType codeBlockSpanTypeFromClassName(String className) { return switch (className) { - '' => CodeBlockSpanType.text, 'hll' => CodeBlockSpanType.highlightedLines, 'w' => CodeBlockSpanType.whitespace, 'esc' => CodeBlockSpanType.escape, diff --git a/lib/model/content.dart b/lib/model/content.dart index c22970a1aa..0b0bb502bb 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -577,22 +577,17 @@ class _ZulipContentParser { final dom.Element katexElement; if (!block) { - assert(element.localName == 'span' - && element.classes.length == 1 - && element.classes.contains('katex')); + assert(element.localName == 'span' && element.className == 'katex'); katexElement = element; } else { - assert(element.localName == 'span' - && element.classes.length == 1 - && element.classes.contains('katex-display')); + assert(element.localName == 'span' && element.className == 'katex-display'); if (element.nodes.length != 1) return null; final child = element.nodes.single; if (child is! dom.Element) return null; if (child.localName != 'span') return null; - if (child.classes.length != 1) return null; - if (!child.classes.contains('katex')) return null; + if (child.className != 'katex') return null; katexElement = child; } @@ -602,8 +597,7 @@ class _ZulipContentParser { final child = katexElement.nodes.first; if (child is! dom.Element) return null; if (child.localName != 'span') return null; - if (child.classes.length != 1) return null; - if (!child.classes.contains('katex-mathml')) return null; + if (child.className != 'katex-mathml') return null; if (child.nodes.length != 1) return null; final grandchild = child.nodes.single; @@ -638,7 +632,18 @@ class _ZulipContentParser { return result; } - static final _emojiClassRegexp = RegExp(r"^emoji(-[0-9a-f]+)*$"); + static final _userMentionClassNameRegexp = () { + // This matches a class `user-mention` or `user-group-mention`, + // plus an optional class `silent`, appearing in either order. + const mentionClass = r"user(?:-group)?-mention"; + return RegExp("^(?:$mentionClass(?: silent)?|silent $mentionClass)\$"); + }(); + + static final _emojiClassNameRegexp = () { + const specificEmoji = r"emoji(?:-[0-9a-f]+)+"; + return RegExp("^(?:emoji $specificEmoji|$specificEmoji emoji)\$"); + }(); + static final _emojiCodeFromClassNameRegexp = RegExp(r"emoji-([^ ]+)"); InlineContentNode parseInlineContent(dom.Node node) { assert(_debugParserContext == _ParserContext.inline); @@ -654,27 +659,25 @@ class _ZulipContentParser { final element = node; final localName = element.localName; - final classes = element.classes; + final className = element.className; List nodes() => parseInlineContentList(element.nodes); - if (localName == 'br' && classes.isEmpty) { + if (localName == 'br' && className.isEmpty) { return LineBreakInlineNode(debugHtmlNode: debugHtmlNode); } - if (localName == 'strong' && classes.isEmpty) { + if (localName == 'strong' && className.isEmpty) { return StrongNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); } - if (localName == 'em' && classes.isEmpty) { + if (localName == 'em' && className.isEmpty) { return EmphasisNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); } - if (localName == 'code' && classes.isEmpty) { + if (localName == 'code' && className.isEmpty) { return InlineCodeNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); } if (localName == 'a' - && (classes.isEmpty - || (classes.length == 1 - && (classes.contains('stream-topic') - || classes.contains('stream'))))) { + && (className.isEmpty + || (className == 'stream-topic' || className == 'stream'))) { final href = element.attributes['href']; if (href == null) return unimplemented(); final link = LinkNode(nodes: nodes(), url: href, debugHtmlNode: debugHtmlNode); @@ -683,10 +686,7 @@ class _ZulipContentParser { } if (localName == 'span' - && (classes.contains('user-mention') - || classes.contains('user-group-mention')) - && (classes.length == 1 - || (classes.length == 2 && classes.contains('silent')))) { + && _userMentionClassNameRegexp.hasMatch(className)) { // TODO assert UserMentionNode can't contain LinkNode; // either a debug-mode check, or perhaps we can make expectations much // tighter on a UserMentionNode's contents overall. @@ -694,22 +694,15 @@ class _ZulipContentParser { } if (localName == 'span' - && classes.length == 2 - && classes.contains('emoji') - && classes.every(_emojiClassRegexp.hasMatch)) { - final emojiCode = classes - .firstWhere((className) => className.startsWith('emoji-')) - .replaceFirst('emoji-', ''); - assert(emojiCode.isNotEmpty); - + && _emojiClassNameRegexp.hasMatch(className)) { + final emojiCode = _emojiCodeFromClassNameRegexp.firstMatch(className)! + .group(1)!; final unicode = tryParseEmojiCodeToUnicode(emojiCode); if (unicode == null) return unimplemented(); return UnicodeEmojiNode(emojiUnicode: unicode, debugHtmlNode: debugHtmlNode); } - if (localName == 'img' - && classes.contains('emoji') - && classes.length == 1) { + if (localName == 'img' && className == 'emoji') { final alt = element.attributes['alt']; if (alt == null) return unimplemented(); final src = element.attributes['src']; @@ -717,9 +710,7 @@ class _ZulipContentParser { return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode); } - if (localName == 'span' - && classes.length == 1 - && classes.contains('katex')) { + if (localName == 'span' && className == 'katex') { final texSource = parseMath(element, block: false); if (texSource == null) return unimplemented(); return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode); @@ -756,13 +747,13 @@ class _ZulipContentParser { case 'ul': listStyle = ListStyle.unordered; break; } assert(listStyle != null); - assert(element.classes.isEmpty); + assert(element.className.isEmpty); final debugHtmlNode = kDebugMode ? element : null; final List> items = []; for (final item in element.nodes) { if (item is dom.Text && item.text == '\n') continue; - if (item is! dom.Element || item.localName != 'li' || item.classes.isNotEmpty) { + if (item is! dom.Element || item.localName != 'li' || item.className.isNotEmpty) { items.add([UnimplementedBlockContentNode(htmlNode: item)]); } items.add(parseImplicitParagraphBlockContentList(item.nodes)); @@ -775,8 +766,7 @@ class _ZulipContentParser { assert(_debugParserContext == _ParserContext.block); final mainElement = () { assert(divElement.localName == 'div' - && divElement.classes.length == 1 - && divElement.classes.contains("codehilite")); + && divElement.className == "codehilite"); if (divElement.nodes.length != 1) return null; final child = divElement.nodes[0]; @@ -819,9 +809,8 @@ class _ZulipContentParser { } span = CodeBlockSpanNode(text: text, type: CodeBlockSpanType.text); - case dom.Element(localName: 'span', :final text, :final classes) - when classes.length == 1: - final CodeBlockSpanType type = codeBlockSpanTypeFromClassName(classes.first); + case dom.Element(localName: 'span', :final text, :final className): + final CodeBlockSpanType type = codeBlockSpanTypeFromClassName(className); switch (type) { case CodeBlockSpanType.unknown: // TODO(#194): Show these as un-syntax-highlighted code, in production. @@ -848,20 +837,19 @@ class _ZulipContentParser { assert(_debugParserContext == _ParserContext.block); final imgElement = () { assert(divElement.localName == 'div' - && divElement.classes.length == 1 - && divElement.classes.contains('message_inline_image')); + && divElement.className == 'message_inline_image'); if (divElement.nodes.length != 1) return null; final child = divElement.nodes[0]; if (child is! dom.Element) return null; if (child.localName != 'a') return null; - if (child.classes.isNotEmpty) return null; + if (child.className.isNotEmpty) return null; if (child.nodes.length != 1) return null; final grandchild = child.nodes[0]; if (grandchild is! dom.Element) return null; if (grandchild.localName != 'img') return null; - if (grandchild.classes.isNotEmpty) return null; + if (grandchild.className.isNotEmpty) return null; return grandchild; }(); @@ -886,18 +874,16 @@ class _ZulipContentParser { } final element = node; final localName = element.localName; - final classes = element.classes; - List blockNodes() => parseBlockContentList(element.nodes); + final className = element.className; - if (localName == 'br' && classes.isEmpty) { + if (localName == 'br' && className.isEmpty) { return LineBreakNode(debugHtmlNode: debugHtmlNode); } - if (localName == 'p' && classes.isEmpty) { + if (localName == 'p' && className.isEmpty) { // Oddly, the way a math block gets encoded in Zulip HTML is inside a

. if (element.nodes case [dom.Element(localName: 'span') && var child, ...]) { - if (child.classes.length == 1 - && child.classes.contains('katex-display')) { + if (child.className == 'katex-display') { if (element.nodes case [_] || [_, dom.Element(localName: 'br'), dom.Text(text: "\n")]) { @@ -927,7 +913,7 @@ class _ZulipContentParser { case 'h5': headingLevel = HeadingLevel.h5; break; case 'h6': headingLevel = HeadingLevel.h6; break; } - if (headingLevel != null && classes.isEmpty) { + if (headingLevel != null && className.isEmpty) { final parsed = parseBlockInline(element.nodes); return HeadingNode(debugHtmlNode: debugHtmlNode, level: headingLevel, @@ -935,21 +921,20 @@ class _ZulipContentParser { nodes: parsed.nodes); } - if ((localName == 'ol' || localName == 'ul') && classes.isEmpty) { + if ((localName == 'ol' || localName == 'ul') && className.isEmpty) { return parseListNode(element); } - if (localName == 'blockquote' && classes.isEmpty) { - return QuotationNode(blockNodes(), debugHtmlNode: debugHtmlNode); + if (localName == 'blockquote' && className.isEmpty) { + return QuotationNode(debugHtmlNode: debugHtmlNode, + parseBlockContentList(element.nodes)); } - if (localName == 'div' - && classes.length == 1 && classes.contains('codehilite')) { + if (localName == 'div' && className == 'codehilite') { return parseCodeBlock(element); } - if (localName == 'div' - && classes.length == 1 && classes.contains('message_inline_image')) { + if (localName == 'div' && className == 'message_inline_image') { return parseImageNode(element); } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 87c6c182e6..dc97c89b92 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -138,7 +138,27 @@ void main() { '

Greg Price

', const UserMentionNode(nodes: [TextNode('Greg Price')])); - // TODO test group mentions and wildcard mentions + testParseInline('silent user @-mention, class order reversed', + // "@_**Greg Price**" (hypothetical server variation) + '

Greg Price

', + const UserMentionNode(nodes: [TextNode('Greg Price')])); + + testParseInline('plain group @-mention', + // "@*test-empty*" + '

@test-empty

', + const UserMentionNode(nodes: [TextNode('@test-empty')])); + + testParseInline('silent group @-mention', + // "@_*test-empty*" + '

test-empty

', + const UserMentionNode(nodes: [TextNode('test-empty')])); + + testParseInline('silent group @-mention, class order reversed', + // "@_*test-empty*" (hypothetical server variation) + '

test-empty

', + const UserMentionNode(nodes: [TextNode('test-empty')])); + + // TODO test wildcard mentions }); testParseInline('parse Unicode emoji, encoded in span element', @@ -146,6 +166,11 @@ void main() { '

:thumbs_up:

', const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); // "👍" + testParseInline('parse Unicode emoji, encoded in span element, class order reversed', + // ":thumbs_up:" (hypothetical server variation) + '

:thumbs_up:

', + const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); // "👍" + testParseInline('parse Unicode emoji, encoded in span element, multiple codepoints', // ":transgender_flag:" '

:transgender_flag:

',