mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
274
mobile/lib/shared/mentions/agent_identity_provider.dart
Normal file
274
mobile/lib/shared/mentions/agent_identity_provider.dart
Normal 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);
|
||||
}
|
||||
6
mobile/lib/shared/mentions/mention_tags.dart
Normal file
6
mobile/lib/shared/mentions/mention_tags.dart
Normal 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(),
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
228
mobile/test/shared/mentions/agent_identity_provider_test.dart
Normal file
228
mobile/test/shared/mentions/agent_identity_provider_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user