diff --git a/lib/model/content.dart b/lib/model/content.dart index 4413857173..dfa958aa70 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -928,8 +928,11 @@ class UserMentionNode extends InlineContainerNode { const UserMentionNode({ super.debugHtmlNode, required super.nodes, + this.userId, }); + 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..cc2e67673b 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,45 @@ class UserMention extends StatelessWidget { @override Widget build(BuildContext context) { final contentTheme = ContentTheme.of(context); - return 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)); + 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); + + if (userId != null && userId > 0) { + // Wrap with gesture detector if we have a valid user ID + return GestureDetector( + onTap: () => Navigator.push( + context, + ProfilePage.buildRoute(context: context, userId: userId), + ), + child: 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: innerContent, + ), + ); + } else { + // Regular container without gesture detector if no valid user ID + return 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: innerContent); + } } // This is a more literal translation of Zulip web's CSS. diff --git a/test/widgets/user_mention_test.dart b/test/widgets/user_mention_test.dart new file mode 100644 index 0000000000..516dae56bc --- /dev/null +++ b/test/widgets/user_mention_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/content.dart'; +import 'package:zulip/widgets/content.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../test_navigation.dart'; +import 'test_app.dart'; + +Widget plainContent(String html) { + return Builder(builder: (context) => + DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: parseContent(html).nodes))); +} + +Future prepareContent(WidgetTester tester, Widget child, { + bool wrapWithPerAccountStoreWidget = false, +}) async { + if (wrapWithPerAccountStoreWidget) { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + } + addTearDown(testBinding.reset); + + await tester.pumpWidget(TestZulipApp( + accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null, + child: child)); + await tester.pump(); // global store + if (wrapWithPerAccountStoreWidget) { + await tester.pump(); + } +} + +void main() { + TestZulipBinding.ensureInitialized(); + + 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 = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + addTearDown(testBinding.reset); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver], + child: plainContent('

@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); + }); + }); +} \ No newline at end of file