fix(mobile): open profile sheet when tapping @mentions (#1876)

Signed-off-by: npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv <0028755dd8f83d29f0d5af546e7cf3be55cca85628aa5cdc5340a73deed4b769@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv <0028755dd8f83d29f0d5af546e7cf3be55cca85628aa5cdc5340a73deed4b769@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-14 16:28:34 -07:00
committed by GitHub
co-authored by npub1qq582hwclq7jnux44a2xul8nhe2ue2zk9z49ehzngznnmmk5ka5sprvchv
parent 59e9821503
commit 7e009a8937
5 changed files with 74 additions and 6 deletions
@@ -122,6 +122,8 @@ class _MessageBubble extends ConsumerWidget {
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
@@ -39,6 +39,10 @@ class MessageContent extends HookConsumerWidget {
/// Called when a #channel link is tapped.
final void Function(String channelId)? onChannelTap;
/// Called when an @mention of a known user is tapped, with the
/// mentioned user's pubkey.
final void Function(String pubkey)? onMentionTap;
final TextStyle? baseStyle;
final int? maxLines;
@@ -50,6 +54,7 @@ class MessageContent extends HookConsumerWidget {
this.channelNames = const {},
this.tags = const [],
this.onChannelTap,
this.onMentionTap,
this.baseStyle,
this.maxLines,
});
@@ -145,7 +150,7 @@ class MessageContent extends HookConsumerWidget {
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
maxLines: maxLines,
inlineComponents: [
_MentionMd(mentionNames: mentionNames),
_MentionMd(mentionNames: mentionNames, onMentionTap: onMentionTap),
CustomEmojiMd(customEmoji),
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
...MarkdownComponent.inlineComponents,
@@ -572,13 +577,14 @@ class _MessageCodeBlock extends HookWidget {
class _MentionMd extends InlineMd {
final Map<String, String> mentionNames;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
knownNames: _mentionAliases(mentionNames.values),
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);
_MentionMd({required this.mentionNames});
_MentionMd({required this.mentionNames, this.onMentionTap});
@override
RegExp get exp => _exp;
@@ -596,22 +602,28 @@ class _MentionMd extends InlineMd {
final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase();
String? displayName;
String? pubkey;
for (final entry in mentionNames.entries) {
final entryName = entry.value.toLowerCase();
final firstName = entryName.split(RegExp(r'\s+')).first;
if (entryName == name || firstName == name) {
displayName = entry.value;
pubkey = entry.key;
break;
}
}
final pill = _TokenPill(
text: '@${displayName ?? raw.substring(1)}',
textStyle: config.style,
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _TokenPill(
text: '@${displayName ?? raw.substring(1)}',
textStyle: config.style,
),
child: pubkey != null && onMentionTap != null
? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill)
: pill,
);
}
}
@@ -505,6 +505,8 @@ class _ThreadMessage extends ConsumerWidget {
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
@@ -363,6 +363,7 @@ class _OriginalPost extends ConsumerWidget {
content: post.content,
mentionNames: mentionNames,
tags: post.tags,
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
],
),
@@ -457,6 +458,7 @@ class _ReplyRow extends ConsumerWidget {
content: reply.content,
mentionNames: mentionNames,
tags: reply.tags,
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
),
],
@@ -775,6 +775,56 @@ void main() {
expect(_allRichText(tester), contains('alice@example.com'));
expect(find.text('@example.com'), findsNothing);
});
testWidgets('mention tap callback fires with pubkey', (tester) async {
String? tappedPubkey;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @Alice check this out',
mentionNames: const {'pk1': 'Alice'},
onMentionTap: (pubkey) => tappedPubkey = pubkey,
),
),
);
await tester.tap(find.text('@Alice'));
expect(tappedPubkey, 'pk1');
});
testWidgets('multi-word mention tap callback fires with pubkey', (
tester,
) async {
String? tappedPubkey;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @Kenny Lopez can you review this?',
mentionNames: const {'pk1': 'Kenny Lopez'},
onMentionTap: (pubkey) => tappedPubkey = pubkey,
),
),
);
await tester.tap(find.text('@Kenny Lopez'));
expect(tappedPubkey, 'pk1');
});
testWidgets('unknown mention renders without tap', (tester) async {
var tapped = false;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @unknown check this',
mentionNames: const {},
onMentionTap: (_) => tapped = true,
),
),
);
await tester.tap(find.text('@unknown'), warnIfMissed: false);
expect(tapped, isFalse);
});
});
group('#channel links', () {