diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index db39850481..aecef6329c 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; @@ -87,8 +89,16 @@ class ActivityPage extends HookConsumerWidget { ]; // Preload sender profiles for visible rows. - final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final item in visibleItems) item.item.pubkey.toLowerCase(), + for (final item in visibleItems) + ...mentionedPubkeysFromTags(item.item.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fb267c03be..9dd8b6ddfa 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -61,6 +61,28 @@ class _InboxRow extends ConsumerWidget { final userCache = ref.watch(userCacheProvider); final profile = userCache[item.item.pubkey.toLowerCase()]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final isDm = channel?.isDm ?? false; final channelName = channel != null && !isDm @@ -172,6 +194,8 @@ class _InboxRow extends ConsumerWidget { // Message preview. MessageContent( content: item.item.displayContent, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: item.item.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 179ea0d4f3..8730908ada 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -8,21 +8,20 @@ import '../channel_typing_provider.dart'; /// /// Used by both the members button badge and the members sheet to avoid /// duplicating the bot-typing cross-reference logic. -final workingBotPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final typingEntries = ref.watch(channelTypingProvider(channelId)); - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final allMembers = membersAsync.asData?.value ?? const []; +final workingBotPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final typingEntries = ref.watch(channelTypingProvider(channelId)); + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final allMembers = membersAsync.asData?.value ?? const []; - final botPubkeys = { - for (final m in allMembers) - if (m.isBot) m.pubkey.toLowerCase(), - }; + final botPubkeys = { + for (final m in allMembers) + if (m.isBot) m.pubkey.toLowerCase(), + }; - return { - for (final e in typingEntries) - if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(), - }; -}); + return { + for (final e in typingEntries) + if (botPubkeys.contains(e.pubkey.toLowerCase())) + e.pubkey.toLowerCase(), + }; + }); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7abeed8d7a..044342d101 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -39,7 +40,6 @@ import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index fcabfd619a..cb6ab67065 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -36,8 +36,14 @@ class _MessageBubble extends ConsumerWidget { // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch( - mentionAgentPubkeysProvider(currentChannelId), + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch( + agentMentionPubkeysProvider(currentChannelId), + ), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], ); final mentionNames = {}; final agentMentionPubkeys = {}; @@ -51,6 +57,12 @@ class _MessageBubble extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -166,7 +178,7 @@ class _MessageBubble extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 9a72054a2b..b990194d15 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; @@ -392,8 +393,9 @@ final channelDetailsProvider = FutureProvider.family(( }); /// Channel members from kind:39002 NIP-29 members event. -final channelMembersProvider = - FutureProvider.family, String>((ref, channelId) async { +final channelMembersProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); final session = ref.watch(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.channelMembers(channelId), @@ -557,6 +559,7 @@ class ChannelActions { ); } _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future joinChannel(String channelId) async { @@ -626,6 +629,7 @@ class ChannelActions { await _ref.read(channelsProvider.notifier).refresh(); _ref.invalidate(channelDetailsProvider(channelId)); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); _ref.invalidate(channelCanvasProvider(channelId)); } @@ -659,6 +663,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future removeMember({ @@ -674,6 +679,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future addReaction(String eventId, String emoji) async { diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index c03087a85d..fbcbca8956 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; -import 'channel_management_provider.dart'; import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -222,11 +221,6 @@ class ChannelMessagesNotifier extends Notifier>> { _lastKnownMessages = merged; state = AsyncData(merged); } - - if (event.kind == EventKind.systemMessage && - _isMembershipEvent(event.content)) { - ref.invalidate(channelMembersProvider(channelId)); - } } void _handleWindowLiveEvent(NostrEvent event) { @@ -288,12 +282,6 @@ class ChannelMessagesNotifier extends Notifier>> { .confirm(eventIds); } - static bool _isMembershipEvent(String content) { - return content.contains('member_joined') || - content.contains('member_left') || - content.contains('member_removed'); - } - /// Adds a just-signed outgoing message before the relay acknowledges it. /// The live relay echo is deduplicated by event id. void addLocalMessage(NostrEvent event) { diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 1a9a02e409..7560f998f3 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -15,6 +15,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -36,6 +37,7 @@ import 'mentions/mention_ranking.dart'; import 'photo_library.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/agent_mention_labels.dart'; part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; @@ -231,6 +233,16 @@ class ComposeBar extends HookConsumerWidget { // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + final agentMentionLabels = _agentMentionLabels( + candidates: mentionMap.value.values, + ); + final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( + '\u0000', + ); + useEffect(() { + controller.setAgentMentionNames(agentMentionLabels); + return null; + }, [controller, agentMentionLabelsKey]); useEffect( () { final memberList = membersAsync.asData?.value ?? []; diff --git a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart new file mode 100644 index 0000000000..bc29d5bb46 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart @@ -0,0 +1,10 @@ +part of '../compose_bar.dart'; + +Set _agentMentionLabels({ + required Iterable candidates, +}) { + return { + for (final candidate in candidates) + if (candidate.isAgent) candidate.label, + }; +} diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 29e22a8ca9..bbca57037a 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -15,6 +15,8 @@ class _MarkdownRule { } class _MarkdownEditingController extends TextEditingController { + final Set _agentMentionNames = {}; + static final _rules = [ _MarkdownRule( r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```', @@ -31,6 +33,21 @@ class _MarkdownEditingController extends TextEditingController { _MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic), ]; + /// Updates the known agent labels which should render as agent mention + /// chips. The editor still stores the literal `@Name` text, matching the + /// markdown sent to the relay. + void setAgentMentionNames(Iterable names) { + final next = { + for (final name in names) + if (name.trim().isNotEmpty) name.trim().toLowerCase(), + }; + if (setEquals(_agentMentionNames, next)) return; + _agentMentionNames + ..clear() + ..addAll(next); + notifyListeners(); + } + @override TextSpan buildTextSpan({ required BuildContext context, @@ -81,6 +98,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextRule == null || nextMatch == null) { spans.addAll( _buildTextSpans( + context, source.substring(offset), inheritedStyle, sourceOffset + offset, @@ -93,6 +111,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextMatch.start > 0) { spans.addAll( _buildTextSpans( + context, tail.substring(0, nextMatch.start), inheritedStyle, sourceOffset + offset, @@ -129,10 +148,12 @@ class _MarkdownEditingController extends TextEditingController { } else { spans.addAll( _buildTextSpans( + context, content, contentStyle, matchOffset + contentStart, composingRange, + renderAgentMentions: false, ), ); } @@ -150,11 +171,18 @@ class _MarkdownEditingController extends TextEditingController { } List _buildTextSpans( + BuildContext context, String source, TextStyle style, int sourceOffset, - TextRange composingRange, - ) { + TextRange composingRange, { + bool renderAgentMentions = true, + }) { + List buildTextSegment(String text, TextStyle segmentStyle) => + renderAgentMentions + ? _buildAgentMentionSpans(context, text, segmentStyle) + : [TextSpan(text: text, style: segmentStyle)]; + if (source.isEmpty) return const []; final localStart = (composingRange.start - sourceOffset) .clamp(0, source.length) @@ -165,7 +193,7 @@ class _MarkdownEditingController extends TextEditingController { if (!composingRange.isValid || composingRange.isCollapsed || localStart >= localEnd) { - return [TextSpan(text: source, style: style)]; + return buildTextSegment(source, style); } final composingDecorations = [ @@ -178,16 +206,80 @@ class _MarkdownEditingController extends TextEditingController { ); return [ if (localStart > 0) - TextSpan(text: source.substring(0, localStart), style: style), + ...buildTextSegment(source.substring(0, localStart), style), TextSpan( text: source.substring(localStart, localEnd), style: composingStyle, ), if (localEnd < source.length) - TextSpan(text: source.substring(localEnd), style: style), + ...buildTextSegment(source.substring(localEnd), style), ]; } + List _buildAgentMentionSpans( + BuildContext context, + String source, + TextStyle style, + ) { + if (_agentMentionNames.isEmpty) { + return [TextSpan(text: source, style: style)]; + } + + final escapedNames = _agentMentionNames.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + final expression = RegExp( + r'(^|\s)@(' + + escapedNames.map(RegExp.escape).join('|') + + r')(?=\s|[,.!?:;)\]}*_]|$)', + caseSensitive: false, + multiLine: true, + ); + final spans = []; + var offset = 0; + for (final match in expression.allMatches(source)) { + final prefix = match.group(1)!; + if (match.start > offset) { + spans.add( + TextSpan(text: source.substring(offset, match.start), style: style), + ); + } + if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); + + final label = match.group(2)!; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _ComposerAgentMentionChip(label: label, textStyle: style), + ), + ); + // The visual chip replaces the `@` placeholder. Keep the label as + // invisible source text so the text span still has one character per + // source character, preserving native cursor and deletion behavior. + spans.add( + TextSpan( + text: label, + semanticsLabel: '', + style: _hiddenMentionTextStyle(style), + ), + ); + offset = match.end; + } + if (offset < source.length) { + spans.add(TextSpan(text: source.substring(offset), style: style)); + } + return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans; + } + + TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) => + inheritedStyle.copyWith( + color: Colors.transparent, + fontSize: 0.01, + height: 0.01, + letterSpacing: 0, + decoration: TextDecoration.none, + ); + (int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) { final delimiterLength = switch (markdownStyle) { _MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2, @@ -247,3 +339,60 @@ class _MarkdownEditingController extends TextEditingController { ); } } + +class _ComposerAgentMentionChip extends StatelessWidget { + final String label; + final TextStyle textStyle; + + const _ComposerAgentMentionChip({ + required this.label, + required this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = textStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + + return Semantics( + label: 'Agent mention: $label', + excludeSemantics: true, + child: Container( + key: const ValueKey('composer-agent-mention-chip'), + padding: const EdgeInsets.fromLTRB( + Grid.half, + Grid.quarter + 1, + Grid.half, + Grid.quarter, + ), + decoration: BoxDecoration( + // The composer surface is already tinted, so the body chip's + // low-opacity fill disappears here. Keep the same chip geometry + // while giving this editable token enough contrast to read as one. + color: context.colors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + LucideIcons.bot, + size: fontSize * 0.95, + color: context.colors.primary, + ), + const SizedBox(width: Grid.quarter), + Text(label, style: style), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index ca97d65e34..9c4ef96bbe 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -1,59 +1,8 @@ -import 'dart:convert'; - -import '../../../shared/relay/nostr_models.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../profile/user_profile.dart'; import '../channel_management_provider.dart'; import 'mention_ranking.dart'; -/// A relay agent parsed from its kind:10100 agent-profile event. -/// -/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility -/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which -/// channels it sits in. -class AgentDirectoryEntry { - final String pubkey; - final String? displayName; - final String? respondTo; - final List respondToAllowlist; - final List channelIds; - - const AgentDirectoryEntry({ - required this.pubkey, - this.displayName, - this.respondTo, - this.respondToAllowlist = const [], - this.channelIds = const [], - }); - - factory AgentDirectoryEntry.fromEvent(NostrEvent event) { - final content = _tryDecodeJsonMap(event.content); - return AgentDirectoryEntry( - pubkey: event.pubkey.toLowerCase(), - displayName: - (content?['display_name'] as String?) ?? - (content?['name'] as String?), - respondTo: content?['respond_to'] as String?, - respondToAllowlist: [ - for (final value in (content?['respond_to_allowlist'] as List?) ?? []) - if (value is String) value.toLowerCase(), - ], - channelIds: [ - for (final value in (content?['channel_ids'] as List?) ?? []) - if (value is String) value, - ], - ); - } -} - -Map? _tryDecodeJsonMap(String content) { - try { - final decoded = jsonDecode(content); - return decoded is Map ? decoded : null; - } catch (_) { - return null; - } -} - /// Whether a non-member relay agent should be mentionable by the current /// user. Mirrors desktop's `relayAgentIsSharedWithUser`: /// - allowlist mode: user must be on the allowlist diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index c2aa056a05..6e94459231 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/crypto/nip_oa.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../profile/user_cache_provider.dart'; import '../../profile/user_profile.dart'; @@ -10,64 +11,6 @@ import '../channels_provider.dart'; import 'mention_candidates.dart'; import 'mention_ranking.dart'; -/// Relay agent directory from kind:10100 agent-profile events. -/// -/// Watches the session and only fetches after the WebSocket connects. -final agentDirectoryProvider = FutureProvider>(( - ref, -) async { - final sessionState = ref.watch(relaySessionProvider); - if (sessionState.status != SessionStatus.connected) return const []; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory(NostrFilters.agentProfiles()); - return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; -}); - -/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 -/// profiles. An entry exists only when the `auth` tag verifies — mirrors -/// desktop's `profile_valid_oa_owner_pubkey`. -final agentOwnersProvider = FutureProvider>((ref) async { - final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), - ); - final owners = {}; - for (final event in events) { - final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; - } - return owners; -}); - -/// Pubkeys currently known to represent agents for rendered mention chips. -/// -/// Uses the same three identity sources as mention autocomplete: channel bot -/// roles, relay agent-directory entries, and verified NIP-OA ownership. -final mentionAgentPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final members = - ref.watch(channelMembersProvider(channelId)).asData?.value ?? - const []; - final relayAgents = - ref.watch(agentDirectoryProvider).asData?.value ?? - const []; - final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; - final userCache = ref.watch(userCacheProvider); - - return { - for (final member in members) - if (member.isBot) member.pubkey.toLowerCase(), - for (final agent in relayAgents) agent.pubkey.toLowerCase(), - ...owners.keys.map((pubkey) => pubkey.toLowerCase()), - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(), - }; -}); - /// Debounce before a mention query hits the relay search endpoint. const _mentionSearchDebounce = Duration(milliseconds: 250); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index ab992a4314..465585d966 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -145,6 +145,10 @@ class MessageContent extends HookConsumerWidget { final baseTextStyle = baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + final resolvedMentionNames = mentionNames; + final resolvedAgentMentionPubkeys = { + ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -154,6 +158,13 @@ class MessageContent extends HookConsumerWidget { customEmojiFromTags(tags), ref.watch(customEmojiListProvider), ); + final mentionPresentationKey = [ + for (final entry + in (resolvedMentionNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ...(resolvedAgentMentionPubkeys.toList()..sort()), + ].join('\u0001'); // Decided here rather than by the caller: this is where the event's own // emoji tags and the community palette have already been merged, and a @@ -220,7 +231,7 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; - for (final name in mentionNames.values) { + for (final name in resolvedMentionNames.values) { if (name.contains(' ')) { final normalizedName = _markdownMentionName(name); segment = segment.replaceAllMapped( @@ -241,29 +252,35 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [markdownContent, mentionNames]); - - final markdown = GptMarkdown( - finalContent, - style: style, - followLinkColor: false, - codeBuilder: (context, name, code, closed) => - _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), - imageBuilder: (context, imageUrl) => - _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), - maxLines: maxLines, - inlineComponents: [ - _MentionMd( - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - onMentionTap: onMentionTap, - ), - CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), - _ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap), - ...MarkdownComponent.inlineComponents, - ], + }, [markdownContent, resolvedMentionNames]); + + final markdown = KeyedSubtree( + key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + child: GptMarkdown( + finalContent, + style: style, + followLinkColor: false, + codeBuilder: (context, name, code, closed) => + _MessageCodeBlock(name: name, code: code), + linkBuilder: (context, linkText, url, linkStyle) => + _buildLink(context, ref, linkText, url, linkStyle, style), + imageBuilder: (context, imageUrl) => + _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + maxLines: maxLines, + inlineComponents: [ + _MentionMd( + mentionNames: resolvedMentionNames, + agentMentionPubkeys: resolvedAgentMentionPubkeys, + onMentionTap: onMentionTap, + ), + CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), + _ChannelLinkMd( + channelNames: channelNames, + onChannelTap: onChannelTap, + ), + ...MarkdownComponent.inlineComponents, + ], + ), ); if (trailingGallery == null) return markdown; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 5d28214b01..810861aa00 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -24,7 +25,6 @@ import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'reaction_row.dart'; import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; @@ -607,7 +607,13 @@ class _ThreadMessage extends ConsumerWidget { profile?.ownerPubkey == currentPubkey?.toLowerCase()); final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); final mentionNames = {}; final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { @@ -620,6 +626,12 @@ class _ThreadMessage extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -725,7 +737,7 @@ class _ThreadMessage extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 8666919a4a..ddc36a1d4a 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../channels/message_content.dart'; @@ -15,7 +17,7 @@ import 'forum_models.dart'; /// /// Long-press opens an action sheet (copy, delete) matching the stream /// message pattern from channel_detail_page.dart. -class ForumPostCard extends ConsumerWidget { +class ForumPostCard extends HookConsumerWidget { final ForumPost post; final String? currentPubkey; final VoidCallback onTap; @@ -31,16 +33,57 @@ class ForumPostCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mentionPubkeys = useMemoized( + () => + post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet() + ..remove(post.pubkey.toLowerCase()), + [post], + ); + final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000'); + + useEffect(() { + if (mentionPubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList()); + } + return null; + }, [mentionPubkeysKey]); + final pk = post.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? _shortPubkey(post.pubkey); - final mentionNames = ref.watch( + final profileMentionNames = ref.watch( userCacheProvider.select( (cache) => _buildMentionNames(post.mentionPubkeys, cache), ), ); + final profileOwnedMentionPubkeys = ref.watch( + userCacheProvider.select( + (cache) => + (post.mentionPubkeys + .where( + (pubkey) => + cache[pubkey.toLowerCase()]?.ownerPubkey != null, + ) + .map((pubkey) => pubkey.toLowerCase()) + .toList() + ..sort()) + .join('\u0000'), + ), + ); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty + ? const [] + : profileOwnedMentionPubkeys.split('\u0000'), + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final preview = post.content.length > 200 ? '${post.content.substring(0, 200)}...' : post.content; @@ -128,6 +171,7 @@ class ForumPostCard extends ConsumerWidget { child: MessageContent( content: preview, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index d2e8490d61..68d2562f68 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -209,21 +210,27 @@ class _ThreadContent extends HookConsumerWidget { final post = thread.post; final replies = thread.replies; - // Preload profiles for all participants. + // Preload profiles for all participants and tagged mentions. final allPubkeys = useMemoized(() { - final pks = {post.pubkey}; + final pks = { + post.pubkey.toLowerCase(), + ...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; for (final reply in replies) { - pks.add(reply.pubkey); + pks + ..add(reply.pubkey.toLowerCase()) + ..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase())); } - return pks.toList(); + return pks.toList()..sort(); }, [post, replies]); + final allPubkeysKey = allPubkeys.join('\u0000'); useEffect(() { if (allPubkeys.isNotEmpty) { ref.read(userCacheProvider.notifier).preload(allPubkeys); } return null; - }, [allPubkeys]); + }, [allPubkeysKey]); return Column( children: [ @@ -322,7 +329,19 @@ class _OriginalPost extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(post.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.all(Grid.xs), @@ -375,6 +394,7 @@ class _OriginalPost extends ConsumerWidget { MessageContent( content: post.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, @@ -409,7 +429,19 @@ class _ReplyRow extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(reply.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: reply.mentionPubkeys, + profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.symmetric( @@ -481,6 +513,7 @@ class _ReplyRow extends ConsumerWidget { child: MessageContent( content: reply.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: reply.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b608b65aef..65fc12dfd9 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,6 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -597,7 +599,7 @@ class _PeopleSection extends ConsumerWidget { } } -class _MessagesSection extends ConsumerWidget { +class _MessagesSection extends HookConsumerWidget { final List hits; final String? currentPubkey; final VoidCallback onResultSelected; @@ -614,8 +616,15 @@ class _MessagesSection extends ConsumerWidget { final channels = ref.watch(channelsProvider).value ?? []; // Preload author profiles. - final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final hit in hits) hit.pubkey.toLowerCase(), + for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -635,7 +644,7 @@ class _MessagesSection extends ConsumerWidget { } } -class _MessageTile extends StatelessWidget { +class _MessageTile extends ConsumerWidget { final SearchHit hit; final UserProfile? authorProfile; final Map userCache; @@ -653,12 +662,34 @@ class _MessageTile extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); final timeAgo = relativeTime(hit.createdAt); final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; final isDm = channel?.isDm ?? false; + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(hit.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return ListTile( key: ValueKey('search-message-row-${hit.eventId}'), @@ -730,6 +761,8 @@ class _MessageTile extends StatelessWidget { MessageContent( key: ValueKey('search-message-body-${hit.eventId}'), content: hit.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: hit.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart new file mode 100644 index 0000000000..ea6a2ee50f --- /dev/null +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -0,0 +1,274 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/crypto/nip_oa.dart'; +import '../../shared/relay/relay.dart'; + +/// A relay agent parsed from its kind:10100 agent-profile event. +/// +/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility +/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which +/// channels it sits in. +class AgentDirectoryEntry { + final String pubkey; + final String? displayName; + final String? respondTo; + final List respondToAllowlist; + final List channelIds; + + const AgentDirectoryEntry({ + required this.pubkey, + this.displayName, + this.respondTo, + this.respondToAllowlist = const [], + this.channelIds = const [], + }); + + factory AgentDirectoryEntry.fromEvent(NostrEvent event) { + final content = _tryDecodeJsonMap(event.content); + return AgentDirectoryEntry( + pubkey: event.pubkey.toLowerCase(), + displayName: + (content?['display_name'] as String?) ?? + (content?['name'] as String?), + respondTo: content?['respond_to'] as String?, + respondToAllowlist: [ + for (final value in (content?['respond_to_allowlist'] as List?) ?? []) + if (value is String) value.toLowerCase(), + ], + channelIds: [ + for (final value in (content?['channel_ids'] as List?) ?? []) + if (value is String) value, + ], + ); + } +} + +Map? _tryDecodeJsonMap(String content) { + try { + final decoded = jsonDecode(content); + return decoded is Map ? decoded : null; + } catch (_) { + return null; + } +} + +/// Relay agent directory from kind:10100 agent-profile events. +/// +/// Watches the session and only fetches after the WebSocket connects. +final agentDirectoryProvider = FutureProvider>(( + ref, +) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const []; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory(NostrFilters.agentProfiles()); + return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; +}); + +/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 +/// profiles. An entry exists only when the `auth` tag verifies — mirrors +/// desktop's `profile_valid_oa_owner_pubkey`. +final agentOwnersProvider = FutureProvider>((ref) async { + final agents = await ref.watch(agentDirectoryProvider.future); + if (agents.isEmpty) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), + ); + final owners = {}; + for (final event in events) { + final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + if (owner != null) owners[event.pubkey.toLowerCase()] = owner; + } + return owners; +}); + +/// Pubkeys currently known to represent agents across the active relay. +/// +/// Message surfaces that do not own channel membership can use this shared +/// identity source; channel features add their bot roles separately. +final knownAgentPubkeysProvider = Provider>((ref) { + final relayAgents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + return _AgentPubkeySet({ + for (final agent in relayAgents) agent.pubkey.toLowerCase(), + ...owners.keys.map((pubkey) => pubkey.toLowerCase()), + }); +}); + +/// Directory display names keyed by agent pubkey for mention presentation. +final agentDirectoryDisplayNamesProvider = Provider>((ref) { + final agents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + return Map.unmodifiable({ + for (final agent in agents) + if (agent.displayName?.trim().isNotEmpty == true) + agent.pubkey.toLowerCase(): agent.displayName!.trim(), + }); +}); + +/// Adds channel bot roles to relay-wide agent identities. +Set agentPubkeysWithChannelBots({ + required Set knownAgentPubkeys, + required Iterable channelBotPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Adds agent identities derived from locally cached verified profiles. +Set agentPubkeysWithProfileOwners({ + required Set knownAgentPubkeys, + required Iterable profileOwnedAgentPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Preserves profile labels while filling missing agent mentions from the +/// relay's agent directory. +Map mentionNamesWithDirectoryLabels({ + required Iterable mentionPubkeys, + required Map profileMentionNames, + required Map directoryDisplayNames, + required Set agentMentionPubkeys, +}) { + final names = Map.from(profileMentionNames); + for (final pubkey in mentionPubkeys) { + final normalizedPubkey = pubkey.toLowerCase(); + if (names[normalizedPubkey]?.trim().isEmpty == true) { + names.remove(normalizedPubkey); + } + final directoryName = directoryDisplayNames[normalizedPubkey]; + if (!names.containsKey(normalizedPubkey) && directoryName != null) { + names[normalizedPubkey] = directoryName; + } + if (!names.containsKey(normalizedPubkey) && + agentMentionPubkeys.contains(normalizedPubkey)) { + names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey); + } + } + return names; +} + +String _agentFallbackLabel(String pubkey) => + pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; + +/// Keeps the role feed alive for consumers that render mentions outside the +/// channel timeline, such as search results. A membership change refreshes the +/// shared bot-role lookup below, regardless of which surface owns the channel. +class _ChannelBotRoleSubscription extends Notifier { + final String channelId; + void Function()? _unsubscribe; + int _subscriptionVersion = 0; + + _ChannelBotRoleSubscription(this.channelId); + + @override + int build() { + final sessionState = ref.watch(relaySessionProvider); + final subscriptionVersion = ++_subscriptionVersion; + _clearSubscription(); + ref.onDispose(() { + _subscriptionVersion++; + _clearSubscription(); + }); + + if (sessionState.status != SessionStatus.connected) return 0; + Future.microtask(() => _subscribe(channelId, subscriptionVersion)); + return 0; + } + + Future _subscribe(String channelId, int subscriptionVersion) async { + final session = ref.read(relaySessionProvider.notifier); + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: const [39002], + tags: { + '#h': [channelId], + }, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), + (_) { + if (_isCurrent(subscriptionVersion)) { + state++; + } + }, + ); + if (!_isCurrent(subscriptionVersion)) { + unsubscribe(); + return; + } + _unsubscribe = unsubscribe; + } catch (error) { + if (_isCurrent(subscriptionVersion)) { + debugPrint( + '[ChannelBotRoleSubscription] failed for $channelId: $error', + ); + } + } + } + + bool _isCurrent(int subscriptionVersion) => + subscriptionVersion == _subscriptionVersion; + + void _clearSubscription() { + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +/// Monotonically increments when the channel's kind:39002 membership snapshot +/// changes. Channel-member and agent-role views share this source so remote +/// membership updates refresh both snapshots together. +final channelMembershipUpdateProvider = NotifierProvider.autoDispose + .family<_ChannelBotRoleSubscription, int, String>( + _ChannelBotRoleSubscription.new, + ); + +/// Bot pubkeys currently assigned a channel bot role. +final channelBotPubkeysProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.channelMembers(channelId), + ); + if (events.isEmpty) return const {}; + return _AgentPubkeySet({ + for (final member in membersFromEvent(events.first)) + if (member.role == 'bot') member.pubkey.toLowerCase(), + }); + }); + +/// Pubkeys currently known to represent agents in a channel. +final agentMentionPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final channelBotPubkeys = + ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ?? + const {}; + return agentPubkeysWithChannelBots( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + channelBotPubkeys: channelBotPubkeys, + ); + }); + +class _AgentPubkeySet extends UnmodifiableSetView { + _AgentPubkeySet(Iterable pubkeys) : super(Set.unmodifiable(pubkeys)); + + @override + bool operator ==(Object other) => + other is Set && length == other.length && every(other.contains); + + @override + int get hashCode => Object.hashAllUnordered(this); +} diff --git a/mobile/lib/shared/mentions/mention_tags.dart b/mobile/lib/shared/mentions/mention_tags.dart new file mode 100644 index 0000000000..bf21282715 --- /dev/null +++ b/mobile/lib/shared/mentions/mention_tags.dart @@ -0,0 +1,6 @@ +/// Pubkeys tagged as message mentions, normalized for profile lookups. +Set mentionedPubkeysFromTags(Iterable> tags) => { + for (final tag in tags) + if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) + tag[1].toLowerCase(), +}; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e5ed0fb352..1c66093899 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -26,6 +26,7 @@ import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -188,6 +189,9 @@ Widget _buildTestable({ channelMembersProvider(_channelId).overrideWith( (ref) async => loadMembers != null ? loadMembers() : members, ), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => const {}), if (createChannelActions != null) channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 7b747adfac..919d4039eb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -17,11 +17,10 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -2269,6 +2268,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); @@ -2285,6 +2289,70 @@ void main() { ]); }); + testWidgets( + 'renders chips only for selected agents outside code and composition', + (tester) async { + final semantics = tester.ensureSemantics(); + final signer = nostr.Keys.generate(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayAgents: [_testAgent('f' * 64)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Helper Bot'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Agent mention: Helper Bot'), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Helper Bot'), findsNothing); + + await tester.enterText(find.byType(TextField), '`@Helper Bot`'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@Helper Bot typing'); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.value = textField.controller!.value.copyWith( + composing: const TextRange(start: 12, end: 18), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 250)); + semantics.dispose(); + }, + ); + testWidgets('does not mutate a DM when mentioning a non-member agent', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 3f2e8e3d6a..811996857c 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; final userPubkey = 'a' * 64; final memberPubkey = 'b' * 64; @@ -18,6 +19,20 @@ ChannelMember member(String pubkey, {String role = 'member'}) { } void main() { + test('role-only agent mentions fall back to a pubkey prefix label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {}, + directoryDisplayNames: const {}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'deadbeef'}, + ); + }); + group('agentIsSharedWithUser', () { test('anyone-mode agent is shared when a channel overlaps', () { final agent = AgentDirectoryEntry( diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index bc6772fd67..9d8adb69bc 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1204,6 +1204,43 @@ Photos expect(find.text('Alice'), findsOneWidget); }); + testWidgets('renders a known agent mention with the bot chip', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + maxLines: 2, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('@'), findsNothing); + expect(find.text('Helper Bot'), findsOneWidget); + }); + + testWidgets('normalizes passed multi-word agent mentions', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('Helper Bot'), findsOneWidget); + expect(_allRichText(tester), isNot(contains('Bot Bot'))); + }); + testWidgets('highlights an entire multi-word display name', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e4a576b91d..c3db833fc1 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -10,8 +10,10 @@ import 'package:buzz/features/search/recent_searches_provider.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; @@ -593,6 +595,72 @@ void main() { await tester.pump(); expect(recentSearches.searches, const ['design']); }); + + testWidgets('renders channel-role bots in message previews', (tester) async { + final channel = Channel( + id: 'channel-1', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + ); + const agentPubkey = 'agent-pubkey'; + const cachedProfile = UserProfile(pubkey: 'author-pubkey'); + final state = SearchState( + query: 'helper', + messageResults: [ + SearchHit( + eventId: 'message-1', + content: 'Ask @Helper Bot to investigate', + kind: 9, + pubkey: 'author-pubkey', + channelId: channel.id, + channelName: channel.name, + createdAt: 1, + score: 1, + tags: [ + ['p', agentPubkey], + ], + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier([channel])), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier(cachedProfile), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + channelBotPubkeysProvider( + channel.id, + ).overrideWith((ref) async => {agentPubkey}), + agentDirectoryDisplayNamesProvider.overrideWith( + (ref) => const {agentPubkey: 'Helper Bot'}, + ), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final content = tester.widget( + find.byKey(const ValueKey('search-message-body-message-1')), + ); + expect(content.mentionNames, const {agentPubkey: 'Helper Bot'}); + expect(content.agentMentionPubkeys, contains(agentPubkey)); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + }); } class _FakeSearchNotifier extends SearchNotifier { @@ -646,8 +714,12 @@ class _FakeProfileNotifier extends ProfileNotifier { } class _FakeChannelsNotifier extends ChannelsNotifier { + _FakeChannelsNotifier([this.channels = const []]); + + final List channels; + @override - Future> build() async => const []; + Future> build() async => channels; } class _FakeUserCacheNotifier extends UserCacheNotifier { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart new file mode 100644 index 0000000000..0ea0afb268 --- /dev/null +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test('refreshes channel bot roles from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect(await container.read(channelBotPubkeysProvider(_channelId).future), { + _agentPubkey, + }); + await relaySession.subscribed; + expect(relaySession.liveFilters.single.kinds, const [39002]); + expect(relaySession.liveFilters.single.tags['#h'], [_channelId]); + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + await container.read(channelBotPubkeysProvider(_channelId).future), + isEmpty, + ); + }); + + test('refreshes channel members from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembersProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'bot', + ); + await relaySession.subscribed; + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'member', + ); + }); + + test('disposes the live role subscription without consumers', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await container.read(channelBotPubkeysProvider(_channelId).future); + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }); + + test( + 'does not retain a live role subscription through working bots', + () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + workingBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }, + ); + + test('blank profile labels defer to the directory label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {pubkey: ' '}, + directoryDisplayNames: const {pubkey: 'Directory bot'}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'Directory bot'}, + ); + }); +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; +const _agentPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +NostrEvent _membershipEvent({required String role}) => NostrEvent( + id: 'membership-$role', + pubkey: 'owner', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _channelId], + ['h', _channelId], + ['p', _agentPubkey, 'wss://relay.example', role], + ], + content: '', + sig: 'sig', +); + +Future _pumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _MembershipRelaySessionNotifier extends RelaySessionNotifier { + final List _memberships; + final List liveFilters = []; + final List<_LiveSubscription> _subscriptions = []; + final Completer _subscribed = Completer(); + var unsubscribeCount = 0; + var _membershipIndex = 0; + + _MembershipRelaySessionNotifier(this._memberships); + + Future get subscribed => _subscribed.future; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + return [_memberships[_membershipIndex++]]; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + liveFilters.add(filter); + final subscription = _LiveSubscription(filter, onEvent); + _subscriptions.add(subscription); + if (!_subscribed.isCompleted) _subscribed.complete(); + return () { + unsubscribeCount++; + _subscriptions.remove(subscription); + }; + } + + void emit(NostrEvent event) { + for (final subscription in List.of(_subscriptions)) { + if (_matches(subscription.filter, event)) { + subscription.onEvent(event); + } + } + } +} + +class _LiveSubscription { + final NostrFilter filter; + final void Function(NostrEvent) onEvent; + + const _LiveSubscription(this.filter, this.onEvent); +} + +bool _matches(NostrFilter filter, NostrEvent event) { + if (!filter.kinds.contains(event.kind)) return false; + return filter.tags.entries.every((entry) { + final tagName = entry.key.substring(1); + return event.tags.any( + (tag) => + tag.isNotEmpty && + tag.first == tagName && + tag.skip(1).any(entry.value.contains), + ); + }); +}