diff --git a/lib/model/content.dart b/lib/model/content.dart index faaef1728c..d26ab0fde7 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -721,6 +721,10 @@ class UserMentionNode extends MentionNode { // final bool isSilent; // TODO(#647) } +class TopicMentionNode extends MentionNode { + const TopicMentionNode({super.debugHtmlNode, required super.nodes}); +} + sealed class EmojiNode extends InlineContentNode { const EmojiNode({super.debugHtmlNode}); } @@ -939,6 +943,13 @@ class _ZulipContentParser { static final _userMentionClassNameRegexp = RegExp( r"(^| )" r"user(?:-group)?-mention" r"( |$)"); + static final _topicMentionClassNameRegexp = () { + // This matches a class `topic-mention`, plus an optional class `silent`, + // appearing in either order. + const mentionClass = "topic-mention"; + return RegExp("^(?:$mentionClass(?: silent)?|silent $mentionClass)\$"); + }(); + static final _emojiClassNameRegexp = () { const specificEmoji = r"emoji(?:-[0-9a-f]+)+"; return RegExp("^(?:emoji $specificEmoji|$specificEmoji emoji)\$"); @@ -995,6 +1006,14 @@ class _ZulipContentParser { return parseUserMention(element) ?? unimplemented(); } + if (localName == 'span' + && _topicMentionClassNameRegexp.hasMatch(className)) { + // TODO assert TopicMentionNode can't contain LinkNode; + // either a debug-mode check, or perhaps we can make expectations much + // tighter on a TopicMentionNode's contents overall. + return TopicMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + } + if (localName == 'span' && _emojiClassNameRegexp.hasMatch(className)) { final emojiCode = _emojiCodeFromClassNameRegexp.firstMatch(className)! diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index dfae70d0ad..9bfc6f580e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -40,6 +40,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(), + colorTopicMentionBackground: 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(), colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(), @@ -73,6 +74,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(), + colorTopicMentionBackground: const HSLColor.fromAHSL(0.18, 183, 0.52, 0.40).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(), colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(), @@ -105,6 +107,7 @@ class ContentTheme extends ThemeExtension { ContentTheme._({ required this.colorCodeBlockBackground, required this.colorDirectMentionBackground, + required this.colorTopicMentionBackground, required this.colorGlobalTimeBackground, required this.colorGlobalTimeBorder, required this.colorMathBlockBorder, @@ -137,6 +140,7 @@ class ContentTheme extends ThemeExtension { final Color colorCodeBlockBackground; final Color colorDirectMentionBackground; + final Color colorTopicMentionBackground; final Color colorGlobalTimeBackground; final Color colorGlobalTimeBorder; final Color colorMathBlockBorder; // TODO(#46) this won't be needed @@ -197,6 +201,7 @@ class ContentTheme extends ThemeExtension { ContentTheme copyWith({ Color? colorCodeBlockBackground, Color? colorDirectMentionBackground, + Color? colorTopicMentionBackground, Color? colorGlobalTimeBackground, Color? colorGlobalTimeBorder, Color? colorMathBlockBorder, @@ -219,6 +224,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: colorCodeBlockBackground ?? this.colorCodeBlockBackground, colorDirectMentionBackground: colorDirectMentionBackground ?? this.colorDirectMentionBackground, + colorTopicMentionBackground: colorTopicMentionBackground ?? this.colorTopicMentionBackground, colorGlobalTimeBackground: colorGlobalTimeBackground ?? this.colorGlobalTimeBackground, colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder, colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder, @@ -248,6 +254,7 @@ class ContentTheme extends ThemeExtension { return ContentTheme._( colorCodeBlockBackground: Color.lerp(colorCodeBlockBackground, other.colorCodeBlockBackground, t)!, colorDirectMentionBackground: Color.lerp(colorDirectMentionBackground, other.colorDirectMentionBackground, t)!, + colorTopicMentionBackground: Color.lerp(colorTopicMentionBackground, other.colorTopicMentionBackground, t)!, colorGlobalTimeBackground: Color.lerp(colorGlobalTimeBackground, other.colorGlobalTimeBackground, t)!, colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!, colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!, @@ -1133,7 +1140,10 @@ class Mention extends StatelessWidget { return Container( decoration: BoxDecoration( // TODO(#646) different for wildcard mentions - color: contentTheme.colorDirectMentionBackground, + color: switch (node) { + UserMentionNode() => contentTheme.colorDirectMentionBackground, + TopicMentionNode() => contentTheme.colorTopicMentionBackground, + }, borderRadius: const BorderRadius.all(Radius.circular(3))), padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize), child: InlineContent( diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 8a895b6ab1..f56d256e8d 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -180,6 +180,26 @@ class ContentExample { expectedText: 'all', '

all

', const UserMentionNode(nodes: [TextNode('all')])); + static final topicMentionPlain = ContentExample.inline( + 'plain @-topic', + "@**topic**", + expectedText: '@topic', + '

@topic

', + const TopicMentionNode(nodes: [TextNode('@topic')])); + + static final topicMentionSilent = ContentExample.inline( + 'silent @-topic', + "@_**topic**", + expectedText: 'topic', + '

topic

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

topic

', + const TopicMentionNode(nodes: [TextNode('topic')])); static final emojiUnicode = ContentExample.inline( 'Unicode emoji, encoded in span element', @@ -1262,6 +1282,12 @@ void main() { testParseExample(ContentExample.legacyChannelWildcardMentionPlain); testParseExample(ContentExample.legacyChannelWildcardMentionSilent); testParseExample(ContentExample.legacyChannelWildcardMentionSilentClassOrderReversed); + + testParseExample(ContentExample.topicMentionPlain); + testParseExample(ContentExample.topicMentionSilent); + testParseExample(ContentExample.topicMentionSilentClassOrderReversed); + + // TODO test wildcard mentions }); testParseExample(ContentExample.emojiUnicode); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 20c41e91aa..6e97c2b5ca 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -687,7 +687,7 @@ void main() { findAncestor: find.byWidget(widget), mentionText)!; } - testWidgets('maintains font-size ratio with surrounding text', (tester) async { + testWidgets('user-mention maintains font-size ratio with surrounding text', (tester) async { await checkFontSizeRatio(tester, targetHtml: '@Chris Bobbe', targetFontSizeFinder: (rootSpan) { @@ -724,6 +724,47 @@ void main() { // TODO(#647): // testFontWeight('non-silent self-user mention in bold context', // expectedWght: 800, // [etc.] + + testContentSmoke(ContentExample.topicMentionPlain); + testContentSmoke(ContentExample.topicMentionSilent); + + testWidgets('topic-mention maintains font-size ratio with surrounding text', (tester) async { + await checkFontSizeRatio(tester, + targetHtml: '@topic', + targetFontSizeFinder: (rootSpan) { + final widget = findMentionWidgetInSpan(rootSpan); + final style = textStyleFromWidget(tester, widget!, '@topic'); + return style.fontSize!; + }); + }); + + testFontWeight('silent topic mention in plain paragraph', + expectedWght: 400, + // @_**topic** + content: plainContent( + '

topic

'), + styleFinder: (tester) { + return textStyleFromWidget(tester, + tester.widget(find.byType(Mention)), 'topic'); + }); + + // TODO(#647): + // testFontWeight('non-silent topic mention in plain paragraph', + // expectedWght: 600, // [etc.] + + testFontWeight('silent topic mention in bold context', + expectedWght: 600, + // # @_**topic** + content: plainContent( + '

topic

'), + styleFinder: (tester) { + return textStyleFromWidget(tester, + tester.widget(find.byType(Mention)), 'topic'); + }); + + // TODO(#647): + // testFontWeight('non-silent topic mention in bold context', + // expectedWght: 800, // [etc.] }); Future tapText(WidgetTester tester, Finder textFinder) async {