diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 97f5fc1c28..2eaae9a41e 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -15,6 +15,9 @@ import 'channel.dart'; import 'channel_management_provider.dart'; import 'channels_provider.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/suggestions.dart'; @@ -84,17 +87,33 @@ class ComposeBar extends HookConsumerWidget { final membersAsync = ref.watch(channelMembersProvider(channelId)); final currentPubkey = ref.watch(currentPubkeyProvider); final userCache = ref.watch(userCacheProvider); - - // Preload profiles for channel members so @mention suggestions show names. - useEffect(() { - final memberList = membersAsync.asData?.value ?? []; - if (memberList.isNotEmpty) { - ref - .read(userCacheProvider.notifier) - .preload(memberList.map((m) => m.pubkey).toList()); - } - return null; - }, [membersAsync.asData?.value.length]); + final isDmChannel = + channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ?? + false; + + // Preload profiles for channel members, mentionable agents, and their + // owners so @mention suggestions show names ("owned by …" included). + final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; + final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + useEffect( + () { + final memberList = membersAsync.asData?.value ?? []; + final pubkeys = [ + ...memberList.map((m) => m.pubkey), + ...?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. final lastTypingSentMs = useRef(0); @@ -165,27 +184,37 @@ class ComposeBar extends HookConsumerWidget { return () => controller.removeListener(listener); }, [controller]); - // Filter channel members against the query. - final members = membersAsync.asData?.value ?? []; - final suggestions = _filterMembers( - members, - mentionQuery.value, - currentPubkey, - userCache, - ); + // Ranked mention candidates (desktop-parity ordering + eligibility). + final suggestions = mentionQuery.value == null + ? const [] + : ref + .watch( + mentionCandidatesProvider(( + 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. final channels = channelsAsync.asData?.value ?? []; final channelSuggestions = filterChannels(channels, channelQuery.value); // Insert a selected mention into the text field. - void insertMention(ChannelMember member) { - final cached = ref.read(userCacheProvider)[member.pubkey.toLowerCase()]; - final name = cached?.displayName?.trim().isNotEmpty == true - ? cached!.displayName!.trim() - : '${member.pubkey.substring(0, 8)}\u2026'; + void insertMention(MentionCandidate candidate) { + final name = candidate.label; // 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); spliceAndMoveCursor( @@ -349,6 +378,7 @@ class ComposeBar extends HookConsumerWidget { suggestions: suggestions, userCache: userCache, currentPubkey: currentPubkey, + isDmChannel: isDmChannel, onSelect: insertMention, ), diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index ae75c23515..a017ed43bd 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -2,6 +2,10 @@ part of '../compose_bar.dart'; 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 /// word boundary. Returns the index of the trigger character, or `null` if none /// is found. diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index b82569500d..867242cc10 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -1,43 +1,17 @@ part of '../compose_bar.dart'; -List _filterMembers( - List members, - String? query, - String? currentPubkey, - Map 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 { - final List suggestions; + final List suggestions; final Map userCache; final String? currentPubkey; - final void Function(ChannelMember) onSelect; + final bool isDmChannel; + final void Function(MentionCandidate) onSelect; const _MentionSuggestions({ required this.suggestions, required this.userCache, required this.currentPubkey, + required this.isDmChannel, required this.onSelect, }); @@ -65,17 +39,10 @@ class _MentionSuggestions extends StatelessWidget { itemCount: suggestions.length, separatorBuilder: (_, _) => const SizedBox.shrink(), itemBuilder: (context, index) { - final member = suggestions[index]; - final profile = userCache[member.pubkey.toLowerCase()]; - final name = profile?.displayName?.trim().isNotEmpty == true - ? profile!.displayName!.trim() - : member.labelFor(currentPubkey); - final avatarUrl = profile?.avatarUrl; - final initial = - (profile?.displayName?.trim().isNotEmpty == true - ? profile!.displayName!.trim() - : member.pubkey)[0] - .toUpperCase(); + final candidate = suggestions[index]; + final name = candidate.label; + final avatarUrl = + candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; return ListTile( dense: true, @@ -88,7 +55,7 @@ class _MentionSuggestions extends StatelessWidget { : null, child: avatarUrl == null ? Text( - initial, + name[0].toUpperCase(), style: context.textTheme.labelSmall?.copyWith( color: context.colors.onPrimaryContainer, ), @@ -96,14 +63,14 @@ class _MentionSuggestions extends StatelessWidget { : null, ), title: Text(name, style: context.textTheme.bodyMedium), - trailing: member.isBot - ? Icon( - LucideIcons.bot, - size: 14, - color: context.colors.onSurfaceVariant, - ) - : null, - onTap: () => onSelect(member), + subtitle: _MentionSuggestionInfo.build( + context, + candidate: candidate, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + userCache: userCache, + ), + 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 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 List filterChannels(List channels, String? query) { if (query == null) return const []; diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart new file mode 100644 index 0000000000..a8326a7966 --- /dev/null +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -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 respondToAllowlist; + final List channelIds; + + const AgentDirectoryEntry({ + required this.pubkey, + this.displayName, + this.respondTo, + this.respondToAllowlist = const [], + this.channelIds = const [], + }); + + factory AgentDirectoryEntry.fromEvent(NostrEvent event) { + final content = _tryDecodeJsonMap(event.content); + return AgentDirectoryEntry( + pubkey: event.pubkey.toLowerCase(), + displayName: + (content?['display_name'] as String?) ?? + (content?['name'] as String?), + respondTo: content?['respond_to'] as String?, + respondToAllowlist: [ + for (final value in (content?['respond_to_allowlist'] as List?) ?? []) + if (value is String) value.toLowerCase(), + ], + channelIds: [ + for (final value in (content?['channel_ids'] as List?) ?? []) + if (value is String) value, + ], + ); + } +} + +Map? _tryDecodeJsonMap(String content) { + try { + final decoded = jsonDecode(content); + return decoded is Map ? decoded : null; + } catch (_) { + return null; + } +} + +/// Whether a non-member relay agent should be mentionable by the current +/// user. Mirrors desktop's `relayAgentIsSharedWithUser`: +/// - allowlist mode: user must be on the allowlist +/// - anyone mode: agent must share at least one channel with the user +bool agentIsSharedWithUser( + AgentDirectoryEntry agent, + Set 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 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 buildMentionCandidates({ + required List members, + required List relayAgents, + required Set sharedChannelIds, + required Map userCache, + required Map ownerByAgentPubkey, + String? currentPubkey, +}) { + final candidates = []; + final seen = {}; + + 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; +} diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart new file mode 100644 index 0000000000..10a360d30d --- /dev/null +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -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>(( + ref, +) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const []; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory(NostrFilters.agentProfiles()); + return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; +}); + +/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 +/// profiles. An entry exists only when the `auth` tag verifies — mirrors +/// desktop's `profile_valid_oa_owner_pubkey`. +final agentOwnersProvider = FutureProvider>((ref) async { + final agents = await ref.watch(agentDirectoryProvider.future); + if (agents.isEmpty) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), + ); + final owners = {}; + for (final event in events) { + final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + if (owner != null) owners[event.pubkey.toLowerCase()] = owner; + } + return owners; +}); + +/// 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, ({String channelId, String query})>(( + ref, + args, + ) { + final members = + ref.watch(channelMembersProvider(args.channelId)).asData?.value ?? + const []; + final relayAgents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final channels = + ref.watch(channelsProvider).asData?.value ?? const []; + 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); + }); diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart new file mode 100644 index 0000000000..0d0875f0ee --- /dev/null +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -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 rankMentionCandidates( + List 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? 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]; +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 6df331fb73..ea1bb54818 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -116,6 +116,9 @@ class ThreadDetailPage extends HookConsumerWidget { children: [ Expanded( child: ListView.builder( + // Reversed so the list opens pinned to the newest reply, + // matching the channel message list. + reverse: true, padding: EdgeInsets.only( left: Grid.gutter, right: Grid.gutter, @@ -124,7 +127,7 @@ class ThreadDetailPage extends HookConsumerWidget { ), itemCount: replies.length + 1, // +1 for thread head itemBuilder: (context, index) { - if (index == 0) { + if (index == replies.length) { // Thread head. return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -163,8 +166,10 @@ class ThreadDetailPage extends HookConsumerWidget { ); } - final reply = replies[index - 1]; - final prevReply = index > 1 ? replies[index - 2] : null; + // Reversed list: index 0 = newest reply. + final chronIdx = replies.length - 1 - index; + final reply = replies[chronIdx]; + final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null; final showAuthor = prevReply == null || prevReply.pubkey.toLowerCase() != diff --git a/mobile/lib/features/profile/user_cache_provider.dart b/mobile/lib/features/profile/user_cache_provider.dart index 24063f5253..e9363b8085 100644 --- a/mobile/lib/features/profile/user_cache_provider.dart +++ b/mobile/lib/features/profile/user_cache_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; import 'user_profile.dart'; @@ -72,6 +73,7 @@ class UserCacheNotifier extends Notifier> { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), ); } diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index e7139e9011..4e74159016 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.dart @@ -8,12 +8,17 @@ class UserProfile { final String? about; 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({ required this.pubkey, this.displayName, this.avatarUrl, this.about, this.nip05Handle, + this.ownerPubkey, }); factory UserProfile.fromJson(Map json) => UserProfile( diff --git a/mobile/lib/shared/crypto/nip_oa.dart b/mobile/lib/shared/crypto/nip_oa.dart new file mode 100644 index 0000000000..f9d00ef1c5 --- /dev/null +++ b/mobile/lib/shared/crypto/nip_oa.dart @@ -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", "", "", ""] +/// 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> 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=`, `created_at<`, or `created_at>` 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; +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index c1cafebb9c..00e1236cca 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -13,6 +13,8 @@ import 'package:nostr/nostr.dart' as nostr; 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/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/theme/theme.dart'; @@ -116,6 +118,10 @@ Widget _buildComposeBar({ channelMembersProvider( 'channel-1', ).overrideWith((ref) async => const []), + agentDirectoryProvider.overrideWith( + (ref) async => const [], + ), + agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart new file mode 100644 index 0000000000..d198c4da2b --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -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); + }); + }); +} diff --git a/mobile/test/features/channels/mentions/mention_ranking_test.dart b/mobile/test/features/channels/mentions/mention_ranking_test.dart new file mode 100644 index 0000000000..1e53f611ce --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_ranking_test.dart @@ -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 rankedPubkeys( + List 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]); + }); +} diff --git a/mobile/test/shared/crypto/nip_oa_test.dart b/mobile/test/shared/crypto/nip_oa_test.dart new file mode 100644 index 0000000000..55577fe848 --- /dev/null +++ b/mobile/test/shared/crypto/nip_oa_test.dart @@ -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 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, + ); + }); +}