From 4e289edfba767b0c811c1d695a1249f8961ee4b7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 25 Nov 2024 15:10:13 -0800 Subject: [PATCH 1/4] content [nfc]: Pull out parseUserMention, having regexp do less work This gives us more breathing room for making this handling more complicated, as we'll need for the new "channel-wildcard-mention" class (#1064). --- lib/model/content.dart | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 7a16b9da2c..bb32ae5435 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -872,6 +872,29 @@ class _ZulipContentParser { return descendant4.text.trim(); } + UserMentionNode? parseUserMention(dom.Element element) { + assert(_debugParserContext == _ParserContext.inline); + assert(element.localName == 'span'); + final debugHtmlNode = kDebugMode ? element : null; + + final classes = element.className.split(' ')..sort(); + assert(classes.contains('user-mention') + || classes.contains('user-group-mention')); + switch (classes) { + case ['user-mention' || 'user-group-mention']: + case ['silent', 'user-mention' || 'user-group-mention']: + break; + default: + return null; + } + + // 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. + final nodes = parseInlineContentList(element.nodes); + return UserMentionNode(nodes: nodes, debugHtmlNode: debugHtmlNode); + } + /// The links found so far in the current block inline container. /// /// Empty is represented as null. @@ -884,12 +907,12 @@ class _ZulipContentParser { return result; } - 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)\$"); - }(); + /// Matches all className values that could be a UserMentionNode, + /// and no className values that could be any other type of node. + // Specifically, checks for `user-mention` or `user-group-mention` + // as a member of the list. + static final _userMentionClassNameRegexp = RegExp( + r"(^| )" r"user(?:-group)?-mention" r"( |$)"); static final _emojiClassNameRegexp = () { const specificEmoji = r"emoji(?:-[0-9a-f]+)+"; @@ -944,10 +967,7 @@ class _ZulipContentParser { if (localName == 'span' && _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. - return UserMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + return parseUserMention(element) ?? unimplemented(); } if (localName == 'span' From cdb55dfc06feb8726d33b9b1b560f6a50dcf13a1 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 25 Nov 2024 15:31:44 -0800 Subject: [PATCH 2/4] content [nfc]: Update comments in UserMentionNode about UI design --- lib/model/content.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index bb32ae5435..a9877cfde2 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -706,17 +706,15 @@ class UserMentionNode extends InlineContainerNode { const UserMentionNode({ super.debugHtmlNode, required super.nodes, - // required this.mentionType, - // required this.isSilent, }); - // We don't currently seem to need this information in code. Instead, + // For the legacy design, we don't need this information in code; instead, // the inner text already shows how to communicate it to the user // (e.g., silent mentions' text lacks a leading "@"), // and we show that text in the same style for all types of @-mention. - // If we need this information in the future, go ahead and add it here. - // final UserMentionType mentionType; - // final bool isSilent; + // We'll need these for implementing the post-2023 Zulip design, though. + // final UserMentionType mentionType; // TODO(#646) + // final bool isSilent; // TODO(#647) } sealed class EmojiNode extends InlineContentNode { From 215527a62d31b297158a69a79ac9d6498ca99f58 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 25 Nov 2024 15:26:26 -0800 Subject: [PATCH 3/4] content [nfc]: Expand parseUserMention to a more sequential structure This should further help make space for adding more logic here, both for the "channel-wildcard-mention" class (#1064) and for distinguishing different types of mentions (#646, #647). --- lib/model/content.dart | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index a9877cfde2..7186400ae9 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -878,12 +878,24 @@ class _ZulipContentParser { final classes = element.className.split(' ')..sort(); assert(classes.contains('user-mention') || classes.contains('user-group-mention')); - switch (classes) { - case ['user-mention' || 'user-group-mention']: - case ['silent', 'user-mention' || 'user-group-mention']: - break; - default: - return null; + int i = 0; + + if (i >= classes.length) return null; + if (classes[i] == 'silent') { + // A silent @-mention. We ignore this flag; see [UserMentionNode]. + i++; + } + + if (i >= classes.length) return null; + if (classes[i] == 'user-mention' || classes[i] == 'user-group-mention') { + // The class we already knew we'd find before we called this function. + // We ignore the distinction between these; see [UserMentionNode]. + i++; + } + + if (i != classes.length) { + // There was some class we didn't expect. + return null; } // TODO assert UserMentionNode can't contain LinkNode; From 79b117d2d48c855586e4ebb6d123b3d395a60b91 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 22 Nov 2024 10:32:18 +0430 Subject: [PATCH 4/4] content: Support the new class of channel wildcard mentions For channel wildcard mentions, the class in the corresponding HTML used to be the same as the user mentions (class="user-mention"), but now there's an additional class added. Now it looks like the following: class="user-mention channel-wildcard-mention". Fixes: #1064 --- lib/model/content.dart | 14 +++++++++- test/model/content_test.dart | 50 +++++++++++++++++++++++++++++++++- test/widgets/content_test.dart | 6 ++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 7186400ae9..d8b68ab699 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -880,6 +880,15 @@ class _ZulipContentParser { || classes.contains('user-group-mention')); int i = 0; + if (i >= classes.length) return null; + bool isChannelWildcardClassIncluded = false; + if (classes[i] == 'channel-wildcard-mention') { + // A newly-added class for channel wildcard mentions. + // See: https://github.com/zulip/zulip/pull/31075 + i++; + isChannelWildcardClassIncluded = true; + } + if (i >= classes.length) return null; if (classes[i] == 'silent') { // A silent @-mention. We ignore this flag; see [UserMentionNode]. @@ -887,9 +896,12 @@ class _ZulipContentParser { } if (i >= classes.length) return null; - if (classes[i] == 'user-mention' || classes[i] == 'user-group-mention') { + if (classes[i] == 'user-mention' || + (classes[i] == 'user-group-mention' && !isChannelWildcardClassIncluded)) { // The class we already knew we'd find before we called this function. // We ignore the distinction between these; see [UserMentionNode]. + // Also, we don't expect "user-group-mention" and "channel-wildcard-mention" + // to be in the list at the same time. i++; } diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 98cf4d28e6..8a895b6ab1 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -139,6 +139,48 @@ class ContentExample { '

test-empty

', const UserMentionNode(nodes: [TextNode('test-empty')])); + static final channelWildcardMentionPlain = ContentExample.inline( + 'plain channel wildcard @-mention', + "@**all**", + expectedText: '@all', + '

@all

', + const UserMentionNode(nodes: [TextNode('@all')])); + + static final channelWildcardMentionSilent = ContentExample.inline( + 'silent channel wildcard @-mention', + "@_**everyone**", + expectedText: 'everyone', + '

everyone

', + const UserMentionNode(nodes: [TextNode('everyone')])); + + static final channelWildcardMentionSilentClassOrderReversed = ContentExample.inline( + 'silent channel wildcard @-mention, class order reversed', + "@_**channel**", // (hypothetical server variation) + expectedText: 'channel', + '

channel

', + const UserMentionNode(nodes: [TextNode('channel')])); + + static final legacyChannelWildcardMentionPlain = ContentExample.inline( + 'legacy plain channel wildcard @-mention', + "@**channel**", + expectedText: '@channel', + '

@channel

', + const UserMentionNode(nodes: [TextNode('@channel')])); + + static final legacyChannelWildcardMentionSilent = ContentExample.inline( + 'legacy silent channel wildcard @-mention', + "@_**stream**", + expectedText: 'stream', + '

stream

', + const UserMentionNode(nodes: [TextNode('stream')])); + + static final legacyChannelWildcardMentionSilentClassOrderReversed = ContentExample.inline( + 'legacy silent channel wildcard @-mention, class order reversed', + "@_**all**", // (hypothetical server variation) + expectedText: 'all', + '

all

', + const UserMentionNode(nodes: [TextNode('all')])); + static final emojiUnicode = ContentExample.inline( 'Unicode emoji, encoded in span element', ":thumbs_up:", @@ -1213,7 +1255,13 @@ void main() { testParseExample(ContentExample.groupMentionSilent); testParseExample(ContentExample.groupMentionSilentClassOrderReversed); - // TODO test wildcard mentions + testParseExample(ContentExample.channelWildcardMentionPlain); + testParseExample(ContentExample.channelWildcardMentionSilent); + testParseExample(ContentExample.channelWildcardMentionSilentClassOrderReversed); + + testParseExample(ContentExample.legacyChannelWildcardMentionPlain); + testParseExample(ContentExample.legacyChannelWildcardMentionSilent); + testParseExample(ContentExample.legacyChannelWildcardMentionSilentClassOrderReversed); }); testParseExample(ContentExample.emojiUnicode); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index fa7febaa17..acb3b3bce1 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -650,6 +650,12 @@ void main() { testContentSmoke(ContentExample.userMentionSilent); testContentSmoke(ContentExample.groupMentionPlain); testContentSmoke(ContentExample.groupMentionSilent); + testContentSmoke(ContentExample.channelWildcardMentionPlain); + testContentSmoke(ContentExample.channelWildcardMentionSilent); + testContentSmoke(ContentExample.channelWildcardMentionSilentClassOrderReversed); + testContentSmoke(ContentExample.legacyChannelWildcardMentionPlain); + testContentSmoke(ContentExample.legacyChannelWildcardMentionSilent); + testContentSmoke(ContentExample.legacyChannelWildcardMentionSilentClassOrderReversed); UserMention? findUserMentionInSpan(InlineSpan rootSpan) { UserMention? result;