Skip to content

Commit 0722254

Browse files
committed
feat: make @-mentions tappable to navigate to user profiles
1 parent 6adc9c3 commit 0722254

File tree

3 files changed

+147
-20
lines changed

3 files changed

+147
-20
lines changed

lib/model/content.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,8 +928,11 @@ class UserMentionNode extends InlineContainerNode {
928928
const UserMentionNode({
929929
super.debugHtmlNode,
930930
required super.nodes,
931+
this.userId,
931932
});
932933

934+
final int? userId;
935+
933936
// For the legacy design, we don't need this information in code; instead,
934937
// the inner text already shows how to communicate it to the user
935938
// (e.g., silent mentions' text lacks a leading "@"),
@@ -1083,7 +1086,15 @@ class _ZulipInlineContentParser {
10831086
// either a debug-mode check, or perhaps we can make expectations much
10841087
// tighter on a UserMentionNode's contents overall.
10851088
final nodes = parseInlineContentList(element.nodes);
1086-
return UserMentionNode(nodes: nodes, debugHtmlNode: debugHtmlNode);
1089+
1090+
// Extract user ID from data-user-id attribute if present
1091+
int? userId;
1092+
final userIdStr = element.attributes['data-user-id'];
1093+
if (userIdStr != null) {
1094+
userId = int.tryParse(userIdStr);
1095+
}
1096+
1097+
return UserMentionNode(nodes: nodes, debugHtmlNode: debugHtmlNode, userId: userId);
10871098
}
10881099

10891100
/// The links found so far in the current block inline container.

lib/widgets/content.dart

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'katex.dart';
2121
import 'lightbox.dart';
2222
import 'message_list.dart';
2323
import 'poll.dart';
24+
import 'profile.dart';
2425
import 'scrolling.dart';
2526
import 'store.dart';
2627
import 'text.dart';
@@ -1216,25 +1217,45 @@ class UserMention extends StatelessWidget {
12161217
@override
12171218
Widget build(BuildContext context) {
12181219
final contentTheme = ContentTheme.of(context);
1219-
return Container(
1220-
decoration: BoxDecoration(
1221-
// TODO(#646) different for wildcard mentions
1222-
color: contentTheme.colorDirectMentionBackground,
1223-
borderRadius: const BorderRadius.all(Radius.circular(3))),
1224-
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
1225-
child: InlineContent(
1226-
// If an @-mention is inside a link, let the @-mention override it.
1227-
recognizer: null, // TODO(#1867) make @-mentions tappable, for info on user
1228-
// One hopes an @-mention can't contain an embedded link.
1229-
// (The parser on creating a UserMentionNode has a TODO to check that.)
1230-
linkRecognizers: null,
1231-
1232-
// TODO(#647) when self-user is non-silently mentioned, make bold, and:
1233-
// TODO(#646) when self-user is non-silently mentioned,
1234-
// distinguish font color between direct and wildcard mentions
1235-
style: ambientTextStyle,
1236-
1237-
nodes: node.nodes));
1220+
final userId = node.userId;
1221+
1222+
final innerContent = InlineContent(
1223+
// If an @-mention is inside a link, let the @-mention override it.
1224+
recognizer: null,
1225+
// One hopes an @-mention can't contain an embedded link.
1226+
// (The parser on creating a UserMentionNode has a TODO to check that.)
1227+
linkRecognizers: null,
1228+
1229+
style: ambientTextStyle,
1230+
1231+
nodes: node.nodes);
1232+
1233+
if (userId != null && userId > 0) {
1234+
// Wrap with gesture detector if we have a valid user ID
1235+
return GestureDetector(
1236+
onTap: () => Navigator.push(
1237+
context,
1238+
ProfilePage.buildRoute(context: context, userId: userId),
1239+
),
1240+
child: Container(
1241+
decoration: BoxDecoration(
1242+
// TODO(#646) different for wildcard mentions
1243+
color: contentTheme.colorDirectMentionBackground,
1244+
borderRadius: const BorderRadius.all(Radius.circular(3))),
1245+
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
1246+
child: innerContent,
1247+
),
1248+
);
1249+
} else {
1250+
// Regular container without gesture detector if no valid user ID
1251+
return Container(
1252+
decoration: BoxDecoration(
1253+
// TODO(#646) different for wildcard mentions
1254+
color: contentTheme.colorDirectMentionBackground,
1255+
borderRadius: const BorderRadius.all(Radius.circular(3))),
1256+
padding: const EdgeInsets.symmetric(horizontal: 0.2 * kBaseFontSize),
1257+
child: innerContent);
1258+
}
12381259
}
12391260

12401261
// This is a more literal translation of Zulip web's CSS.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:zulip/model/content.dart';
4+
import 'package:zulip/widgets/content.dart';
5+
import 'package:zulip/widgets/profile.dart';
6+
7+
import '../example_data.dart' as eg;
8+
import '../model/binding.dart';
9+
import '../test_navigation.dart';
10+
import 'test_app.dart';
11+
12+
Widget plainContent(String html) {
13+
return Builder(builder: (context) =>
14+
DefaultTextStyle(
15+
style: ContentTheme.of(context).textStylePlainParagraph,
16+
child: BlockContentList(nodes: parseContent(html).nodes)));
17+
}
18+
19+
Future<void> prepareContent(WidgetTester tester, Widget child, {
20+
bool wrapWithPerAccountStoreWidget = false,
21+
}) async {
22+
if (wrapWithPerAccountStoreWidget) {
23+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
24+
}
25+
addTearDown(testBinding.reset);
26+
27+
await tester.pumpWidget(TestZulipApp(
28+
accountId: wrapWithPerAccountStoreWidget ? eg.selfAccount.id : null,
29+
child: child));
30+
await tester.pump(); // global store
31+
if (wrapWithPerAccountStoreWidget) {
32+
await tester.pump();
33+
}
34+
}
35+
36+
void main() {
37+
TestZulipBinding.ensureInitialized();
38+
39+
group('UserMention tappable functionality', () {
40+
testWidgets('mention with valid user ID has gesture detector', (tester) async {
41+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\" data-user-id=\"123\">@Test User</span></p>'));
42+
expect(find.byType(GestureDetector), findsOneWidget);
43+
});
44+
45+
testWidgets('mention with user ID navigates to ProfilePage when tapped', (tester) async {
46+
final pushedRoutes = <Route<dynamic>>[];
47+
final testNavObserver = TestNavigatorObserver()
48+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
49+
50+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
51+
addTearDown(testBinding.reset);
52+
await tester.pumpWidget(TestZulipApp(
53+
accountId: eg.selfAccount.id,
54+
navigatorObservers: [testNavObserver],
55+
child: plainContent('<p><span class=\"user-mention\" data-user-id=\"123\">@Test User</span></p>'),
56+
));
57+
await tester.pump(); // global store
58+
59+
await tester.pump(); // Allow any deferred work to complete
60+
61+
expect(find.byType(GestureDetector), findsOneWidget);
62+
63+
await tester.tap(find.byType(GestureDetector));
64+
await tester.pump();
65+
66+
// Verify that navigation occurred (at least one route was pushed)
67+
expect(pushedRoutes.length, greaterThanOrEqualTo(1));
68+
});
69+
70+
testWidgets('mention without user ID does not have gesture detector', (tester) async {
71+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\">@Test User</span></p>'));
72+
expect(find.byType(GestureDetector), findsNothing);
73+
});
74+
75+
testWidgets('mention with invalid user ID does not have gesture detector', (tester) async {
76+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\" data-user-id=\"invalid\">@Test User</span></p>'));
77+
expect(find.byType(GestureDetector), findsNothing);
78+
});
79+
80+
testWidgets('mention with wildcard user ID does not have gesture detector', (tester) async {
81+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\" data-user-id=\"*\">@all</span></p>'));
82+
expect(find.byType(GestureDetector), findsNothing);
83+
});
84+
85+
testWidgets('mention with zero user ID does not have gesture detector', (tester) async {
86+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\" data-user-id=\"0\">@Test User</span></p>'));
87+
expect(find.byType(GestureDetector), findsNothing);
88+
});
89+
90+
testWidgets('mention with negative user ID does not have gesture detector', (tester) async {
91+
await prepareContent(tester, plainContent('<p><span class=\"user-mention\" data-user-id=\"-1\">@Test User</span></p>'));
92+
expect(find.byType(GestureDetector), findsNothing);
93+
});
94+
});
95+
}

0 commit comments

Comments
 (0)