From bb467ad263856af58bbed92f374ba6afac8c7c7b Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Wed, 4 Mar 2026 23:57:22 +0530 Subject: [PATCH 1/5] content test: Add missing mention smoke tests Added two missing silent class order reversed smoke tests so as to be more consistent with the rest of the tests. --- test/widgets/content_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index dafa30f02b..3f101792fa 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -985,10 +985,14 @@ void main() { wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.userMentionSilent, wrapWithPerAccountStoreWidget: true); + testContentSmoke(ContentExample.userMentionSilentClassOrderReversed, + wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.groupMentionPlain, wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.groupMentionSilent, wrapWithPerAccountStoreWidget: true); + testContentSmoke(ContentExample.groupMentionSilentClassOrderReversed, + wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.channelWildcardMentionPlain, wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.channelWildcardMentionSilent, From f92f55a23ab9e19d5f366eab0403cb0c72356289 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Thu, 5 Mar 2026 00:28:08 +0530 Subject: [PATCH 2/5] content test: Add test for parsing legacy user mention There is no silent version of this test as the silent mention feature was introduced in zulip/zulip#11221, long after the legacy user mentions were replaced in January 2017. --- test/model/content_test.dart | 11 +++++++++++ test/widgets/content_test.dart | 2 ++ 2 files changed, 13 insertions(+) diff --git a/test/model/content_test.dart b/test/model/content_test.dart index b5da630f7e..acb74dc703 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -127,6 +127,15 @@ class ContentExample { '

Greg Price

', const UserMentionNode(nodes: [TextNode('Greg Price')], isSilent: true, userId: 2187)); + // No silent version of this form, because silent mentions were new + // in zulip/zulip#11221, long after this legacy form was removed. + static final legacyUserMention = ContentExample.inline( + 'legacy user @-mention', + "@**Greg Price**", // (before 2017-01) + expectedText: '@Greg Price', + '

@Greg Price

', + const UserMentionNode(nodes: [TextNode('@Greg Price')], isSilent: false, userId: null)); + static final groupMentionPlain = ContentExample.inline( 'plain group @-mention', "@*test-empty*", @@ -1868,6 +1877,8 @@ void main() async { testParseExample(ContentExample.userMentionSilent); testParseExample(ContentExample.userMentionSilentClassOrderReversed); + testParseExample(ContentExample.legacyUserMention); + testParseExample(ContentExample.groupMentionPlain); testParseExample(ContentExample.groupMentionSilent); testParseExample(ContentExample.groupMentionSilentClassOrderReversed); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 3f101792fa..686b8f4b03 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -987,6 +987,8 @@ void main() { wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.userMentionSilentClassOrderReversed, wrapWithPerAccountStoreWidget: true); + testContentSmoke(ContentExample.legacyUserMention, + wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.groupMentionPlain, wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.groupMentionSilent, From 443717b0e351621929164172995d64b8dd956592 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Thu, 5 Mar 2026 01:16:22 +0530 Subject: [PATCH 3/5] content [nfc]: Fix and update TODO comment in `Mention` code The font color change is part of #647 and not #646. Now, font weight and color are to be changed when the self-user is mentioned regardless of whether the mention is silent. Refer discussion: https://chat.zulip.org/#narrow/channel/101-design/topic/User.20group.20mentions.20inconsistency/near/2389405 --- lib/widgets/content.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 297b77894a..32f2bfaa8d 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1235,8 +1235,7 @@ class Mention extends StatelessWidget { // (The parser on creating a MentionNode has a TODO to check that.) linkRecognizers: null, - // TODO(#647) when self-user is non-silently mentioned, make bold, and: - // TODO(#646) distinguish font color between direct and wildcard mentions + // TODO(#647) when self-user is mentioned, make bold, and change font color. style: ambientTextStyle, nodes: nodes)); From 0853a2f7d049f1c17710736821455e8de07ade00 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Thu, 5 Mar 2026 03:13:05 +0530 Subject: [PATCH 4/5] content: Add `WildcardMentionNode` and update `parseMention` --- lib/model/content.dart | 62 ++++++++++++++++++++-------------- lib/widgets/content.dart | 1 + test/model/content_test.dart | 18 +++++----- test/widgets/content_test.dart | 4 +-- 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 358a02feb1..20346c6f63 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1097,8 +1097,8 @@ class UserMentionNode extends MentionNode { /// The ID of the user being mentioned. /// - /// This is null for wildcard mentions - /// or when the user ID is unavailable in the HTML (e.g., legacy mentions). + /// This is null when the user ID is unavailable in the HTML, + /// e.g., when the mention is a legacy user mention. final int? userId; @override @@ -1129,7 +1129,13 @@ class UserGroupMentionNode extends MentionNode { } } -// TODO(#646) add WildcardMentionNode +class WildcardMentionNode extends MentionNode { + const WildcardMentionNode({ + super.debugHtmlNode, + required super.nodes, + required super.isSilent, + }); +} sealed class EmojiNode extends InlineContentNode { const EmojiNode({super.debugHtmlNode}); @@ -1369,29 +1375,35 @@ class _ZulipInlineContentParser { // tighter on a MentionNode's contents overall. final nodes = parseInlineContentList(element.nodes); - if (mentionType case 'user-group-mention') { - final userGroupId = int.tryParse( - element.attributes['data-user-group-id'] ?? '', - radix: 10); - if (userGroupId == null) { + switch ((mentionType, element.attributes['data-user-id'])) { + case ('user-group-mention', _): + final userGroupId = int.tryParse( + element.attributes['data-user-group-id'] ?? '', + radix: 10); + if (userGroupId == null) { + return null; + } + return UserGroupMentionNode( + nodes: nodes, + isSilent: isSilent, + userGroupId: userGroupId, + debugHtmlNode: debugHtmlNode); + case ('topic-mention', _): + case ('user-mention', _) when hasChannelWildcardClass: + case ('user-mention', '*'): // legacy channel wildcard + return WildcardMentionNode( + nodes: nodes, + isSilent: isSilent, + debugHtmlNode: debugHtmlNode); + case ('user-mention', final userIdString): + final userId = int.tryParse(userIdString ?? '', radix: 10); + return UserMentionNode( + nodes: nodes, + isSilent: isSilent, + userId: userId, + debugHtmlNode: debugHtmlNode); + case _: return null; - } - return UserGroupMentionNode( - nodes: nodes, - isSilent: isSilent, - userGroupId: userGroupId, - debugHtmlNode: debugHtmlNode); - } else { - final userId = switch (element.attributes['data-user-id']) { - // For legacy or wildcard mentions. - null || '*' => null, - final userIdString => int.tryParse(userIdString, radix: 10), - }; - return UserMentionNode( - nodes: nodes, - isSilent: isSilent, - userId: userId, - debugHtmlNode: debugHtmlNode); } } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 32f2bfaa8d..d01aed8390 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1220,6 +1220,7 @@ class Mention extends StatelessWidget { nodes = [TextNode(node.isSilent ? fullName : '@$fullName')]; } case UserMentionNode(userId: null): + case WildcardMentionNode(): } return Container( diff --git a/test/model/content_test.dart b/test/model/content_test.dart index acb74dc703..09d29cdee7 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -162,63 +162,63 @@ class ContentExample { "@**all**", expectedText: '@all', '

@all

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

everyone

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

channel

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

@channel

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

stream

', - const UserMentionNode(nodes: [TextNode('stream')], isSilent: true, userId: null)); + const WildcardMentionNode(nodes: [TextNode('stream')], isSilent: true)); 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')], isSilent: true, userId: null)); + const WildcardMentionNode(nodes: [TextNode('all')], isSilent: true)); static final topicMentionPlain = ContentExample.inline( 'plain @-topic', "@**topic**", expectedText: '@topic', '

@topic

', - const UserMentionNode(nodes: [TextNode('@topic')], isSilent: false, userId: null)); + const WildcardMentionNode(nodes: [TextNode('@topic')], isSilent: false)); static final topicMentionSilent = ContentExample.inline( 'silent @-topic', "@_**topic**", expectedText: 'topic', '

topic

', - const UserMentionNode(nodes: [TextNode('topic')], isSilent: true, userId: null)); + const WildcardMentionNode(nodes: [TextNode('topic')], isSilent: true)); static final topicMentionSilentClassOrderReversed = ContentExample.inline( 'silent @-topic, class order reversed', "@_**topic**", // (hypothetical server variation) expectedText: 'topic', '

topic

', - const UserMentionNode(nodes: [TextNode('topic')], isSilent: true, userId: null)); + const WildcardMentionNode(nodes: [TextNode('topic')], isSilent: true)); static final emojiUnicode = ContentExample.inline( 'Unicode emoji, encoded in span element', diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 686b8f4b03..3fd50f4cbe 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1104,8 +1104,8 @@ void main() { testWidgets('falls back to original text when userId is null', (tester) async { await prepare( tester: tester, - html: '

@all

'); - check(find.text('@all')).findsOne(); + html: '

@Greg Price

'); + check(find.text('@Greg Price')).findsOne(); }); testWidgets('handles silent mentions correctly', (tester) async { From 514a38af782d9af8f34d6963f4104f56a1aeca55 Mon Sep 17 00:00:00 2001 From: Ruhaan Date: Fri, 6 Mar 2026 02:44:16 +0530 Subject: [PATCH 5/5] content: Add distinguishing pill colors for mentions Fixes #646. Used the same pill colors as the web app to distinguish user mentions from user group and wildcard mentions. For the colors used in the web app see: https://github.com/zulip/zulip/blob/81e0305f9/web/styles/app_variables.css#L2004 https://github.com/zulip/zulip/blob/81e0305f9/web/styles/app_variables.css#L2012 Previously, the pill colors for user mentions were used for all types of mentions. --- lib/widgets/content.dart | 16 ++++++++++++-- test/widgets/content_test.dart | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index d01aed8390..8fd4a86e03 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -43,6 +43,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: const HSLColor.fromAHSL(0.04, 0, 0, 0).toColor(), colorDirectMentionBackground: const HSLColor.fromAHSL(0.2, 240, 0.7, 0.7).toColor(), + colorGroupMentionBackground: const HSLColor.fromAHSL(0.18, 183, 0.6, 0.45).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(), colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), @@ -77,6 +78,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: const HSLColor.fromAHSL(0.04, 0, 0, 1).toColor(), colorDirectMentionBackground: const HSLColor.fromAHSL(0.25, 240, 0.52, 0.6).toColor(), + colorGroupMentionBackground: const HSLColor.fromAHSL(0.20, 183, 0.52, 0.4).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(), colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), // the same as light in Web @@ -110,6 +112,7 @@ class ContentTheme extends ThemeExtension { ContentTheme._({ required this.colorCodeBlockBackground, required this.colorDirectMentionBackground, + required this.colorGroupMentionBackground, required this.colorGlobalTimeBackground, required this.colorGlobalTimeBorder, required this.colorLink, @@ -143,6 +146,7 @@ class ContentTheme extends ThemeExtension { final Color colorCodeBlockBackground; final Color colorDirectMentionBackground; + final Color colorGroupMentionBackground; final Color colorGlobalTimeBackground; final Color colorGlobalTimeBorder; final Color colorLink; @@ -204,6 +208,7 @@ class ContentTheme extends ThemeExtension { ContentTheme copyWith({ Color? colorCodeBlockBackground, Color? colorDirectMentionBackground, + Color? colorGroupMentionBackground, Color? colorGlobalTimeBackground, Color? colorGlobalTimeBorder, Color? colorLink, @@ -227,6 +232,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: colorCodeBlockBackground ?? this.colorCodeBlockBackground, colorDirectMentionBackground: colorDirectMentionBackground ?? this.colorDirectMentionBackground, + colorGroupMentionBackground: colorGroupMentionBackground ?? this.colorGroupMentionBackground, colorGlobalTimeBackground: colorGlobalTimeBackground ?? this.colorGlobalTimeBackground, colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder, colorLink: colorLink ?? this.colorLink, @@ -257,6 +263,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: Color.lerp(colorCodeBlockBackground, other.colorCodeBlockBackground, t)!, colorDirectMentionBackground: Color.lerp(colorDirectMentionBackground, other.colorDirectMentionBackground, t)!, + colorGroupMentionBackground: Color.lerp(colorGroupMentionBackground, other.colorGroupMentionBackground, t)!, colorGlobalTimeBackground: Color.lerp(colorGlobalTimeBackground, other.colorGlobalTimeBackground, t)!, colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!, colorLink: Color.lerp(colorLink, other.colorLink, t)!, @@ -1223,10 +1230,15 @@ class Mention extends StatelessWidget { case WildcardMentionNode(): } + final backgroundPillColor = switch (node) { + UserMentionNode() => contentTheme.colorDirectMentionBackground, + UserGroupMentionNode() || WildcardMentionNode() + => contentTheme.colorGroupMentionBackground, + }; + return Container( decoration: BoxDecoration( - // TODO(#646) different for wildcard mentions - color: contentTheme.colorDirectMentionBackground, + color: backgroundPillColor, borderRadius: const BorderRadius.all(Radius.circular(3))), padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize), child: InlineContent( diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 3fd50f4cbe..ff182a7c41 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:legacy_checks/legacy_checks.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -1072,6 +1073,44 @@ void main() { // testFontWeight('non-silent self-user mention in bold context', // expectedWght: 800, // [etc.] + group('pill color', () { + Future checkPillColor(WidgetTester tester, { + required String html, + required Color Function(ContentTheme) expectColor, + }) async { + await prepareContent(tester, + wrapWithPerAccountStoreWidget: true, + plainContent(html)); + + final renderObject = tester.renderObject(find.byType(Mention)); + final paintBounds = renderObject.paintBounds; + final contentTheme = ContentTheme.of(tester.element(find.byType(Mention))); + + check(renderObject).legacyMatcher(equals(paints..rrect( + rrect: RRect.fromRectAndRadius(paintBounds, const Radius.circular(3)), + style: .fill, + color: expectColor(contentTheme)))); + } + + testWidgets('user mention', (tester) async { + await checkPillColor(tester, + html: ContentExample.userMentionPlain.html, + expectColor: (theme) => theme.colorDirectMentionBackground); + }); + + testWidgets('user group mention', (tester) async { + await checkPillColor(tester, + html: ContentExample.groupMentionPlain.html, + expectColor: (theme) => theme.colorGroupMentionBackground); + }); + + testWidgets('wildcard mention', (tester) async { + await checkPillColor(tester, + html: ContentExample.channelWildcardMentionPlain.html, + expectColor: (theme) => theme.colorGroupMentionBackground); + }); + }); + group('user mention dynamic name resolution', () { Future prepare({ required WidgetTester tester,