From 332751e3d2d64eb64c9ca54b5885755944817ec2 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sat, 1 Aug 2026 07:31:05 +0530 Subject: [PATCH] fix(mobile): exclude archived identities from mention autocomplete (#3840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz Mobile's `@` mention autocomplete still offered an archived agent identity that was no longer a channel member, while Buzz Desktop correctly hid it. Desktop filters archived identities out of forward-looking discovery surfaces (autocomplete, pickers, search) via the relay's NIP-IA archived snapshot; Mobile never read that snapshot, so the archived agent remained selectable. Bring Mobile to parity: - `NostrFilters.archivedIdentities()`: NIP-IA `kind:13535` snapshot filter. - `archivedAgentPubkeysProvider`: fetch the relay-signed, addressable `kind:13535` snapshot and decode one `p` tag per archived pubkey. Fail-open — an absent/unfetched snapshot yields an empty set, so a cold start or a relay without archives never briefly hides everyone. - `buildMentionCandidates`: fold archived pubkeys out of candidate assembly. The current user is exempt (NIP-IA §Self Requests anti-shadowban — the archived user must still see and self-unarchive), matching desktop's `useIsArchivedPredicate`. Regression tests pin the three behaviors: archived agents hidden, the archived self still visible, and non-archived agents unaffected. Verification: no Flutter SDK is installed in the contributor environment, so `flutter analyze` / `flutter test` could not be run locally. The changes are confined to a pure builder (`buildMentionCandidates`) plus data-source and filter additions whose shapes mirror existing kind-filter / provider patterns; each edited file passed a delimiter/structure sanity pass and the new tests reuse the existing `member()` fixture and pubkey constants, mirroring the adjacent passing tests. Signed-off-by: iroiro147 --- .../channels/mentions/mention_candidates.dart | 7 +++ .../mentions/mention_candidates_provider.dart | 3 ++ .../mentions/agent_identity_provider.dart | 22 +++++++++ mobile/lib/shared/relay/nostr_filters.dart | 6 +++ .../mentions/mention_candidates_test.dart | 49 +++++++++++++++++++ 5 files changed, 87 insertions(+) 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); + }); }); }