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
co-authored by npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx
parent 2a051a404d
commit edaec99edc
2 changed files with 218 additions and 57 deletions
@@ -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();
@@ -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([
@@ -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';
@@ -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 [];
}
}