diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 9c4ef96bbe..ec670cca7f 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -51,13 +51,20 @@ List buildMentionCandidates({ required Map userCache, required Map ownerByAgentPubkey, List searchResults = const [], + Set archivedPubkeys = const {}, String? currentPubkey, }) { final candidates = []; final seen = {}; + final self = currentPubkey?.toLowerCase(); for (final member in members) { final pk = member.pubkey.toLowerCase(); + // Fold relay-archived identities (#3840) out of + // autocomplete; the current user is exempt (NIP-IA §Self + // Requests anti-shadowban), matching desktop's + // `useIsArchivedPredicate`. + if (archivedPubkeys.contains(pk) && pk != self) continue; if (!seen.add(pk)) continue; final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e94459231..4e07299ce9 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -80,6 +80,8 @@ final mentionCandidatesProvider = Provider.family ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final archived = ref.watch(archivedAgentPubkeysProvider).asData?.value ?? + const {}; final channels = ref.watch(channelsProvider).asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); @@ -100,6 +102,7 @@ final mentionCandidatesProvider = Provider.family userCache: userCache, ownerByAgentPubkey: owners, searchResults: searchResults, + archivedPubkeys: archived, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50f..4d662271e8 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -69,6 +69,28 @@ final agentDirectoryProvider = FutureProvider>(( return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; }); +/// NIP-IA archived-identity pubkeys (kind:13535 snapshot). +/// +/// The relay emits a single relay-signed `p`-tag-per-identity snapshot listing +/// identities archived on the relay. Mobile folds those identities out of +/// mention autocomplete (desktop does the same via `useIsArchivedPredicate`). +/// +/// Fail-open by construction: while the snapshot is missing or unfetched the +/// set is empty, so a cold start or a relay without any archives never briefly +/// hides everyone. +final archivedAgentPubkeysProvider = 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.archivedIdentities()); + if (events.isEmpty) return const {}; + return _AgentPubkeySet({ + for (final tag in events.first.tags) + if (tag.length >= 2 && tag[0] == 'p') tag[1].toLowerCase(), + }); +}); + + /// 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`. diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 5cea037f9d..c4c62f60b5 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -209,6 +209,12 @@ abstract final class NostrFilters { static NostrFilter agentProfiles() => const NostrFilter(kinds: [10100], limit: 100); + /// NIP-IA archived-identity list (kind:13535). Relay-signed addressable + /// snapshot; one `p` tag per archived identity. Addresses match the relay's + /// `KIND_IA_ARCHIVED_LIST` = 13535. + static NostrFilter archivedIdentities() => + const NostrFilter(kinds: [13535], limit: 1); + /// User status (NIP-38, kind:30315). static NostrFilter userStatus(String pubkey) => NostrFilter(kinds: [30315], authors: [pubkey], limit: 1); diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 811996857c..cdc045efeb 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -280,5 +280,54 @@ void main() { expect(candidates, hasLength(1)); expect(candidates.single.isMember, isTrue); }); + + test('archived agent identities are hidden from mention candidates', () { + final candidates = buildMentionCandidates( + members: [member(agentPubkey, role: 'bot')], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.any((c) => c.pubkey == agentPubkey), isFalse); + }); + + test('the current user is exempt from the archived fold', () { + final candidates = buildMentionCandidates( + members: [member(agentPubkey, role: 'bot')], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: agentPubkey, + ); + + expect( + candidates.any((c) => c.pubkey == agentPubkey), + isTrue, + reason: 'NIP-IA anti-shadowban: the archived self must remain visible', + ); + }); + + test('a non-archived agent candidate remains when others are archived', + () { + const keptPubkey = 'b' * 64; + final candidates = buildMentionCandidates( + members: [ + member(agentPubkey, role: 'bot'), + member(keptPubkey, role: 'bot'), + ], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.any((c) => c.pubkey == agentPubkey), isFalse); + expect(candidates.any((c) => c.pubkey == keptPubkey), isTrue); + }); }); }