Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -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 "@"),
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
42 changes: 28 additions & 14 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,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.
Expand Down
30 changes: 15 additions & 15 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,105 +111,105 @@ class ContentExample {
"@**Greg Price**",
expectedText: '@Greg Price',
'<p><span class="user-mention" data-user-id="2187">@Greg Price</span></p>',
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',
'<p><span class="user-mention silent" data-user-id="2187">Greg Price</span></p>',
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',
'<p><span class="silent user-mention" data-user-id="2187">Greg Price</span></p>',
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',
'<p><span class="user-group-mention" data-user-group-id="186">@test-empty</span></p>',
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',
'<p><span class="user-group-mention silent" data-user-group-id="186">test-empty</span></p>',
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',
'<p><span class="silent user-group-mention" data-user-group-id="186">test-empty</span></p>',
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',
'<p><span class="user-mention channel-wildcard-mention" data-user-id="*">@all</span></p>',
const UserMentionNode(nodes: [TextNode('@all')]));
const UserMentionNode(userId: null, nodes: [TextNode('@all')]));

static final channelWildcardMentionSilent = ContentExample.inline(
'silent channel wildcard @-mention',
"@_**everyone**",
expectedText: 'everyone',
'<p><span class="user-mention channel-wildcard-mention silent" data-user-id="*">everyone</span></p>',
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',
'<p><span class="silent user-mention channel-wildcard-mention" data-user-id="*">channel</span></p>',
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',
'<p><span class="user-mention" data-user-id="*">@channel</span></p>',
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',
'<p><span class="user-mention silent" data-user-id="*">stream</span></p>',
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',
'<p><span class="silent user-mention" data-user-id="*">all</span></p>',
const UserMentionNode(nodes: [TextNode('all')]));
const UserMentionNode(userId: null, nodes: [TextNode('all')]));

static final topicMentionPlain = ContentExample.inline(
'plain @-topic',
"@**topic**",
expectedText: '@topic',
'<p><span class="topic-mention">@topic</span></p>',
const UserMentionNode(nodes: [TextNode('@topic')]));
const UserMentionNode(userId: null, nodes: [TextNode('@topic')]));

static final topicMentionSilent = ContentExample.inline(
'silent @-topic',
"@_**topic**",
expectedText: 'topic',
'<p><span class="topic-mention silent">topic</span></p>',
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',
'<p><span class="silent topic-mention">topic</span></p>',
const UserMentionNode(nodes: [TextNode('topic')]));
const UserMentionNode(userId: null, nodes: [TextNode('topic')]));

static final emojiUnicode = ContentExample.inline(
'Unicode emoji, encoded in span element',
Expand Down
57 changes: 57 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<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);
});
});
}