diff --git a/lib/model/content.dart b/lib/model/content.dart index 4413857173..498a5d2d8b 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -926,10 +926,13 @@ enum UserMentionType { user, userGroup } class UserMentionNode extends InlineContainerNode { const UserMentionNode({ + required this.userId, super.debugHtmlNode, required super.nodes, }); + final int? userId; + // 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 "@"), @@ -1083,7 +1086,15 @@ class _ZulipInlineContentParser { // 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); + + // Extract user ID from data-user-id attribute if present + int? userId; + final userIdStr = element.attributes['data-user-id']; + if (userIdStr != null) { + userId = int.tryParse(userIdStr); + } + + return UserMentionNode(nodes: nodes, debugHtmlNode: debugHtmlNode, userId: userId); } /// The links found so far in the current block inline container. diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 2816e90233..b3ceb4fa37 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -21,6 +21,7 @@ import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; +import 'profile.dart'; import 'scrolling.dart'; import 'store.dart'; import 'text.dart'; @@ -1216,25 +1217,38 @@ class UserMention extends StatelessWidget { @override Widget build(BuildContext context) { final contentTheme = ContentTheme.of(context); - return Container( + final userId = node.userId; + + final innerContent = InlineContent( + // If an @-mention is inside a link, let the @-mention override it. + recognizer: null, + // One hopes an @-mention can't contain an embedded link. + // (The parser on creating a UserMentionNode has a TODO to check that.) + linkRecognizers: null, + + style: ambientTextStyle, + + nodes: node.nodes); + + final widget = Container( decoration: BoxDecoration( // TODO(#646) different for wildcard mentions color: contentTheme.colorDirectMentionBackground, borderRadius: const BorderRadius.all(Radius.circular(3))), padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize), - child: InlineContent( - // If an @-mention is inside a link, let the @-mention override it. - recognizer: null, // TODO(#1867) make @-mentions tappable, for info on user - // One hopes an @-mention can't contain an embedded link. - // (The parser on creating a UserMentionNode has a TODO to check that.) - linkRecognizers: null, - - // TODO(#647) when self-user is non-silently mentioned, make bold, and: - // TODO(#646) when self-user is non-silently mentioned, - // distinguish font color between direct and wildcard mentions - style: ambientTextStyle, - - nodes: node.nodes)); + child: innerContent); + + if (userId != null && userId > 0) { + return GestureDetector( + onTap: () => Navigator.push( + context, + ProfilePage.buildRoute(context: context, userId: userId), + ), + child: widget, + ); + } else { + return widget; + } } // This is a more literal translation of Zulip web's CSS. diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 84e3baed42..6c2aaa6acb 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -111,105 +111,105 @@ class ContentExample { "@**Greg Price**", expectedText: '@Greg Price', '
@Greg Price
', - const UserMentionNode(nodes: [TextNode('@Greg Price')])); + const UserMentionNode(userId: null, nodes: [TextNode('@Greg Price')])); static final userMentionSilent = ContentExample.inline( 'silent user @-mention', "@_**Greg Price**", expectedText: 'Greg Price', 'Greg Price
', - const UserMentionNode(nodes: [TextNode('Greg Price')])); + const UserMentionNode(userId: null, nodes: [TextNode('Greg Price')])); static final userMentionSilentClassOrderReversed = ContentExample.inline( 'silent user @-mention, class order reversed', "@_**Greg Price**", // (hypothetical server variation) expectedText: 'Greg Price', 'Greg Price
', - const UserMentionNode(nodes: [TextNode('Greg Price')])); + const UserMentionNode(userId: null, nodes: [TextNode('Greg Price')])); static final groupMentionPlain = ContentExample.inline( 'plain group @-mention', "@*test-empty*", expectedText: '@test-empty', '@test-empty
', - const UserMentionNode(nodes: [TextNode('@test-empty')])); + const UserMentionNode(userId: null, nodes: [TextNode('@test-empty')])); static final groupMentionSilent = ContentExample.inline( 'silent group @-mention', "@_*test-empty*", expectedText: 'test-empty', 'test-empty
', - const UserMentionNode(nodes: [TextNode('test-empty')])); + const UserMentionNode(userId: null, nodes: [TextNode('test-empty')])); static final groupMentionSilentClassOrderReversed = ContentExample.inline( 'silent group @-mention, class order reversed', "@_*test-empty*", // (hypothetical server variation) expectedText: 'test-empty', 'test-empty
', - const UserMentionNode(nodes: [TextNode('test-empty')])); + const UserMentionNode(userId: null, nodes: [TextNode('test-empty')])); static final channelWildcardMentionPlain = ContentExample.inline( 'plain channel wildcard @-mention', "@**all**", expectedText: '@all', '@all
', - const UserMentionNode(nodes: [TextNode('@all')])); + const UserMentionNode(userId: null, nodes: [TextNode('@all')])); static final channelWildcardMentionSilent = ContentExample.inline( 'silent channel wildcard @-mention', "@_**everyone**", expectedText: 'everyone', 'everyone
', - const UserMentionNode(nodes: [TextNode('everyone')])); + const UserMentionNode(userId: null, 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')])); + const UserMentionNode(userId: null, nodes: [TextNode('channel')])); static final legacyChannelWildcardMentionPlain = ContentExample.inline( 'legacy plain channel wildcard @-mention', "@**channel**", expectedText: '@channel', '@channel
', - const UserMentionNode(nodes: [TextNode('@channel')])); + const UserMentionNode(userId: null, nodes: [TextNode('@channel')])); static final legacyChannelWildcardMentionSilent = ContentExample.inline( 'legacy silent channel wildcard @-mention', "@_**stream**", expectedText: 'stream', 'stream
', - const UserMentionNode(nodes: [TextNode('stream')])); + const UserMentionNode(userId: null, 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')])); + const UserMentionNode(userId: null, nodes: [TextNode('all')])); static final topicMentionPlain = ContentExample.inline( 'plain @-topic', "@**topic**", expectedText: '@topic', '@topic
', - const UserMentionNode(nodes: [TextNode('@topic')])); + const UserMentionNode(userId: null, nodes: [TextNode('@topic')])); static final topicMentionSilent = ContentExample.inline( 'silent @-topic', "@_**topic**", expectedText: 'topic', 'topic
', - const UserMentionNode(nodes: [TextNode('topic')])); + const UserMentionNode(userId: null, nodes: [TextNode('topic')])); static final topicMentionSilentClassOrderReversed = ContentExample.inline( 'silent @-topic, class order reversed', "@_**topic**", // (hypothetical server variation) expectedText: 'topic', 'topic
', - const UserMentionNode(nodes: [TextNode('topic')])); + const UserMentionNode(userId: null, nodes: [TextNode('topic')])); 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 28af496c59..018b065a02 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1383,4 +1383,61 @@ void main() { check(linkText.textAlign).equals(TextAlign.center); }); }); + + group('UserMention tappable functionality', () { + testWidgets('mention with valid user ID has gesture detector', (tester) async { + await prepareContent(tester, plainContent('@Test User
')); + expect(find.byType(GestureDetector), findsOneWidget); + }); + + testWidgets('mention with user ID navigates to ProfilePage when tapped', (tester) async { + final pushedRoutes =@Test User
'), + )); + await tester.pump(); // global store + + await tester.pump(); // Allow any deferred work to complete + + expect(find.byType(GestureDetector), findsOneWidget); + + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + + // Verify that navigation occurred (at least one route was pushed) + expect(pushedRoutes.length, greaterThanOrEqualTo(1)); + }); + + testWidgets('mention without user ID does not have gesture detector', (tester) async { + await prepareContent(tester, plainContent('@Test User
')); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('mention with invalid user ID does not have gesture detector', (tester) async { + await prepareContent(tester, plainContent('@Test User
')); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('mention with wildcard user ID does not have gesture detector', (tester) async { + await prepareContent(tester, plainContent('@all
')); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('mention with zero user ID does not have gesture detector', (tester) async { + await prepareContent(tester, plainContent('@Test User
')); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('mention with negative user ID does not have gesture detector', (tester) async { + await prepareContent(tester, plainContent('@Test User
')); + expect(find.byType(GestureDetector), findsNothing); + }); + }); }