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 297b77894a..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)!, @@ -1220,12 +1227,18 @@ class Mention extends StatelessWidget { nodes = [TextNode(node.isSilent ? fullName : '@$fullName')]; } case UserMentionNode(userId: null): + 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( @@ -1235,8 +1248,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)); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index b5da630f7e..09d29cdee7 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*", @@ -153,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', @@ -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 dafa30f02b..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'; @@ -985,10 +986,16 @@ void main() { wrapWithPerAccountStoreWidget: true); testContentSmoke(ContentExample.userMentionSilent, wrapWithPerAccountStoreWidget: true); + testContentSmoke(ContentExample.userMentionSilentClassOrderReversed, + wrapWithPerAccountStoreWidget: true); + testContentSmoke(ContentExample.legacyUserMention, + 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, @@ -1066,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, @@ -1098,8 +1143,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 {