mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
mobile: thread scroll-to-bottom and desktop-parity mention autocomplete (#1499)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -15,6 +15,9 @@ import 'channel.dart';
|
|||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
import 'channels_provider.dart';
|
import 'channels_provider.dart';
|
||||||
import 'emoji_picker.dart';
|
import 'emoji_picker.dart';
|
||||||
|
import 'mentions/mention_candidates.dart';
|
||||||
|
import 'mentions/mention_candidates_provider.dart';
|
||||||
|
import 'mentions/mention_ranking.dart';
|
||||||
|
|
||||||
part 'compose_bar/helpers.dart';
|
part 'compose_bar/helpers.dart';
|
||||||
part 'compose_bar/suggestions.dart';
|
part 'compose_bar/suggestions.dart';
|
||||||
@@ -84,17 +87,33 @@ class ComposeBar extends HookConsumerWidget {
|
|||||||
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
final membersAsync = ref.watch(channelMembersProvider(channelId));
|
||||||
final currentPubkey = ref.watch(currentPubkeyProvider);
|
final currentPubkey = ref.watch(currentPubkeyProvider);
|
||||||
final userCache = ref.watch(userCacheProvider);
|
final userCache = ref.watch(userCacheProvider);
|
||||||
|
final isDmChannel =
|
||||||
|
channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ??
|
||||||
|
false;
|
||||||
|
|
||||||
// Preload profiles for channel members so @mention suggestions show names.
|
// Preload profiles for channel members, mentionable agents, and their
|
||||||
useEffect(() {
|
// owners so @mention suggestions show names ("owned by …" included).
|
||||||
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
|
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
|
||||||
if (memberList.isNotEmpty) {
|
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
|
||||||
ref
|
useEffect(
|
||||||
.read(userCacheProvider.notifier)
|
() {
|
||||||
.preload(memberList.map((m) => m.pubkey).toList());
|
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
|
||||||
}
|
final pubkeys = [
|
||||||
return null;
|
...memberList.map((m) => m.pubkey),
|
||||||
}, [membersAsync.asData?.value.length]);
|
...?relayAgents?.map((a) => a.pubkey),
|
||||||
|
...?agentOwners?.values,
|
||||||
|
];
|
||||||
|
if (pubkeys.isNotEmpty) {
|
||||||
|
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
membersAsync.asData?.value.length,
|
||||||
|
relayAgents?.length,
|
||||||
|
agentOwners?.length,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Typing indicator broadcast — throttled to one event per 3 seconds.
|
// Typing indicator broadcast — throttled to one event per 3 seconds.
|
||||||
final lastTypingSentMs = useRef(0);
|
final lastTypingSentMs = useRef(0);
|
||||||
@@ -165,27 +184,37 @@ class ComposeBar extends HookConsumerWidget {
|
|||||||
return () => controller.removeListener(listener);
|
return () => controller.removeListener(listener);
|
||||||
}, [controller]);
|
}, [controller]);
|
||||||
|
|
||||||
// Filter channel members against the query.
|
// Ranked mention candidates (desktop-parity ordering + eligibility).
|
||||||
final members = membersAsync.asData?.value ?? <ChannelMember>[];
|
final suggestions = mentionQuery.value == null
|
||||||
final suggestions = _filterMembers(
|
? const <MentionCandidate>[]
|
||||||
members,
|
: ref
|
||||||
mentionQuery.value,
|
.watch(
|
||||||
currentPubkey,
|
mentionCandidatesProvider((
|
||||||
userCache,
|
channelId: channelId,
|
||||||
);
|
query: mentionQuery.value!,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.take(_mentionSuggestionLimit)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Resolve owner names for the visible "owned by …" subtitles.
|
||||||
|
useEffect(() {
|
||||||
|
final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey];
|
||||||
|
if (ownerPubkeys.isNotEmpty) {
|
||||||
|
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [suggestions.length, mentionQuery.value]);
|
||||||
|
|
||||||
// Filter channels against the query.
|
// Filter channels against the query.
|
||||||
final channels = channelsAsync.asData?.value ?? <Channel>[];
|
final channels = channelsAsync.asData?.value ?? <Channel>[];
|
||||||
final channelSuggestions = filterChannels(channels, channelQuery.value);
|
final channelSuggestions = filterChannels(channels, channelQuery.value);
|
||||||
|
|
||||||
// Insert a selected mention into the text field.
|
// Insert a selected mention into the text field.
|
||||||
void insertMention(ChannelMember member) {
|
void insertMention(MentionCandidate candidate) {
|
||||||
final cached = ref.read(userCacheProvider)[member.pubkey.toLowerCase()];
|
final name = candidate.label;
|
||||||
final name = cached?.displayName?.trim().isNotEmpty == true
|
|
||||||
? cached!.displayName!.trim()
|
|
||||||
: '${member.pubkey.substring(0, 8)}\u2026';
|
|
||||||
// Track the resolved pubkey so we can pass it at send time.
|
// Track the resolved pubkey so we can pass it at send time.
|
||||||
mentionMap.value[name] = member.pubkey;
|
mentionMap.value[name] = candidate.pubkey;
|
||||||
|
|
||||||
final start = mentionStartIdx.value.clamp(0, controller.text.length);
|
final start = mentionStartIdx.value.clamp(0, controller.text.length);
|
||||||
spliceAndMoveCursor(
|
spliceAndMoveCursor(
|
||||||
@@ -349,6 +378,7 @@ class ComposeBar extends HookConsumerWidget {
|
|||||||
suggestions: suggestions,
|
suggestions: suggestions,
|
||||||
userCache: userCache,
|
userCache: userCache,
|
||||||
currentPubkey: currentPubkey,
|
currentPubkey: currentPubkey,
|
||||||
|
isDmChannel: isDmChannel,
|
||||||
onSelect: insertMention,
|
onSelect: insertMention,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ part of '../compose_bar.dart';
|
|||||||
|
|
||||||
const _typingThrottleMs = 3000;
|
const _typingThrottleMs = 3000;
|
||||||
|
|
||||||
|
/// Cap on ranked mention suggestions shown — matches desktop's
|
||||||
|
/// `MENTION_SUGGESTION_LIMIT`.
|
||||||
|
const _mentionSuggestionLimit = 50;
|
||||||
|
|
||||||
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
|
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
|
||||||
/// word boundary. Returns the index of the trigger character, or `null` if none
|
/// word boundary. Returns the index of the trigger character, or `null` if none
|
||||||
/// is found.
|
/// is found.
|
||||||
|
|||||||
@@ -1,43 +1,17 @@
|
|||||||
part of '../compose_bar.dart';
|
part of '../compose_bar.dart';
|
||||||
|
|
||||||
List<ChannelMember> _filterMembers(
|
|
||||||
List<ChannelMember> members,
|
|
||||||
String? query,
|
|
||||||
String? currentPubkey,
|
|
||||||
Map<String, UserProfile> userCache,
|
|
||||||
) {
|
|
||||||
if (query == null) return const [];
|
|
||||||
final q = query.toLowerCase();
|
|
||||||
return members
|
|
||||||
.where(
|
|
||||||
(m) =>
|
|
||||||
currentPubkey == null ||
|
|
||||||
m.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
|
|
||||||
)
|
|
||||||
.where((m) {
|
|
||||||
if (q.isEmpty) return true;
|
|
||||||
final profile = userCache[m.pubkey.toLowerCase()];
|
|
||||||
final name = (profile?.displayName ?? m.displayName ?? '')
|
|
||||||
.toLowerCase();
|
|
||||||
final firstName = name.split(RegExp(r'\s+')).first;
|
|
||||||
return name.startsWith(q) ||
|
|
||||||
firstName.startsWith(q) ||
|
|
||||||
name.contains(q);
|
|
||||||
})
|
|
||||||
.take(6)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MentionSuggestions extends StatelessWidget {
|
class _MentionSuggestions extends StatelessWidget {
|
||||||
final List<ChannelMember> suggestions;
|
final List<MentionCandidate> suggestions;
|
||||||
final Map<String, UserProfile> userCache;
|
final Map<String, UserProfile> userCache;
|
||||||
final String? currentPubkey;
|
final String? currentPubkey;
|
||||||
final void Function(ChannelMember) onSelect;
|
final bool isDmChannel;
|
||||||
|
final void Function(MentionCandidate) onSelect;
|
||||||
|
|
||||||
const _MentionSuggestions({
|
const _MentionSuggestions({
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
required this.userCache,
|
required this.userCache,
|
||||||
required this.currentPubkey,
|
required this.currentPubkey,
|
||||||
|
required this.isDmChannel,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,17 +39,10 @@ class _MentionSuggestions extends StatelessWidget {
|
|||||||
itemCount: suggestions.length,
|
itemCount: suggestions.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
separatorBuilder: (_, _) => const SizedBox.shrink(),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = suggestions[index];
|
final candidate = suggestions[index];
|
||||||
final profile = userCache[member.pubkey.toLowerCase()];
|
final name = candidate.label;
|
||||||
final name = profile?.displayName?.trim().isNotEmpty == true
|
final avatarUrl =
|
||||||
? profile!.displayName!.trim()
|
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
|
||||||
: member.labelFor(currentPubkey);
|
|
||||||
final avatarUrl = profile?.avatarUrl;
|
|
||||||
final initial =
|
|
||||||
(profile?.displayName?.trim().isNotEmpty == true
|
|
||||||
? profile!.displayName!.trim()
|
|
||||||
: member.pubkey)[0]
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
@@ -88,7 +55,7 @@ class _MentionSuggestions extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
child: avatarUrl == null
|
child: avatarUrl == null
|
||||||
? Text(
|
? Text(
|
||||||
initial,
|
name[0].toUpperCase(),
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
color: context.colors.onPrimaryContainer,
|
color: context.colors.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
@@ -96,14 +63,14 @@ class _MentionSuggestions extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(name, style: context.textTheme.bodyMedium),
|
title: Text(name, style: context.textTheme.bodyMedium),
|
||||||
trailing: member.isBot
|
subtitle: _MentionSuggestionInfo.build(
|
||||||
? Icon(
|
context,
|
||||||
LucideIcons.bot,
|
candidate: candidate,
|
||||||
size: 14,
|
currentPubkey: currentPubkey,
|
||||||
color: context.colors.onSurfaceVariant,
|
isDmChannel: isDmChannel,
|
||||||
)
|
userCache: userCache,
|
||||||
: null,
|
),
|
||||||
onTap: () => onSelect(member),
|
onTap: () => onSelect(candidate),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -111,6 +78,78 @@ class _MentionSuggestions extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The secondary info line under a mention suggestion — mirrors desktop's
|
||||||
|
/// `MentionAutocomplete` subtitle: bot icon + "agent" (or an "admin" badge
|
||||||
|
/// for human admins), then "owned by …" / "not in channel".
|
||||||
|
abstract final class _MentionSuggestionInfo {
|
||||||
|
static Widget? build(
|
||||||
|
BuildContext context, {
|
||||||
|
required MentionCandidate candidate,
|
||||||
|
required String? currentPubkey,
|
||||||
|
required bool isDmChannel,
|
||||||
|
required Map<String, UserProfile> userCache,
|
||||||
|
}) {
|
||||||
|
final ownerLabel = candidate.isAgent
|
||||||
|
? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, userCache)
|
||||||
|
: null;
|
||||||
|
final notInChannel = !isDmChannel && !candidate.isMember;
|
||||||
|
final isAdmin = !candidate.isAgent && candidate.role == 'admin';
|
||||||
|
|
||||||
|
final String? detail;
|
||||||
|
if (ownerLabel != null && notInChannel) {
|
||||||
|
detail = 'owned by $ownerLabel \u00b7 not in channel';
|
||||||
|
} else if (ownerLabel != null) {
|
||||||
|
detail = 'owned by $ownerLabel';
|
||||||
|
} else if (notInChannel) {
|
||||||
|
detail = 'not in channel';
|
||||||
|
} else {
|
||||||
|
detail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.isAgent && !isAdmin && detail == null) return null;
|
||||||
|
|
||||||
|
final style = context.textTheme.labelSmall?.copyWith(
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (candidate.isAgent) ...[
|
||||||
|
Icon(
|
||||||
|
LucideIcons.bot,
|
||||||
|
size: 12,
|
||||||
|
color: context.colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Grid.half),
|
||||||
|
Text('agent', style: style),
|
||||||
|
] else if (isAdmin)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Grid.xxs,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colors.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(Radii.sm),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'admin',
|
||||||
|
style: style?.copyWith(
|
||||||
|
color: context.colors.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (detail != null) ...[
|
||||||
|
if (candidate.isAgent || isAdmin) const SizedBox(width: Grid.xxs),
|
||||||
|
Flexible(
|
||||||
|
child: Text(detail, style: style, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
List<Channel> filterChannels(List<Channel> channels, String? query) {
|
List<Channel> filterChannels(List<Channel> channels, String? query) {
|
||||||
if (query == null) return const [];
|
if (query == null) return const [];
|
||||||
|
|||||||
153
mobile/lib/features/channels/mentions/mention_candidates.dart
Normal file
153
mobile/lib/features/channels/mentions/mention_candidates.dart
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../../shared/relay/nostr_models.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
|
||||||
|
/// - anyone mode: agent must share at least one channel with the user
|
||||||
|
bool agentIsSharedWithUser(
|
||||||
|
AgentDirectoryEntry agent,
|
||||||
|
Set<String> sharedChannelIds,
|
||||||
|
String? currentPubkey,
|
||||||
|
) {
|
||||||
|
if (agent.respondTo == 'allowlist' && currentPubkey != null) {
|
||||||
|
return agent.respondToAllowlist.contains(currentPubkey.toLowerCase());
|
||||||
|
}
|
||||||
|
return agent.respondTo == 'anyone' &&
|
||||||
|
agent.channelIds.any(sharedChannelIds.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the "owned by …" label. Mirrors desktop's `formatOwnerLabel`.
|
||||||
|
String? formatOwnerLabel(
|
||||||
|
String? ownerPubkey,
|
||||||
|
String? currentPubkey,
|
||||||
|
Map<String, UserProfile> userCache,
|
||||||
|
) {
|
||||||
|
if (ownerPubkey == null) return null;
|
||||||
|
final owner = ownerPubkey.toLowerCase();
|
||||||
|
if (currentPubkey != null && owner == currentPubkey.toLowerCase()) {
|
||||||
|
return 'you';
|
||||||
|
}
|
||||||
|
final profile = userCache[owner];
|
||||||
|
final name = profile?.displayName?.trim();
|
||||||
|
if (name != null && name.isNotEmpty) return name;
|
||||||
|
final handle = profile?.nip05Handle?.trim();
|
||||||
|
if (handle != null && handle.isNotEmpty) return handle;
|
||||||
|
return '${ownerPubkey.substring(0, 8)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble the full mention candidate list: channel members first-class,
|
||||||
|
/// then eligible non-member relay agents. Mirrors desktop's `useMentions`
|
||||||
|
/// candidate assembly (minus personas and global people search, which are
|
||||||
|
/// desktop-only surfaces).
|
||||||
|
List<MentionCandidate> buildMentionCandidates({
|
||||||
|
required List<ChannelMember> members,
|
||||||
|
required List<AgentDirectoryEntry> relayAgents,
|
||||||
|
required Set<String> sharedChannelIds,
|
||||||
|
required Map<String, UserProfile> userCache,
|
||||||
|
required Map<String, String> ownerByAgentPubkey,
|
||||||
|
String? currentPubkey,
|
||||||
|
}) {
|
||||||
|
final candidates = <MentionCandidate>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
|
||||||
|
for (final member in members) {
|
||||||
|
final pk = member.pubkey.toLowerCase();
|
||||||
|
if (!seen.add(pk)) continue;
|
||||||
|
final profile = userCache[pk];
|
||||||
|
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
|
||||||
|
final isAgent = member.isBot || ownerPubkey != null;
|
||||||
|
candidates.add(
|
||||||
|
MentionCandidate(
|
||||||
|
pubkey: pk,
|
||||||
|
displayName: profile?.displayName?.trim().isNotEmpty == true
|
||||||
|
? profile!.displayName!.trim()
|
||||||
|
: member.displayName,
|
||||||
|
secondaryLabel: profile?.nip05Handle,
|
||||||
|
avatarUrl: profile?.avatarUrl,
|
||||||
|
isAgent: isAgent,
|
||||||
|
isMember: true,
|
||||||
|
role: member.role,
|
||||||
|
ownerPubkey: ownerPubkey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final agent in relayAgents) {
|
||||||
|
final pk = agent.pubkey;
|
||||||
|
if (seen.contains(pk)) continue;
|
||||||
|
if (!agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(pk);
|
||||||
|
final profile = userCache[pk];
|
||||||
|
candidates.add(
|
||||||
|
MentionCandidate(
|
||||||
|
pubkey: pk,
|
||||||
|
displayName: profile?.displayName?.trim().isNotEmpty == true
|
||||||
|
? profile!.displayName!.trim()
|
||||||
|
: agent.displayName,
|
||||||
|
secondaryLabel: profile?.nip05Handle,
|
||||||
|
avatarUrl: profile?.avatarUrl,
|
||||||
|
isAgent: true,
|
||||||
|
isMember: false,
|
||||||
|
ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../shared/crypto/nip_oa.dart';
|
||||||
|
import '../../../shared/relay/relay.dart';
|
||||||
|
import '../../profile/user_cache_provider.dart';
|
||||||
|
import '../channel.dart';
|
||||||
|
import '../channel_management_provider.dart';
|
||||||
|
import '../channels_provider.dart';
|
||||||
|
import 'mention_candidates.dart';
|
||||||
|
import 'mention_ranking.dart';
|
||||||
|
|
||||||
|
/// Relay agent directory from kind:10100 agent-profile events.
|
||||||
|
///
|
||||||
|
/// Watches the session so it re-fetches once connected (a fetch fired
|
||||||
|
/// before the WS is up resolves empty on timeout).
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Ranked mention candidates for a channel + query. Channel members first,
|
||||||
|
/// then non-member relay agents the user can actually reach; ordering
|
||||||
|
/// matches desktop's `rankMentionCandidates`.
|
||||||
|
final mentionCandidatesProvider = Provider.family
|
||||||
|
.autoDispose<List<MentionCandidate>, ({String channelId, String query})>((
|
||||||
|
ref,
|
||||||
|
args,
|
||||||
|
) {
|
||||||
|
final members =
|
||||||
|
ref.watch(channelMembersProvider(args.channelId)).asData?.value ??
|
||||||
|
const <ChannelMember>[];
|
||||||
|
final relayAgents =
|
||||||
|
ref.watch(agentDirectoryProvider).asData?.value ??
|
||||||
|
const <AgentDirectoryEntry>[];
|
||||||
|
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
|
||||||
|
final channels =
|
||||||
|
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
|
||||||
|
final userCache = ref.watch(userCacheProvider);
|
||||||
|
final currentPubkey = ref.watch(currentPubkeyProvider);
|
||||||
|
|
||||||
|
final sharedChannelIds = {
|
||||||
|
for (final channel in channels)
|
||||||
|
if (channel.isMember && !channel.isArchived) channel.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
final candidates = buildMentionCandidates(
|
||||||
|
members: members,
|
||||||
|
relayAgents: relayAgents,
|
||||||
|
sharedChannelIds: sharedChannelIds,
|
||||||
|
userCache: userCache,
|
||||||
|
ownerByAgentPubkey: owners,
|
||||||
|
currentPubkey: currentPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rankMentionCandidates(candidates, args.query);
|
||||||
|
});
|
||||||
104
mobile/lib/features/channels/mentions/mention_ranking.dart
Normal file
104
mobile/lib/features/channels/mentions/mention_ranking.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// A mention autocomplete candidate. Mirrors the desktop's
|
||||||
|
/// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts).
|
||||||
|
@immutable
|
||||||
|
class MentionCandidate {
|
||||||
|
final String pubkey;
|
||||||
|
final String? displayName;
|
||||||
|
final String? secondaryLabel;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final bool isAgent;
|
||||||
|
final bool isMember;
|
||||||
|
final String? role;
|
||||||
|
final String? ownerPubkey;
|
||||||
|
|
||||||
|
const MentionCandidate({
|
||||||
|
required this.pubkey,
|
||||||
|
this.displayName,
|
||||||
|
this.secondaryLabel,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.isAgent = false,
|
||||||
|
this.isMember = false,
|
||||||
|
this.role,
|
||||||
|
this.ownerPubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
final name = displayName?.trim();
|
||||||
|
if (name != null && name.isNotEmpty) return name;
|
||||||
|
return pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group rank: channel members, then people, then other agents.
|
||||||
|
/// Mirrors desktop's `getMentionCandidateGroupRank` (personas are a
|
||||||
|
/// desktop-only concept; their slot between members and people is unused
|
||||||
|
/// here so numbering stays aligned).
|
||||||
|
int _groupRank(MentionCandidate candidate) {
|
||||||
|
if (candidate.isMember) return 0;
|
||||||
|
if (!candidate.isAgent) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match-quality score for one label. Lower is better; null means no match.
|
||||||
|
/// Mirrors desktop's `scoreMentionCandidateLabel`.
|
||||||
|
int? _scoreLabel(String label, String lowerQuery) {
|
||||||
|
final lower = label.toLowerCase();
|
||||||
|
if (lower == lowerQuery) return 0;
|
||||||
|
if (lower.startsWith(lowerQuery)) return 1;
|
||||||
|
|
||||||
|
final words = lower.split(RegExp(r'[\s\-_]+')).where((w) => w.isNotEmpty);
|
||||||
|
if (words.any((word) => word == lowerQuery)) return 2;
|
||||||
|
if (words.any((word) => word.startsWith(lowerQuery))) return 3;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank candidates for a mention query. Mirrors desktop's
|
||||||
|
/// `rankMentionCandidates`: sort by group, then match quality, then the
|
||||||
|
/// stable original order.
|
||||||
|
List<MentionCandidate> rankMentionCandidates(
|
||||||
|
List<MentionCandidate> candidates,
|
||||||
|
String query,
|
||||||
|
) {
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
final ranked = <(MentionCandidate, int, int, int)>[];
|
||||||
|
for (var order = 0; order < candidates.length; order++) {
|
||||||
|
final candidate = candidates[order];
|
||||||
|
|
||||||
|
final labelScores = [candidate.displayName, candidate.secondaryLabel].map((
|
||||||
|
value,
|
||||||
|
) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) return null;
|
||||||
|
return _scoreLabel(trimmed, lowerQuery);
|
||||||
|
}).whereType<int>();
|
||||||
|
int? score = labelScores.isEmpty
|
||||||
|
? null
|
||||||
|
: labelScores.reduce((a, b) => a < b ? a : b);
|
||||||
|
|
||||||
|
if (score == null) {
|
||||||
|
final pubkeyLower = candidate.pubkey.toLowerCase();
|
||||||
|
if (pubkeyLower.startsWith(lowerQuery)) {
|
||||||
|
score = 4;
|
||||||
|
} else if (pubkeyLower.contains(lowerQuery)) {
|
||||||
|
score = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score == null) continue;
|
||||||
|
ranked.add((candidate, _groupRank(candidate), score, order));
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked.sort((a, b) {
|
||||||
|
final group = a.$2.compareTo(b.$2);
|
||||||
|
if (group != 0) return group;
|
||||||
|
final score = a.$3.compareTo(b.$3);
|
||||||
|
if (score != 0) return score;
|
||||||
|
return a.$4.compareTo(b.$4);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [for (final item in ranked) item.$1];
|
||||||
|
}
|
||||||
@@ -116,6 +116,9 @@ class ThreadDetailPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
// Reversed so the list opens pinned to the newest reply,
|
||||||
|
// matching the channel message list.
|
||||||
|
reverse: true,
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: Grid.gutter,
|
left: Grid.gutter,
|
||||||
right: Grid.gutter,
|
right: Grid.gutter,
|
||||||
@@ -124,7 +127,7 @@ class ThreadDetailPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
itemCount: replies.length + 1, // +1 for thread head
|
itemCount: replies.length + 1, // +1 for thread head
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) {
|
if (index == replies.length) {
|
||||||
// Thread head.
|
// Thread head.
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -163,8 +166,10 @@ class ThreadDetailPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final reply = replies[index - 1];
|
// Reversed list: index 0 = newest reply.
|
||||||
final prevReply = index > 1 ? replies[index - 2] : null;
|
final chronIdx = replies.length - 1 - index;
|
||||||
|
final reply = replies[chronIdx];
|
||||||
|
final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null;
|
||||||
final showAuthor =
|
final showAuthor =
|
||||||
prevReply == null ||
|
prevReply == null ||
|
||||||
prevReply.pubkey.toLowerCase() !=
|
prevReply.pubkey.toLowerCase() !=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/crypto/nip_oa.dart';
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import 'user_profile.dart';
|
import 'user_profile.dart';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
|||||||
avatarUrl: data.avatarUrl,
|
avatarUrl: data.avatarUrl,
|
||||||
about: data.about,
|
about: data.about,
|
||||||
nip05Handle: data.nip05,
|
nip05Handle: data.nip05,
|
||||||
|
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ class UserProfile {
|
|||||||
final String? about;
|
final String? about;
|
||||||
final String? nip05Handle;
|
final String? nip05Handle;
|
||||||
|
|
||||||
|
/// NIP-OA verified owner pubkey from the profile's `auth` tag; non-null
|
||||||
|
/// means this identity is an agent (mirrors desktop's `ownerPubkey`).
|
||||||
|
final String? ownerPubkey;
|
||||||
|
|
||||||
const UserProfile({
|
const UserProfile({
|
||||||
required this.pubkey,
|
required this.pubkey,
|
||||||
this.displayName,
|
this.displayName,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.about,
|
this.about,
|
||||||
this.nip05Handle,
|
this.nip05Handle,
|
||||||
|
this.ownerPubkey,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
|
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
|
||||||
|
|||||||
74
mobile/lib/shared/crypto/nip_oa.dart
Normal file
74
mobile/lib/shared/crypto/nip_oa.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
import 'package:pointycastle/digests/sha256.dart';
|
||||||
|
|
||||||
|
/// NIP-OA (Owner Attestation) — verify the `auth` tag on a kind:0 profile
|
||||||
|
/// that proves an owner key authorized an agent key.
|
||||||
|
///
|
||||||
|
/// Tag format: ["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
|
||||||
|
/// Preimage: "nostr:agent-auth:" + agent_pubkey_hex + ":" + conditions
|
||||||
|
/// Signature: BIP-340 Schnorr over SHA256(preimage) by the owner key.
|
||||||
|
///
|
||||||
|
/// Mirrors `profile_valid_oa_owner_pubkey` in desktop/src-tauri: the tag is
|
||||||
|
/// verified against the profile event author, so a forged or stale marker
|
||||||
|
/// cannot turn a person into an agent.
|
||||||
|
///
|
||||||
|
/// Returns the owner pubkey (lowercase hex) for the first valid auth tag,
|
||||||
|
/// or null if none verifies.
|
||||||
|
String? verifiedOaOwnerPubkey(List<List<String>> tags, String agentPubkey) {
|
||||||
|
final agent = agentPubkey.toLowerCase();
|
||||||
|
|
||||||
|
for (final tag in tags) {
|
||||||
|
if (tag.length != 4 || tag[0] != 'auth') continue;
|
||||||
|
|
||||||
|
final owner = tag[1].toLowerCase();
|
||||||
|
final conditions = tag[2];
|
||||||
|
final sig = tag[3];
|
||||||
|
|
||||||
|
// Self-attestation is meaningless and rejected.
|
||||||
|
if (owner == agent) continue;
|
||||||
|
if (owner.length != 64 || sig.length != 128) continue;
|
||||||
|
if (!_validConditions(conditions)) continue;
|
||||||
|
|
||||||
|
final preimage = utf8.encode('nostr:agent-auth:$agent:$conditions');
|
||||||
|
final digest = SHA256Digest().process(Uint8List.fromList(preimage));
|
||||||
|
final message = digest
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nostr.Schnorr.verify(
|
||||||
|
publicKey: owner,
|
||||||
|
message: message,
|
||||||
|
signature: sig,
|
||||||
|
)) {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Malformed hex — treat as an invalid tag.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the NIP-OA `conditions` string: empty, or `&`-joined clauses of
|
||||||
|
/// `kind=<n>`, `created_at<<n>`, or `created_at><n>` with canonical decimals.
|
||||||
|
bool _validConditions(String conditions) {
|
||||||
|
if (conditions.isEmpty) return true;
|
||||||
|
if (conditions.contains(RegExp(r'\s'))) return false;
|
||||||
|
|
||||||
|
for (final clause in conditions.split('&')) {
|
||||||
|
final match = RegExp(
|
||||||
|
r'^(?:kind=|created_at<|created_at>)(0|[1-9][0-9]*)$',
|
||||||
|
).firstMatch(clause);
|
||||||
|
if (match == null) return false;
|
||||||
|
final value = int.tryParse(match.group(1)!);
|
||||||
|
if (value == null || value > 4294967295) return false;
|
||||||
|
if (clause.startsWith('kind=') && value > 65535) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import 'package:nostr/nostr.dart' as nostr;
|
|||||||
import 'package:buzz/features/channels/channel.dart';
|
import 'package:buzz/features/channels/channel.dart';
|
||||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||||
import 'package:buzz/features/channels/compose_bar.dart';
|
import 'package:buzz/features/channels/compose_bar.dart';
|
||||||
|
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
||||||
|
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
import 'package:buzz/shared/theme/theme.dart';
|
import 'package:buzz/shared/theme/theme.dart';
|
||||||
|
|
||||||
@@ -116,6 +118,10 @@ Widget _buildComposeBar({
|
|||||||
channelMembersProvider(
|
channelMembersProvider(
|
||||||
'channel-1',
|
'channel-1',
|
||||||
).overrideWith((ref) async => const <ChannelMember>[]),
|
).overrideWith((ref) async => const <ChannelMember>[]),
|
||||||
|
agentDirectoryProvider.overrideWith(
|
||||||
|
(ref) async => const <AgentDirectoryEntry>[],
|
||||||
|
),
|
||||||
|
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
|
||||||
relayClientProvider.overrideWithValue(
|
relayClientProvider.overrideWithValue(
|
||||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
final userPubkey = 'a' * 64;
|
||||||
|
final memberPubkey = 'b' * 64;
|
||||||
|
final agentPubkey = 'c' * 64;
|
||||||
|
final ownerPubkey = 'd' * 64;
|
||||||
|
|
||||||
|
ChannelMember member(String pubkey, {String role = 'member'}) {
|
||||||
|
return ChannelMember(
|
||||||
|
pubkey: pubkey,
|
||||||
|
role: role,
|
||||||
|
joinedAt: DateTime(2024),
|
||||||
|
displayName: 'Member',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('agentIsSharedWithUser', () {
|
||||||
|
test('anyone-mode agent is shared when a channel overlaps', () {
|
||||||
|
final agent = AgentDirectoryEntry(
|
||||||
|
pubkey: agentPubkey,
|
||||||
|
respondTo: 'anyone',
|
||||||
|
channelIds: const ['chan-1'],
|
||||||
|
);
|
||||||
|
expect(agentIsSharedWithUser(agent, {'chan-1'}, userPubkey), isTrue);
|
||||||
|
expect(agentIsSharedWithUser(agent, {'chan-2'}, userPubkey), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowlist-mode agent requires the user on the allowlist', () {
|
||||||
|
final agent = AgentDirectoryEntry(
|
||||||
|
pubkey: agentPubkey,
|
||||||
|
respondTo: 'allowlist',
|
||||||
|
respondToAllowlist: [userPubkey],
|
||||||
|
channelIds: const ['chan-1'],
|
||||||
|
);
|
||||||
|
expect(agentIsSharedWithUser(agent, {'chan-1'}, userPubkey), isTrue);
|
||||||
|
expect(agentIsSharedWithUser(agent, {'chan-1'}, 'e' * 64), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatOwnerLabel', () {
|
||||||
|
test('returns "you" for the current user', () {
|
||||||
|
expect(formatOwnerLabel(userPubkey, userPubkey, const {}), 'you');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers display name, then handle, then pubkey prefix', () {
|
||||||
|
final profiles = {
|
||||||
|
ownerPubkey: UserProfile(pubkey: ownerPubkey, displayName: 'Wes'),
|
||||||
|
};
|
||||||
|
expect(formatOwnerLabel(ownerPubkey, userPubkey, profiles), 'Wes');
|
||||||
|
expect(
|
||||||
|
formatOwnerLabel(ownerPubkey, userPubkey, const {}),
|
||||||
|
'${'d' * 8}\u2026',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null without an owner', () {
|
||||||
|
expect(formatOwnerLabel(null, userPubkey, const {}), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildMentionCandidates', () {
|
||||||
|
test('members come first; eligible non-member agents follow', () {
|
||||||
|
final candidates = buildMentionCandidates(
|
||||||
|
members: [member(memberPubkey), member(userPubkey)],
|
||||||
|
relayAgents: [
|
||||||
|
AgentDirectoryEntry(
|
||||||
|
pubkey: agentPubkey,
|
||||||
|
displayName: 'Helper',
|
||||||
|
respondTo: 'anyone',
|
||||||
|
channelIds: const ['chan-1'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
sharedChannelIds: {'chan-1'},
|
||||||
|
userCache: const {},
|
||||||
|
ownerByAgentPubkey: {agentPubkey: ownerPubkey},
|
||||||
|
currentPubkey: userPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidates.map((c) => c.pubkey).toList(), [
|
||||||
|
memberPubkey,
|
||||||
|
userPubkey,
|
||||||
|
agentPubkey,
|
||||||
|
]);
|
||||||
|
expect(candidates.last.isAgent, isTrue);
|
||||||
|
expect(candidates.last.isMember, isFalse);
|
||||||
|
expect(candidates.last.ownerPubkey, ownerPubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes the current user (desktop parity)', () {
|
||||||
|
final candidates = buildMentionCandidates(
|
||||||
|
members: [member(userPubkey)],
|
||||||
|
relayAgents: const [],
|
||||||
|
sharedChannelIds: const {},
|
||||||
|
userCache: const {},
|
||||||
|
ownerByAgentPubkey: const {},
|
||||||
|
currentPubkey: userPubkey,
|
||||||
|
);
|
||||||
|
expect(candidates.map((c) => c.pubkey), contains(userPubkey));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes non-shared agents and deduplicates member agents', () {
|
||||||
|
final candidates = buildMentionCandidates(
|
||||||
|
members: [member(agentPubkey, role: 'bot')],
|
||||||
|
relayAgents: [
|
||||||
|
AgentDirectoryEntry(
|
||||||
|
pubkey: agentPubkey,
|
||||||
|
respondTo: 'anyone',
|
||||||
|
channelIds: const ['chan-1'],
|
||||||
|
),
|
||||||
|
AgentDirectoryEntry(
|
||||||
|
pubkey: 'f' * 64,
|
||||||
|
respondTo: 'anyone',
|
||||||
|
channelIds: const ['chan-9'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
sharedChannelIds: {'chan-1'},
|
||||||
|
userCache: const {},
|
||||||
|
ownerByAgentPubkey: const {},
|
||||||
|
currentPubkey: userPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidates, hasLength(1));
|
||||||
|
expect(candidates.single.pubkey, agentPubkey);
|
||||||
|
expect(candidates.single.isMember, isTrue);
|
||||||
|
expect(candidates.single.isAgent, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
114
mobile/test/features/channels/mentions/mention_ranking_test.dart
Normal file
114
mobile/test/features/channels/mentions/mention_ranking_test.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:buzz/features/channels/mentions/mention_ranking.dart';
|
||||||
|
|
||||||
|
// Ported from desktop/src/features/messages/lib/mentionRanking.test.mjs
|
||||||
|
// (persona cases omitted — personas are desktop-only).
|
||||||
|
|
||||||
|
final channelBrainPubkey = '1' * 64;
|
||||||
|
final otherBrainPubkey = '2' * 64;
|
||||||
|
|
||||||
|
MentionCandidate candidate({
|
||||||
|
String? displayName = 'Brain',
|
||||||
|
String? secondaryLabel,
|
||||||
|
bool isAgent = false,
|
||||||
|
bool isMember = false,
|
||||||
|
String? pubkey,
|
||||||
|
}) {
|
||||||
|
return MentionCandidate(
|
||||||
|
pubkey: pubkey ?? otherBrainPubkey,
|
||||||
|
displayName: displayName,
|
||||||
|
secondaryLabel: secondaryLabel,
|
||||||
|
isAgent: isAgent,
|
||||||
|
isMember: isMember,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> rankedPubkeys(
|
||||||
|
List<MentionCandidate> candidates, [
|
||||||
|
String query = 'brain',
|
||||||
|
]) {
|
||||||
|
return [
|
||||||
|
for (final ranked in rankMentionCandidates(candidates, query))
|
||||||
|
ranked.pubkey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('channel members outrank people and other agents', () {
|
||||||
|
final remoteAgent = candidate(isAgent: true, pubkey: otherBrainPubkey);
|
||||||
|
final person = candidate(pubkey: '6' * 64);
|
||||||
|
final channelMember = candidate(
|
||||||
|
isAgent: true,
|
||||||
|
isMember: true,
|
||||||
|
pubkey: channelBrainPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankedPubkeys([remoteAgent, person, channelMember]), [
|
||||||
|
channelBrainPubkey,
|
||||||
|
'6' * 64,
|
||||||
|
otherBrainPubkey,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact and prefix quality sort within the channel-member group', () {
|
||||||
|
final wordPrefixMember = candidate(
|
||||||
|
displayName: 'The Brain',
|
||||||
|
isMember: true,
|
||||||
|
pubkey: '3' * 64,
|
||||||
|
);
|
||||||
|
final exactMember = candidate(
|
||||||
|
displayName: 'Brain',
|
||||||
|
isMember: true,
|
||||||
|
pubkey: channelBrainPubkey,
|
||||||
|
);
|
||||||
|
final prefixMember = candidate(
|
||||||
|
displayName: 'Brainiac',
|
||||||
|
isMember: true,
|
||||||
|
pubkey: '4' * 64,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankedPubkeys([wordPrefixMember, exactMember, prefixMember]), [
|
||||||
|
channelBrainPubkey,
|
||||||
|
'4' * 64,
|
||||||
|
'3' * 64,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matching secondary labels participate in ranking', () {
|
||||||
|
final memberByHandle = candidate(
|
||||||
|
displayName: 'Acme Bot',
|
||||||
|
secondaryLabel: 'brain@example.com',
|
||||||
|
isMember: true,
|
||||||
|
pubkey: channelBrainPubkey,
|
||||||
|
);
|
||||||
|
final nonMemberName = candidate(
|
||||||
|
displayName: 'Brain',
|
||||||
|
pubkey: otherBrainPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rankedPubkeys([nonMemberName, memberByHandle]), [
|
||||||
|
channelBrainPubkey,
|
||||||
|
otherBrainPubkey,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-matching candidates are dropped', () {
|
||||||
|
final match = candidate(displayName: 'Brain', pubkey: channelBrainPubkey);
|
||||||
|
final noMatch = candidate(displayName: 'Pinky', pubkey: '7' * 64);
|
||||||
|
|
||||||
|
expect(rankedPubkeys([match, noMatch]), [channelBrainPubkey]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pubkey prefix matches when no label matches', () {
|
||||||
|
final byPubkey = candidate(displayName: 'Pinky', pubkey: 'abc${'0' * 61}');
|
||||||
|
|
||||||
|
expect(rankedPubkeys([byPubkey], 'abc'), ['abc${'0' * 61}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query keeps stable order within groups', () {
|
||||||
|
final second = candidate(displayName: 'Beta', pubkey: '9' * 64);
|
||||||
|
final first = candidate(displayName: 'Alpha', pubkey: '8' * 64);
|
||||||
|
|
||||||
|
expect(rankedPubkeys([second, first], ''), ['9' * 64, '8' * 64]);
|
||||||
|
});
|
||||||
|
}
|
||||||
79
mobile/test/shared/crypto/nip_oa_test.dart
Normal file
79
mobile/test/shared/crypto/nip_oa_test.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
import 'package:pointycastle/digests/sha256.dart';
|
||||||
|
import 'package:buzz/shared/crypto/nip_oa.dart';
|
||||||
|
|
||||||
|
String _sha256Hex(String input) {
|
||||||
|
final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input)));
|
||||||
|
return digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> authTag(
|
||||||
|
nostr.Keys owner,
|
||||||
|
String agentPubkey, {
|
||||||
|
String conditions = '',
|
||||||
|
}) {
|
||||||
|
final message = _sha256Hex('nostr:agent-auth:$agentPubkey:$conditions');
|
||||||
|
final sig = nostr.Schnorr.sign(secretKey: owner.secret, message: message);
|
||||||
|
return ['auth', owner.public, conditions, sig];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final owner = nostr.Keys.generate();
|
||||||
|
final agent = nostr.Keys.generate();
|
||||||
|
|
||||||
|
test('returns the owner pubkey for a valid auth tag', () {
|
||||||
|
final tag = authTag(owner, agent.public);
|
||||||
|
expect(
|
||||||
|
verifiedOaOwnerPubkey([tag], agent.public),
|
||||||
|
owner.public.toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid conditions strings', () {
|
||||||
|
final tag = authTag(owner, agent.public, conditions: 'kind=0');
|
||||||
|
expect(
|
||||||
|
verifiedOaOwnerPubkey([tag], agent.public),
|
||||||
|
owner.public.toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a signature over a different agent pubkey', () {
|
||||||
|
final otherAgent = nostr.Keys.generate();
|
||||||
|
final tag = authTag(owner, otherAgent.public);
|
||||||
|
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a tampered signature', () {
|
||||||
|
final tag = authTag(owner, agent.public);
|
||||||
|
final tampered = [...tag];
|
||||||
|
tampered[3] = tampered[3].replaceRange(
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
tampered[3][0] == '0' ? '1' : '0',
|
||||||
|
);
|
||||||
|
expect(verifiedOaOwnerPubkey([tampered], agent.public), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects self-attestation', () {
|
||||||
|
final tag = authTag(agent, agent.public);
|
||||||
|
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed conditions', () {
|
||||||
|
final tag = authTag(owner, agent.public, conditions: 'kind=abc');
|
||||||
|
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unrelated tags', () {
|
||||||
|
expect(
|
||||||
|
verifiedOaOwnerPubkey([
|
||||||
|
['p', owner.public],
|
||||||
|
], agent.public),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user