mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx
parent
2a051a404d
commit
edaec99edc
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user