Skip to content

Commit 0194098

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

File tree

3 files changed

+146
-20
lines changed

3 files changed

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

0 commit comments

Comments
 (0)