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
19 changes: 15 additions & 4 deletions mobile/lib/features/channels/mentions/mention_candidates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,22 @@ List<MentionCandidate> buildMentionCandidates({
required Set<String> sharedChannelIds,
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
Set<String> archivedPubkeys = const {},
List<UserProfile> searchResults = const [],
String? currentPubkey,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};
final currentLower = currentPubkey?.toLowerCase();
final archived = archivedPubkeys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
bool isArchived(String pubkey) =>
pubkey != currentLower && archived.contains(pubkey);

for (final member in members) {
final pk = member.pubkey.toLowerCase();
if (isArchived(pk)) continue;
if (!seen.add(pk)) continue;
final profile = userCache[pk];
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
Expand All @@ -81,14 +89,17 @@ List<MentionCandidate> buildMentionCandidates({
final directoryPubkeys = <String>{};
final sharedAgentPubkeys = <String>{};
for (final agent in relayAgents) {
directoryPubkeys.add(agent.pubkey);
final pk = agent.pubkey.toLowerCase();
directoryPubkeys.add(pk);
if (isArchived(pk)) continue;
if (agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
sharedAgentPubkeys.add(agent.pubkey);
sharedAgentPubkeys.add(pk);
}
}

for (final agent in relayAgents) {
final pk = agent.pubkey;
final pk = agent.pubkey.toLowerCase();
if (isArchived(pk)) continue;
if (seen.contains(pk)) continue;
if (!sharedAgentPubkeys.contains(pk)) continue;
seen.add(pk);
Expand All @@ -108,9 +119,9 @@ List<MentionCandidate> buildMentionCandidates({
);
}

final currentLower = currentPubkey?.toLowerCase();
for (final profile in searchResults) {
final pk = profile.pubkey.toLowerCase();
if (isArchived(pk)) continue;
if (seen.contains(pk)) continue;
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey;
final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/identity_archive/archived_identities_provider.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
Expand Down Expand Up @@ -80,6 +81,13 @@ final mentionCandidatesProvider = Provider.family
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final archivedSnapshot = ref.watch(archivedIdentityPubkeysProvider);
// Do not briefly expose archived identities while the relay-scoped
// snapshot is loading or refreshing for a new relay. The archive
// provider itself fails open to an empty set when the relay cannot
// supply a valid snapshot.
if (archivedSnapshot.isLoading) return const [];
final archivedPubkeys = archivedSnapshot.asData?.value ?? const {};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
Expand All @@ -99,6 +107,7 @@ final mentionCandidatesProvider = Provider.family
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
archivedPubkeys: archivedPubkeys,
searchResults: searchResults,
currentPubkey: currentPubkey,
);
Expand Down
101 changes: 101 additions & 0 deletions mobile/lib/shared/identity_archive/archived_identities_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:convert';

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;

import '../relay/relay.dart';

final _hexPubkey = RegExp(r'^[0-9a-fA-F]{64}$');

final archivedIdentitiesHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});

/// Relay-scoped archive state from the latest valid NIP-IA snapshot.
///
/// This fails open while disconnected or when NIP-11/snapshot verification
/// fails, matching desktop's discovery predicate.
final archivedIdentityPubkeysProvider = FutureProvider<Set<String>>((
ref,
) async {
final sessionState = ref.watch(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) {
final connected = Completer<Set<String>>();
ref.onDispose(() {
if (!connected.isCompleted) connected.complete(const {});
});
return connected.future;
}

final config = ref.watch(relayConfigProvider);
try {
final response = await ref
.read(archivedIdentitiesHttpClientProvider)
.get(
Uri.parse(config.baseUrl),
headers: const {'Accept': 'application/nostr+json'},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode < 200 || response.statusCode >= 300) {
return const {};
}

final document = jsonDecode(response.body);
if (document is! Map<String, dynamic>) return const {};
final relaySelf = document['self'];
if (relaySelf is! String || !_hexPubkey.hasMatch(relaySelf)) {
return const {};
}

final events = await ref.read(relaySessionProvider.notifier).queryRelay([
NostrFilter(
kinds: const [EventKind.archivedIdentities],
authors: [relaySelf.toLowerCase()],
limit: 1,
),
]);
if (events.isEmpty) return const {};
events.sort((left, right) => right.createdAt.compareTo(left.createdAt));
return archivedPubkeysFromSnapshot(events.first, relaySelf);
} catch (_) {
return const {};
}
});

/// Returns the archived pubkeys from a valid snapshot signed by [relayPubkey].
/// Invalid or foreign snapshots fail open so unauthenticated relay state never
/// hides an identity.
Set<String> archivedPubkeysFromSnapshot(
NostrEvent snapshot,
String relayPubkey,
) {
final relay = relayPubkey.toLowerCase();
if (!_hexPubkey.hasMatch(relay) ||
snapshot.kind != EventKind.archivedIdentities ||
snapshot.pubkey.toLowerCase() != relay) {
return const {};
}

final nip70Tags = snapshot.tags.where(
(tag) => tag.isNotEmpty && tag.first == '-',
);
if (nip70Tags.length != 1 || nip70Tags.single.length != 1) {
return const {};
}

try {
nostr.Event.fromJson(jsonEncode(snapshot.toJson()));
} catch (_) {
return const {};
}

return Set.unmodifiable({
for (final tag in snapshot.tags)
if (tag.length >= 2 && tag.first == 'p' && _hexPubkey.hasMatch(tag[1]))
tag[1].toLowerCase(),
});
}
1 change: 1 addition & 0 deletions mobile/lib/shared/relay/nostr_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ abstract final class EventKind {
static const typingIndicator = 20002;
static const auth = 22242;
static const agentObserverFrame = 24200;
static const archivedIdentities = 13535;
static const huddleReaction = 24810;
static const readState = 30078;
static const eventReminder = 30300;
Expand Down
90 changes: 90 additions & 0 deletions mobile/test/features/channels/compose_bar_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/photo_library.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/identity_archive/archived_identities_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
Expand Down Expand Up @@ -170,6 +171,8 @@ Widget _buildComposeBar({
List<ChannelMember> members = const <ChannelMember>[],
Future<List<ChannelMember>>? membersFuture,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
Set<String> archivedPubkeys = const <String>{},
Future<Set<String>>? archivedPubkeysFuture,
List<Channel> channels = const <Channel>[],
String? currentPubkey,
bool? supportsShowingSystemContextMenu,
Expand All @@ -189,6 +192,9 @@ Widget _buildComposeBar({
).overrideWith((ref) => membersFuture ?? Future.value(members)),
agentDirectoryProvider.overrideWith((ref) async => relayAgents),
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
archivedIdentityPubkeysProvider.overrideWith(
(ref) => archivedPubkeysFuture ?? Future.value(archivedPubkeys),
),
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
Expand Down Expand Up @@ -2213,6 +2219,90 @@ void main() {
);
});

testWidgets('hides archived agents from mention suggestions', (
tester,
) async {
final agentPubkey = 'c' * 64;
final signer = nostr.Keys.generate();
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: signer.nsec,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);

await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
currentPubkey: signer.public,
archivedPubkeys: {agentPubkey},
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
displayName: 'Archived Helper Bot',
respondTo: 'anyone',
channelIds: const ['shared-channel'],
),
],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);

await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@');
await tester.pumpAndSettle();

expect(find.text('Archived Helper Bot'), findsNothing);
});

testWidgets('hides mention suggestions while archive state loads', (
tester,
) async {
final agentPubkey = 'c' * 64;
final signer = nostr.Keys.generate();
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: signer.nsec,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);

await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
currentPubkey: signer.public,
archivedPubkeysFuture: Completer<Set<String>>().future,
relayAgents: [
AgentDirectoryEntry(
pubkey: agentPubkey,
displayName: 'Potentially Archived Bot',
respondTo: 'anyone',
channelIds: const ['shared-channel'],
),
],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);

await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@');
await tester.pump();

expect(find.text('Potentially Archived Bot'), findsNothing);
});

testWidgets('adds a selected non-member agent as a bot before sending', (
tester,
) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ void main() {
});

group('buildMentionCandidates', () {
test('excludes archived identities but keeps the current user', () {
final archivedMember = '4' * 64;
final archivedAgent = '5' * 64;
final candidates = buildMentionCandidates(
members: [member(archivedMember), member(userPubkey)],
relayAgents: [
AgentDirectoryEntry(
pubkey: archivedAgent,
respondTo: 'anyone',
channelIds: const ['chan-1'],
),
],
sharedChannelIds: const {'chan-1'},
userCache: const {},
ownerByAgentPubkey: const {},
archivedPubkeys: {archivedMember, archivedAgent, userPubkey},
currentPubkey: userPubkey,
);

expect(candidates.map((candidate) => candidate.pubkey), [userPubkey]);
});

test('members come first; eligible non-member agents follow', () {
final candidates = buildMentionCandidates(
members: [member(memberPubkey), member(userPubkey)],
Expand Down
Loading