fix(mobile): invalidate DM directory providers at the community boundary (#2842)

### What changed?

Made the mobile new-message directory providers
(`relayDirectoryUsersProvider`, `relayDirectorySearchProvider`)
`autoDispose`, and both now watch `relayConfigProvider` so they refetch
when the active relay/community configuration changes.

### Why?

Follow-up to #2810 (Codex P1 review flag: "Invalidate the directory when
the community changes").

Both providers previously cached results for the whole app session. They
watched only the relay-session notifier (a stable instance that survives
dependency rebuilds) and the current pubkey, which keeps its value when
two communities share a signing key. Switching between such communities
could reopen the New message sheet showing the previous relay's people,
and submit their pubkeys to the current relay. The search provider was
also a non-autoDispose family keyed by raw query strings, so every
distinct typed query leaked a cached provider entry for the session.

Watching `relayConfigProvider` (which rebuilds on every community switch
via `activeCommunityProvider`) invalidates cached browse and search
results at the community boundary, and `autoDispose` releases the cache
when the sheet closes.

### How is it tested?

Full mobile suite green (585 passed / 1 skipped), `flutter analyze`
clean.

Added tests:

-
[`channel_management_provider_test.dart`](https://github.com/block/buzz/blob/gated/directory-provider-invalidation/mobile/test/features/channels/channel_management_provider_test.dart)
— browse and search refetch on relay-config change with an unchanged
session notifier and pubkey; cached search families are released once
unlistened.

Signed-off-by: npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx <b03a39aea3cc4f3e2d609eff1d8c013e27bd384123beab66185665d805ffe628@buzz.block.builderlab.xyz>
Co-authored-by: npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx <b03a39aea3cc4f3e2d609eff1d8c013e27bd384123beab66185665d805ffe628@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-07-25 07:31:46 -07:00
committed by GitHub
parent 2a051a404d
commit edaec99edc
2 changed files with 218 additions and 57 deletions

View File

@@ -235,68 +235,80 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
/// This mirrors desktop's empty-query people directory by listing kind:0 /// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a /// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing. /// fallback for older relays that do not support directory listing.
final relayDirectoryUsersProvider = FutureProvider<List<DirectoryUser>>(( ///
ref, /// autoDispose so the cached listing is dropped when the New message sheet
) async { /// closes, and the [relayConfigProvider] watch invalidates it at the
if (mockDmDirectoryEnabled) { /// community boundary. [relaySessionProvider.notifier] is a stable Notifier
return dmDirectoryPreviewUsers; /// 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); // Rebuild whenever the active relay/community configuration changes.
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); ref.watch(relayConfigProvider);
final directoryEvents = await session.queryRelay([ final session = ref.watch(relaySessionProvider.notifier);
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}), final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
]); final directoryEvents = await session.queryRelay([
var users = directoryUsersFromProfileEvents(directoryEvents); const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
if (users.isNotEmpty) { ]);
return users var users = directoryUsersFromProfileEvents(directoryEvents);
.where((user) => user.pubkey.toLowerCase() != currentPubkey) if (users.isNotEmpty) {
.toList(); return users
} .where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}
final membershipEvents = await session.fetchHistory( final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(), NostrFilters.relayMembers(),
); );
final memberPubkeys = relayMemberPubkeysFromEvents( final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents, membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList(); ).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) { if (memberPubkeys.isEmpty) {
return const []; return const [];
} }
final profileEvents = await session.queryRelay([ final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys), NostrFilters.profilesBatch(memberPubkeys),
]); ]);
final profilesByPubkey = { final profilesByPubkey = {
for (final event in profileEvents) for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event), event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
}; };
users = users =
[ [
for (final pubkey in memberPubkeys) for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?) if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser( DirectoryUser(
pubkey: pubkey, pubkey: pubkey,
displayName: profile.displayName, displayName: profile.displayName,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05, nip05Handle: profile.nip05,
) )
else else
DirectoryUser(pubkey: pubkey), DirectoryUser(pubkey: pubkey),
]..sort((a, b) { ]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo( final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(), b.label.toLowerCase(),
); );
return labelComparison != 0 return labelComparison != 0
? labelComparison ? labelComparison
: a.pubkey.compareTo(b.pubkey); : a.pubkey.compareTo(b.pubkey);
}); });
return users; return users;
}); });
/// Prefix-searches the active relay's kind:0 people directory. /// 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(); final trimmed = query.trim();
if (mockDmDirectoryEnabled) { if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase(); final normalizedQuery = trimmed.toLowerCase();
@@ -312,6 +324,8 @@ final relayDirectorySearchProvider =
return ref.watch(relayDirectoryUsersProvider.future); return ref.watch(relayDirectoryUsersProvider.future);
} }
// Rebuild whenever the active relay/community configuration changes.
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier); final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([ final events = await session.queryRelay([

View File

@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; 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/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/relay/relay.dart';
@@ -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 [];
}
} }