mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
## 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>
258 lines
7.7 KiB
Dart
258 lines
7.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../relay/relay.dart';
|
|
import '../theme/theme_provider.dart';
|
|
import '../community/community_provider.dart';
|
|
import 'read_state_manager.dart';
|
|
|
|
class ReadStateState {
|
|
final bool isReady;
|
|
final String? pubkey;
|
|
final Map<String, int> contexts;
|
|
final int version;
|
|
|
|
/// Session-local forced-unread flags, keyed by the forced context id
|
|
/// (a channel id from the channel tile, or a `msg:` key from the message
|
|
/// actions sheet), each mapped to the channel it belongs to so channel
|
|
/// tiles and badges can surface message-level forces.
|
|
final Map<String, String> forcedUnreadContexts;
|
|
|
|
const ReadStateState({
|
|
required this.isReady,
|
|
required this.pubkey,
|
|
required this.contexts,
|
|
required this.version,
|
|
this.forcedUnreadContexts = const {},
|
|
});
|
|
|
|
const ReadStateState.inert()
|
|
: isReady = false,
|
|
pubkey = null,
|
|
contexts = const {},
|
|
version = 0,
|
|
forcedUnreadContexts = const {};
|
|
|
|
/// Channels that should surface as unread because of a forced-unread flag —
|
|
/// either forced directly, or containing a forced message.
|
|
Set<String> get locallyForcedChannelIds =>
|
|
forcedUnreadContexts.values.toSet();
|
|
|
|
/// Whether this exact context (channel id or `msg:` key) is forced unread.
|
|
bool isForcedUnread(String contextId) =>
|
|
forcedUnreadContexts.containsKey(contextId);
|
|
|
|
int? effectiveTimestamp(String contextId) => contexts[contextId];
|
|
|
|
ReadStateState copyWithContext(String contextId, int timestamp) {
|
|
final current = contexts[contextId] ?? 0;
|
|
if (timestamp <= current) {
|
|
return this;
|
|
}
|
|
|
|
return ReadStateState(
|
|
isReady: isReady,
|
|
pubkey: pubkey,
|
|
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
|
|
version: version + 1,
|
|
forcedUnreadContexts: forcedUnreadContexts,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ReadStateNotifier extends Notifier<ReadStateState> {
|
|
ReadStateManager? _manager;
|
|
bool _isInitialized = false;
|
|
final Map<String, String> _forcedUnreadContexts = {};
|
|
|
|
@override
|
|
ReadStateState build() {
|
|
_manager?.dispose(flushPending: false);
|
|
_manager = null;
|
|
_isInitialized = false;
|
|
_forcedUnreadContexts.clear();
|
|
|
|
final relayConfig = ref.watch(relayConfigProvider);
|
|
ref.watch(relaySessionProvider);
|
|
final activeCommunity = ref.watch(activeCommunityProvider).value;
|
|
|
|
final nsec = relayConfig.nsec?.trim();
|
|
if (nsec == null || nsec.isEmpty) {
|
|
return const ReadStateState.inert();
|
|
}
|
|
|
|
final signedRelay = SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: nsec,
|
|
);
|
|
final pubkey =
|
|
_normalizePubkey(activeCommunity?.pubkey) ??
|
|
_safeDerivedPubkey(signedRelay);
|
|
if (pubkey == null) {
|
|
return const ReadStateState.inert();
|
|
}
|
|
|
|
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
|
|
if (crypto == null) {
|
|
return const ReadStateState.inert();
|
|
}
|
|
|
|
final prefs = ref.read(savedPrefsProvider);
|
|
late final ReadStateManager manager;
|
|
manager = ReadStateManager(
|
|
pubkey: pubkey,
|
|
relayUrl: relayConfig.baseUrl,
|
|
prefs: prefs,
|
|
crypto: crypto,
|
|
relaySession: ref.read(relaySessionProvider.notifier),
|
|
signedEventRelay: signedRelay,
|
|
remoteEnabled: true,
|
|
onChanged: () => _emitManagerState(manager),
|
|
);
|
|
_manager = manager;
|
|
|
|
ref.onDispose(() {
|
|
manager.dispose();
|
|
if (_manager == manager) {
|
|
_manager = null;
|
|
}
|
|
});
|
|
|
|
ref.listen(appLifecycleProvider, (_, next) {
|
|
if (next == AppLifecycleState.paused ||
|
|
next == AppLifecycleState.detached ||
|
|
next == AppLifecycleState.hidden) {
|
|
unawaited(manager.flush());
|
|
}
|
|
});
|
|
|
|
ref.listen(relaySessionProvider, (prev, next) {
|
|
if (prev?.status != SessionStatus.connected &&
|
|
next.status == SessionStatus.connected) {
|
|
unawaited(manager.reinitializeRemote());
|
|
}
|
|
});
|
|
|
|
Future.microtask(() async {
|
|
await manager.initialize();
|
|
if (_manager != manager) return;
|
|
_isInitialized = true;
|
|
_emitManagerState(manager);
|
|
});
|
|
|
|
return _stateFromManager(manager, isReady: false);
|
|
}
|
|
|
|
/// Advance a context's read marker. Clears the forced-unread flag for
|
|
/// exactly this context, if any. An explicit channel-level "Mark read"
|
|
/// (channel tile/menu) should pass [clearForcedMessages] so message-level
|
|
/// forces inside the channel are released too; the automatic read on
|
|
/// channel open must not, so a message deliberately marked unread stays
|
|
/// unread until acted on.
|
|
void markContextRead(
|
|
String contextId,
|
|
int unixTimestamp, {
|
|
bool clearForcedMessages = false,
|
|
}) {
|
|
var removed = _forcedUnreadContexts.remove(contextId) != null;
|
|
if (clearForcedMessages) {
|
|
final before = _forcedUnreadContexts.length;
|
|
_forcedUnreadContexts.removeWhere(
|
|
(_, channelId) => channelId == contextId,
|
|
);
|
|
removed = removed || _forcedUnreadContexts.length != before;
|
|
}
|
|
_manager?.markContextRead(contextId, unixTimestamp);
|
|
if (removed) {
|
|
_refreshForcedState();
|
|
}
|
|
}
|
|
|
|
/// Force a context unread for the rest of the session. [contextId] is a
|
|
/// channel id (channel-tile action) or a `msg:` key (message actions
|
|
/// sheet); [channelId] is the channel the context belongs to, so tiles and
|
|
/// badges can surface message-level forces. Read markers are monotonic,
|
|
/// so this is the only way to move a context back to unread.
|
|
void markContextUnread(String contextId, {required String channelId}) {
|
|
if (_manager == null) return;
|
|
_forcedUnreadContexts[contextId] = channelId;
|
|
_refreshForcedState();
|
|
}
|
|
|
|
/// Clear a forced-unread flag without moving any read marker. Reading to
|
|
/// the bottom of a channel the reader marked unread uses this: it ends the
|
|
/// manual unread but must not read mentions or replies the reader has not
|
|
/// seen.
|
|
void clearForcedUnread(String contextId) {
|
|
if (_forcedUnreadContexts.remove(contextId) != null) {
|
|
_refreshForcedState();
|
|
}
|
|
}
|
|
|
|
void _refreshForcedState() {
|
|
final manager = _manager;
|
|
if (manager == null) return;
|
|
state = _stateFromManager(
|
|
manager,
|
|
isReady: _isInitialized,
|
|
previousVersion: state.version,
|
|
);
|
|
}
|
|
|
|
void seedContextRead(String contextId, int unixTimestamp) {
|
|
_manager?.seedContextRead(contextId, unixTimestamp);
|
|
}
|
|
|
|
void _emitManagerState(ReadStateManager manager) {
|
|
if (_manager != manager) return;
|
|
final advances = manager.drainSyncedAdvances();
|
|
for (final contextId in advances) {
|
|
_forcedUnreadContexts.remove(contextId);
|
|
}
|
|
state = _stateFromManager(
|
|
manager,
|
|
isReady: _isInitialized,
|
|
previousVersion: state.version,
|
|
);
|
|
}
|
|
|
|
ReadStateState _stateFromManager(
|
|
ReadStateManager manager, {
|
|
required bool isReady,
|
|
int? previousVersion,
|
|
}) {
|
|
return ReadStateState(
|
|
isReady: isReady,
|
|
pubkey: manager.pubkey,
|
|
contexts: manager.effectiveContexts,
|
|
version: (previousVersion ?? 0) + 1,
|
|
forcedUnreadContexts: Map.unmodifiable(
|
|
Map<String, String>.from(_forcedUnreadContexts),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final readStateProvider = NotifierProvider<ReadStateNotifier, ReadStateState>(
|
|
ReadStateNotifier.new,
|
|
);
|
|
|
|
String? _normalizePubkey(String? value) {
|
|
final normalized = value?.trim().toLowerCase();
|
|
if (normalized == null || normalized.isEmpty) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
String? _safeDerivedPubkey(SignedEventRelay relay) {
|
|
try {
|
|
return _normalizePubkey(relay.pubkey);
|
|
} catch (e) {
|
|
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
|
|
return null;
|
|
}
|
|
}
|