Skip to content
Merged
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
128 changes: 71 additions & 57 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -235,68 +235,80 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
/// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing.
final relayDirectoryUsersProvider = FutureProvider<List<DirectoryUser>>((
ref,
) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}
///
/// autoDispose so the cached listing is dropped when the New message sheet
/// closes, and the [relayConfigProvider] watch invalidates it at the
/// community boundary. [relaySessionProvider.notifier] is a stable Notifier
/// instance and [currentPubkeyProvider] keeps its value when two communities
/// share a signing key, so neither triggers a refetch on its own.
final relayDirectoryUsersProvider =
FutureProvider.autoDispose<List<DirectoryUser>>((ref) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}

final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}
// Rebuild whenever the active relay/community configuration changes.
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}

final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}
final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}

final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});
final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});

/// Prefix-searches the active relay's kind:0 people directory.
final relayDirectorySearchProvider =
FutureProvider.family<List<DirectoryUser>, String>((ref, query) async {
///
/// autoDispose family: each distinct query would otherwise cache a provider
/// instance for the whole session (the mention search provider is autoDispose
/// for the same reason). The [relayConfigProvider] watch invalidates cached
/// results at the community boundary.
final relayDirectorySearchProvider = FutureProvider.autoDispose
.family<List<DirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase();
Expand All @@ -312,6 +324,8 @@ final relayDirectorySearchProvider =
return ref.watch(relayDirectoryUsersProvider.future);
}

// Rebuild whenever the active relay/community configuration changes.
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([
Expand Down
147 changes: 147 additions & 0 deletions mobile/test/features/channels/channel_management_provider_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/relay/relay.dart';

Expand Down Expand Up @@ -198,4 +199,150 @@ void main() {
]);
});
});

group('directory providers relay-config invalidation', () {
NostrEvent profile(String pubkey, String name) => NostrEvent(
id: '$pubkey-profile',
pubkey: pubkey,
createdAt: 1700000000,
kind: 0,
tags: const [],
content: '{"display_name":"$name"}',
sig: 'sig',
);

ProviderContainer buildContainer(_DirectoryFakeRelaySession session) {
return ProviderContainer(
retry: (_, _) => null,
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue('me'),
],
);
}

test('browse directory refetches when the relay config changes', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);

// Keep the autoDispose provider alive across the config change, the
// way an open New message sheet would.
final subscription = container.listen(
relayDirectoryUsersProvider,
(_, _) {},
);
addTearDown(subscription.close);

final firstUsers = await container.read(
relayDirectoryUsersProvider.future,
);
expect(firstUsers.map((user) => user.label), ['Alice']);
expect(session.directoryQueryCount, 1);

// Simulate switching to a community that shares the same signing key:
// session notifier instance and pubkey both survive; only the relay
// config changes.
session.profileEvents = [profile('bob', 'Bob')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'http://other-community.example', nsec: null);
await container.pump();

final secondUsers = await container.read(
relayDirectoryUsersProvider.future,
);
expect(secondUsers.map((user) => user.label), ['Bob']);
expect(session.directoryQueryCount, 2);
});

test('search results refetch when the relay config changes', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);

final subscription = container.listen(
relayDirectorySearchProvider('ali'),
(_, _) {},
);
addTearDown(subscription.close);

final firstResults = await container.read(
relayDirectorySearchProvider('ali').future,
);
expect(firstResults.map((user) => user.label), ['Alice']);
expect(session.searchQueryCount, 1);

session.profileEvents = [profile('alina', 'Alina')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'http://other-community.example', nsec: null);
await container.pump();

final secondResults = await container.read(
relayDirectorySearchProvider('ali').future,
);
expect(secondResults.map((user) => user.label), ['Alina']);
expect(session.searchQueryCount, 2);
});

test('cached search families are released once unlistened', () async {
final session = _DirectoryFakeRelaySession(
profileEvents: [profile('alice', 'Alice')],
);
final container = buildContainer(session);
addTearDown(container.dispose);

final subscription = container.listen(
relayDirectorySearchProvider('ali'),
(_, _) {},
);
await container.read(relayDirectorySearchProvider('ali').future);
subscription.close();
await container.pump();

// A fresh read after disposal hits the relay again instead of reusing
// a session-lifetime cache entry.
await container.read(relayDirectorySearchProvider('ali').future);
expect(session.searchQueryCount, 2);
});
});
}

/// Fake [RelaySessionNotifier] that serves canned kind:0 profile events from
/// [queryRelay] and counts directory vs. search queries.
class _DirectoryFakeRelaySession extends RelaySessionNotifier {
_DirectoryFakeRelaySession({required this.profileEvents});

List<NostrEvent> profileEvents;
int directoryQueryCount = 0;
int searchQueryCount = 0;

@override
SessionState build() => const SessionState(status: SessionStatus.connected);

@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.any((filter) => filter.search != null)) {
searchQueryCount++;
} else {
directoryQueryCount++;
}
return profileEvents;
}

@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
return const [];
}
}
Loading