Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,20 @@ List<MentionCandidate> buildMentionCandidates({
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
Set<String> archivedPubkeys = const {},
String? currentPubkey,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ final mentionCandidatesProvider = Provider.family
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final archived = ref.watch(archivedAgentPubkeysProvider).asData?.value ??
const <String>{};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
Expand All @@ -100,6 +102,7 @@ final mentionCandidatesProvider = Provider.family
userCache: userCache,
ownerByAgentPubkey: owners,
searchResults: searchResults,
archivedPubkeys: archived,
currentPubkey: currentPubkey,
);

Expand Down
22 changes: 22 additions & 0 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
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<Set<String>>((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`.
Expand Down
6 changes: 6 additions & 0 deletions mobile/lib/shared/relay/nostr_filters.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}