Render mobile agent mention chips (#3702)

## Summary

- Render selected agent mentions as visible bot chips in the mobile
composer.
- Recognize agent profiles consistently when rendering message-body
mentions.
<img width="630" height="1368" alt="Screenshot 2026-07-30 at 07 54 16"
src="https://github.com/user-attachments/assets/035b46bf-ee78-4ee5-82fc-84591415ed7c"
/>

## Validation

- `flutter test test/features/channels/compose_bar_test.dart
test/features/channels/message_content_test.dart`
- `flutter analyze`

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-30 18:02:48 +01:00
committed by GitHub
parent 61b96c9828
commit 06582ee6f0
25 changed files with 1139 additions and 194 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<Set<String>, String>((
ref,
channelId,
) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final workingBotPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(),
};
});
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase()))
e.pubkey.toLowerCase(),
};
});

View File

@@ -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';

View File

@@ -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 = <String, String>{};
final agentMentionPubkeys = <String>{};
@@ -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,

View File

@@ -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<ChannelDetails, String>((
});
/// Channel members from kind:39002 NIP-29 members event.
final channelMembersProvider =
FutureProvider.family<List<ChannelMember>, String>((ref, channelId) async {
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, 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<void> 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<void> removeMember({
@@ -674,6 +679,7 @@ class ChannelActions {
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> addReaction(String eventId, String emoji) async {

View File

@@ -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<AsyncValue<List<NostrEvent>>> {
_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<AsyncValue<List<NostrEvent>>> {
.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) {

View File

@@ -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 ?? <ChannelMember>[];

View File

@@ -0,0 +1,10 @@
part of '../compose_bar.dart';
Set<String> _agentMentionLabels({
required Iterable<MentionCandidate> candidates,
}) {
return <String>{
for (final candidate in candidates)
if (candidate.isAgent) candidate.label,
};
}

View File

@@ -15,6 +15,8 @@ class _MarkdownRule {
}
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
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<String> 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<InlineSpan> _buildTextSpans(
BuildContext context,
String source,
TextStyle style,
int sourceOffset,
TextRange composingRange,
) {
TextRange composingRange, {
bool renderAgentMentions = true,
}) {
List<InlineSpan> 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<InlineSpan> _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 = <InlineSpan>[];
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),
],
),
),
);
}
}

View File

@@ -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<String> respondToAllowlist;
final List<String> 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<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? 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

View File

@@ -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<List<AgentDirectoryEntry>>((
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<Map<String, String>>((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 = <String, String>{};
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<Set<String>, String>((
ref,
channelId,
) {
final members =
ref.watch(channelMembersProvider(channelId)).asData?.value ??
const <ChannelMember>[];
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
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);

View File

@@ -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]);
}, [markdownContent, resolvedMentionNames]);
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,
],
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;

View File

@@ -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 = <String, String>{};
final agentMentionPubkeys = <String>{};
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,

View File

@@ -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 <String>[]
: 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,

View File

@@ -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 = <String>{post.pubkey};
final pks = <String>{
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,

View File

@@ -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<SearchHit> 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<String, UserProfile> 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(

View File

@@ -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<String> respondToAllowlist;
final List<String> 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<String, dynamic>? _tryDecodeJsonMap(String content) {
try {
final decoded = jsonDecode(content);
return decoded is Map<String, dynamic> ? 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<List<AgentDirectoryEntry>>((
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<Map<String, String>>((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 = <String, String>{};
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<Set<String>>((ref) {
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
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<Map<String, String>>((ref) {
final agents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
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<String> agentPubkeysWithChannelBots({
required Set<String> knownAgentPubkeys,
required Iterable<String> channelBotPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Adds agent identities derived from locally cached verified profiles.
Set<String> agentPubkeysWithProfileOwners({
required Set<String> knownAgentPubkeys,
required Iterable<String> profileOwnedAgentPubkeys,
}) => _AgentPubkeySet({
...knownAgentPubkeys,
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
});
/// Preserves profile labels while filling missing agent mentions from the
/// relay's agent directory.
Map<String, String> mentionNamesWithDirectoryLabels({
required Iterable<String> mentionPubkeys,
required Map<String, String> profileMentionNames,
required Map<String, String> directoryDisplayNames,
required Set<String> agentMentionPubkeys,
}) {
final names = Map<String, String>.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<int> {
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<void> _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<Set<String>, 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<Set<String>, String>((ref, channelId) {
final channelBotPubkeys =
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
const <String>{};
return agentPubkeysWithChannelBots(
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
channelBotPubkeys: channelBotPubkeys,
);
});
class _AgentPubkeySet extends UnmodifiableSetView<String> {
_AgentPubkeySet(Iterable<String> 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);
}

View File

@@ -0,0 +1,6 @@
/// Pubkeys tagged as message mentions, normalized for profile lookups.
Set<String> mentionedPubkeysFromTags(Iterable<List<String>> tags) => {
for (final tag in tags)
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention'))
tag[1].toLowerCase(),
};

View File

@@ -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 <String>{}),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)

View File

@@ -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 <List<String>>[],
}) 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<TextField>(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 {

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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<MessageContent>(
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<Channel> channels;
@override
Future<List<Channel>> build() async => const [];
Future<List<Channel>> build() async => channels;
}
class _FakeUserCacheNotifier extends UserCacheNotifier {

View File

@@ -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<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrEvent> _memberships;
final List<NostrFilter> liveFilters = [];
final List<_LiveSubscription> _subscriptions = [];
final Completer<void> _subscribed = Completer<void>();
var unsubscribeCount = 0;
var _membershipIndex = 0;
_MembershipRelaySessionNotifier(this._memberships);
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
return [_memberships[_membershipIndex++]];
}
@override
Future<void Function()> 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),
);
});
}