Files
buzz/mobile/lib/shared/identity_names/identity_names_provider.dart
Wes 3ec6327618 fix(mobile): keep cached state with its community (#8349)
## Summary

One identity can be paired with several communities, but mobile keyed
several caches by pubkey only. Mobile keeps a single `ProviderScope` and
doesn't remount on a community switch, so one community's data showed up
in another, and some of it was published to the other community's relay.

- **Channel groups**: the second community showed the first community's
groups.
- **Read marks** (contexts, publishable set, NIP-RS client and slot
IDs): the first community's context IDs were merged into, and published
to, the second community's read-state slot.
- **Inbox done/unread rows and thread follows**: one shared 500-entry
cap, so activity in one community evicted another's entries.
- **Channel stars and mutes**: union-merged across communities and
published to every relay.

Every one of these is now keyed by pubkey + normalized community relay,
the same way channel sort already is.

**Switching communities.** The relay session rebuilds for the new
community before the previous community's managers are retired. Each
manager (groups, read state, stars, mutes) now checks that the session
still serves its own community before it publishes, merges a fetch, or
handles a live event. Previously, a pending publish flushed on dispose,
or an in-flight fetch, could cross into the next community.

**Old pubkey-only entries.** Channel IDs can repeat across communities,
so nothing tells us which community an old channel-keyed entry belongs
to:
- **Groups, stars, mutes and read marks** are dropped on upgrade, and
each community restores its own from its relay. Any change that never
reached a relay is lost, and a read mark the relay doesn't hold shows as
unread once. That's an accepted tradeoff: such changes would be very old
by now.
- **Inbox, thread follows, and read-state slot and client IDs** seed
every community, and the old entry is kept. The inbox and follows
entries are event IDs, which only ever match their own community. The
slot ID keeps each community publishing to the slot it already has on
its relay.

This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr
filters only it used). Nothing in the app navigates to it anymore, and
its providers were among the community-unscoped caches.

### Related issue

None found.

### Testing

- Each affected store has a regression test through the real provider
that switches A → B → A. For groups, read marks, stars and mutes, the
test sets a value in A, switches to B while A's change is still pending,
and checks two cases: B's data arriving live and B answering A's
in-flight fetch. It asserts that nothing sent to B contains A's IDs, B
shows only its own value, and A comes back unchanged. Each guard was
removed in turn to confirm a test fails without it, except two that the
current switch order never reaches: the groups merge guard and the
read-state live-event guard.
- Upgrade tests cover each kind of old entry: dropped for groups, stars,
mutes and read marks, seeded for inbox, follows and slot IDs.
- `flutter analyze` is clean, and the full mobile suite passes in the
pre-push hook at `89ebfcc12`.
- Not yet exercised in the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:59:38 -06:00

83 lines
3.1 KiB
Dart

import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../mentions/agent_identity_provider.dart';
import '../profile/user_cache_provider.dart';
import '../relay/relay.dart';
import 'identity_names.dart';
/// Client-wide naming facts. Views choose their own comparison context with
/// [IdentityNameSources.scope]; never resolve against this whole cache.
final identityNameSourcesProvider = Provider<IdentityNameSources>((ref) {
return IdentityNameSources(
profiles: ref.watch(userCacheProvider),
agentPubkeys: ref.watch(knownAgentPubkeysProvider),
agentDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentOwners: ref.watch(agentOwnersProvider).asData?.value ?? const {},
viewer: ref.watch(myPubkeyProvider)?.toLowerCase(),
);
});
/// [names]'s comparison context resolved against the live naming facts.
/// Lets a surface hand its context to a sheet or route as a watchable
/// value, so the destination follows later profile and owner changes.
final liveIdentityNamesProvider = Provider.autoDispose
.family<IdentityNames, IdentityNames>(
(ref, names) => names.withSources(ref.watch(identityNameSourcesProvider)),
);
/// Requests missing owner profiles for [names] after the current build.
void loadIdentityNameOwners(Ref ref, IdentityNames names) {
final missing = names.missingOwnerProfiles();
if (missing.isEmpty) return;
Future.microtask(() {
if (ref.mounted) {
ref.read(userCacheProvider.notifier).preload(missing.toList());
}
});
}
/// Labels for a displayed collection with no channel context, such as search
/// results or a picker's choices. [candidates] is that collection; keys
/// outside it resolve against it plus themselves.
///
/// Uncached profiles are loaded for the identities in [shown] (default: all
/// [candidates]). Pass [shown] when the context also holds identities that are
/// only compared, such as a channel roster whose names another owner loads,
/// so opening the view does not queue a profile read for every one of them.
IdentityNames watchIdentityNames(
WidgetRef ref,
Iterable<String> candidates, {
Set<String> agentPubkeys = const {},
Map<String, String> fallbackNames = const {},
Iterable<String>? shown,
}) {
final sources = ref.watch(identityNameSourcesProvider);
final names = sources.scope(
candidates,
agentPubkeys: agentPubkeys,
fallbackNames: fallbackNames,
);
// A displayed identity's profile carries its owner hint, so load uncached
// candidates as well as their owners; channels load member profiles
// elsewhere, but a displayed collection has no other loader.
final loadFor = shown == null
? names.candidates
: {
for (final key in shown)
if (names.candidates.contains(key.toLowerCase())) key.toLowerCase(),
};
final missing = {
for (final key in loadFor)
if (!sources.profiles.containsKey(key)) key,
...names.missingOwnerProfiles(loadFor),
};
if (missing.isNotEmpty) {
Future.microtask(() {
if (ref.context.mounted) {
ref.read(userCacheProvider.notifier).preload(missing.toList());
}
});
}
return names;
}