Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -928,8 +928,11 @@ class UserMentionNode extends InlineContainerNode {
const UserMentionNode({
super.debugHtmlNode,
required super.nodes,
this.userId,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be required so that the parameters are explicit, especially in the tests.

Suggested change
this.userId,
required this.userId,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW the reason I generally want the fields on content nodes (and other types from the API) to be required in the constructors is for the sake of the code other than the tests. It's because these generally don't have reasonable defaults outside the context of making test data — instead, the only reasonable value to to use is whatever comes from the actual data from the server, and there's usually not any call site where that would have a constant value like null.

In test data, by contrast, there's very commonly some reasonable "boring" value that would work for most tests without the reader of the test needing to care about what specific value it is. For those, defaults are good. The contrast where a lot of fields have good defaults for test data but no good defaults for the app's code is why example_data.dart has lots of functions that basically just wrap constructors for various API types but provide defaults.

});

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 "@"),
Expand Down Expand Up @@ -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.
Expand Down
59 changes: 40 additions & 19 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Comment on lines 1220 to 1251
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, this is doing the same right?

Suggested change
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);
}
Widget 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));
if (node.userId == null) return widget;
return GestureDetector(
onTap: () {
Navigator.push(context,
ProfilePage.buildRoute(context: context, userId: node.userId!));
},
child: widget);

}

// This is a more literal translation of Zulip web's CSS.
Expand Down
94 changes: 94 additions & 0 deletions test/widgets/user_mention_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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('<p><span class="user-mention" data-user-id="123">@Test User</span></p>'));
expect(find.byType(GestureDetector), findsOneWidget);
});

testWidgets('mention with user ID navigates to ProfilePage when tapped', (tester) async {
final pushedRoutes = <Route<dynamic>>[];
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('<p><span class="user-mention" data-user-id="123">@Test User</span></p>'),
));
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('<p><span class="user-mention">@Test User</span></p>'));
expect(find.byType(GestureDetector), findsNothing);
});

testWidgets('mention with invalid user ID does not have gesture detector', (tester) async {
await prepareContent(tester, plainContent('<p><span class="user-mention" data-user-id="invalid">@Test User</span></p>'));
expect(find.byType(GestureDetector), findsNothing);
});

testWidgets('mention with wildcard user ID does not have gesture detector', (tester) async {
await prepareContent(tester, plainContent('<p><span class="user-mention" data-user-id="*">@all</span></p>'));
expect(find.byType(GestureDetector), findsNothing);
});

testWidgets('mention with zero user ID does not have gesture detector', (tester) async {
await prepareContent(tester, plainContent('<p><span class="user-mention" data-user-id="0">@Test User</span></p>'));
expect(find.byType(GestureDetector), findsNothing);
});

testWidgets('mention with negative user ID does not have gesture detector', (tester) async {
await prepareContent(tester, plainContent('<p><span class="user-mention" data-user-id="-1">@Test User</span></p>'));
expect(find.byType(GestureDetector), findsNothing);
});
});
}