perf(mobile): restore cached channels before community sync (#8580)

## Summary

Returning to a previously visited community shows its saved channel list
immediately, including offline, while membership and unread state
refresh in the background. Initial revalidation failures keep the list
visible and retry every 60 seconds without overlapping background
attempts. After successful history revalidation, ordinary reconnects use
metadata-only refreshes. A reconnect arriving during a background
request queues one follow-up attempt; timer ticks never queue
overlapping work. History deadlines retain the full-revalidation retry
until history succeeds.

Snapshots are disposable, versioned and scoped by canonical relay URL
and signing identity, capped at 2 MB / 2,000 member channels. They
include hidden-DM IDs and prefer fresher in-memory state. Community
removal and identity replacement retire outstanding writes; re-adding
unchanged credentials preserves the current cache and writer. Startup
sweeps orphan snapshots without waiting for disk acknowledgement;
community mutations also sweep orphan snapshots, including data restored
without secure community records. Cache-delete failures do not block
sign-out or strand push revocation; deletion is attempted before
journaling and retried by orphan cleanup. Successfully persisted
unchanged snapshots skip disk writes; failed writes remain retryable.

The cold path reuses fetched membership rosters, overlaps
profile/latest-message requests, shares verified DM profiles with
avatars, and fetches the own profile once per authenticated connection.
Community connection work overlaps the switching animation; avatar
hydration no longer gates landing. Full channel refreshes also
revalidate cached DM participant profiles so relay-side name changes
reach labels and avatars; metadata-only reconnects and background
refreshes reuse admitted profiles.

Me-chat and invite starter recovery use an explicit fresh membership
fetch that propagates errors while leaving the cached list visible.
Cached layout/read preferences permit immediate landing; newer
cross-device preferences may update ordering or badges after reveal.

Integrated with main’s #8490 idle/resume changes: hidden chats detach
live subscriptions, background membership polling stays quiet, and each
resume reconciles once. Chat history follows main’s live-admission-first
ordering to avoid missing messages committed during relay admission;
cached community landing remains immediate.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,778
tests passed, 19 skipped; four checks for builds without push
configuration passed. File-size and diff checks passed.
- Provider/storage journeys cover offline startup, failed initial
revalidation recovering live delivery, bounded retries, lightweight
reconnects, scope isolation, default-port URLs, padded/mismatched stored
credentials, credential replacement, backup-restored orphan data,
removal races, failed platform deletes and failed writes.
- Follow-up regressions cover interrupted native Me-chat lookup, invite
failure propagation, retry after an initial list failure, history
deadlines, queued reconnects, escaped-path keys, unchanged credential
re-adds and non-blocking startup cleanup. Reverting each of the eight
behavior/caller/cleanup-order fixes fails its corresponding regression.
- DM-name regression changes only the relay profile, verifies a full
refresh updates the channel label and shared profile cache, and confirms
ordinary reconnects avoid another profile request. The original
cache-only implementation fails this regression.
- Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's
413 channels went from 6.26–6.30 s to approximately 1.5 s on returning;
all three warmed communities switched in 1.47–1.54 s. Real socket pause
restored the cached list offline and retained it on reconnect. These are
debug simulator observations, not release-device benchmarks.
- Integration with current main also passes the real-session
resume/background/AUTH and live-admission/detach journeys.
- Follow-up failure paths were exercised with deterministic
provider/storage tests and widget journeys; the running simulator was
not modified during this follow-up.

Human acceptance remains pending; this PR stays draft. Try switching
among previously visited communities, opening a channel, restarting and
repeating. First visits and oversized snapshots require network loading.
Cached metadata can be stale until refresh; it lives in ordinary
preferences without additional application-level encryption. The
snapshot contains no message bodies or credentials. Persistent
disk-deletion failures can leave metadata until a later cleanup
succeeds.

---------

Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz>
Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz>
Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-10-26 10:57:07 -07:00
committed by GitHub
parent d090ac24bb
commit ab73cd9fb5
24 changed files with 2427 additions and 408 deletions

View File

@@ -247,9 +247,9 @@ InviteJoinRecovery buildMobileInviteJoinRecovery(
.read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future)
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
ensureScopeCurrent(); ensureScopeCurrent();
await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true); final channels = await ref
ensureScopeCurrent(); .read(channelsProvider.notifier)
final channels = await ref.read(channelsProvider.future); .fetchFresh(fetchDirectory: true);
ensureScopeCurrent(); ensureScopeCurrent();
return channels; return channels;
}, },

View File

@@ -81,6 +81,28 @@ class Channel {
: null, : null,
); );
/// Inverse of [Channel.fromJson].
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'channel_type': channelType,
'labels': labels,
'visibility': visibility,
'description': description,
'topic': topic,
'purpose': purpose,
'created_by': createdBy,
'created_at': createdAt.toIso8601String(),
'member_count': memberCount,
'last_message_at': lastMessageAt?.toIso8601String(),
'archived_at': archivedAt?.toIso8601String(),
'participants': participants,
'participant_pubkeys': participantPubkeys,
'is_member': isMember,
'ttl_seconds': ttlSeconds,
'ttl_deadline': ttlDeadline?.toIso8601String(),
};
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null; bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
/// Solo presentation is carried by NIP-CL metadata, not project filing. /// Solo presentation is carried by NIP-CL metadata, not project filing.

View File

@@ -195,15 +195,15 @@ Future<T> _fenced<T>(_ChannelRefreshFence fence, Future<T> future) async {
/// Lives in this part file because `channels_provider.dart` sits against the /// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. /// repository-wide 1200-line file ceiling enforced by `just file-size-check`.
Future<Map<String, String>> _resolveDmDisplayNames( Future<Map<String, String>> _resolveDmDisplayNames(
RelaySessionNotifier session, Ref ref,
_ChannelRefreshFence fence, _ChannelRefreshFence fence,
Iterable<NostrEvent> dedupedMetas, Iterable<ChannelData> channelMetas,
String myPk, String myPk, {
) async { required bool refreshCached,
}) async {
final dmParticipants = <String>{}; final dmParticipants = <String>{};
final myPkLower = myPk.toLowerCase(); final myPkLower = myPk.toLowerCase();
for (final event in dedupedMetas) { for (final data in channelMetas) {
final data = ChannelData.fromEvent(event);
if (data.channelType != 'dm') continue; if (data.channelType != 'dm') continue;
for (final pk in data.participantPubkeys) { for (final pk in data.participantPubkeys) {
final lower = pk.toLowerCase(); final lower = pk.toLowerCase();
@@ -212,24 +212,44 @@ Future<Map<String, String>> _resolveDmDisplayNames(
} }
if (dmParticipants.isEmpty) return const {}; if (dmParticipants.isEmpty) return const {};
final profileEvents = await _fenced( final profiles = await _fenced(
fence, fence,
session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())), ref
.read(userCacheProvider.notifier)
.resolve(dmParticipants.toList(), refreshCached: refreshCached),
); );
final displayNames = <String, String>{}; final displayNames = <String, String>{};
for (final event in profileEvents) { for (final profile in profiles.values) {
if (event.kind != 0) continue;
final profile = ProfileData.fromEvent(event);
final label = profile.displayName?.trim().isNotEmpty == true final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim() ? profile.displayName!.trim()
: profile.nip05?.trim().isNotEmpty == true : profile.nip05Handle?.trim().isNotEmpty == true
? profile.nip05!.trim() ? profile.nip05Handle!.trim()
: shortPubkey(profile.pubkey); : shortPubkey(profile.pubkey);
displayNames[profile.pubkey.toLowerCase()] = label; displayNames[profile.pubkey.toLowerCase()] = label;
} }
return displayNames; return displayNames;
} }
List<NostrEvent> _latestChannelEvents(
Iterable<NostrEvent> events, {
required int kind,
}) {
final latestById = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != kind) continue;
final id = event.getTagValue('d');
if (id == null) continue;
final current = latestById[id];
if (current == null ||
event.createdAt > current.createdAt ||
(event.createdAt == current.createdAt &&
event.id.compareTo(current.id) < 0)) {
latestById[id] = event;
}
}
return latestById.values.toList();
}
Future<Set<String>> _fetchHiddenDmIds( Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session, RelaySessionNotifier session,
String myPk, String myPk,

View File

@@ -0,0 +1,70 @@
part of 'channels_provider.dart';
extension _ChannelSnapshots on ChannelsNotifier {
ChannelListSnapshotStorage? _snapshotStorage() =>
_lifecycleRef.read(channelListSnapshotStorageProvider);
List<Channel>? _restoreSnapshot(String? pubkey, String relay) {
if (pubkey == null) return null;
final storage = _snapshotStorage();
final snapshot = storage?.read(pubkey, relay);
if (snapshot == null) return null;
try {
final channels = snapshot.channels
.map(Channel.fromJson)
.where(
(channel) =>
channel.isMember && !snapshot.hiddenDmIds.contains(channel.id),
)
.toList();
_hiddenDmIds = Set.unmodifiable(snapshot.hiddenDmIds);
_hasLoaded = true;
_hasCachedSnapshot = true;
_needsSnapshotRevalidation = true;
return channels;
} catch (_) {
unawaited(
storage!.remove(pubkey, relay).catchError((Object error) {
debugPrint(
'[ChannelsNotifier] invalid snapshot removal failed: $error',
);
}),
);
return null;
}
}
void Function(List<Channel>, Set<String>)? _captureSnapshotWriter(
String pubkey,
String relay,
) {
final storage = _snapshotStorage();
if (storage == null) return null;
// Capture removal ownership before network I/O. Community removal retires
// this token even while the active provider is still being torn down.
final token = storage.capture(pubkey, relay);
return (channels, hiddenDmIds) {
final snapshot = ChannelListSnapshot(
channels: [
for (final channel in channels)
if (channel.isMember) channel.toJson(),
],
hiddenDmIds: hiddenDmIds,
);
unawaited(
storage
.write(token, snapshot)
.then(
(saved) {
if (!saved) {
debugPrint('[ChannelsNotifier] snapshot was not saved');
}
},
onError: (Object error, StackTrace stack) {
debugPrint('[ChannelsNotifier] snapshot write failed: $error');
},
),
);
};
}
}

View File

@@ -290,17 +290,21 @@ class ChannelsPage extends HookConsumerWidget {
Future<Rect?> prepareCommunityLanding() async { Future<Rect?> prepareCommunityLanding() async {
// The list snapshot precedes unread history and DM profile hydration. // The list snapshot precedes unread history and DM profile hydration.
// Both can still change weight, labels, and sorting behind the picker. // Both can still change weight, labels, and sorting behind the picker.
final channels = ref.read(channelsProvider).requireValue; // A restored list is already usable offline. Refresh its unread state
final dmPubkeys = { // and participant profiles behind the page instead of gating navigation.
for (final channel in channels) if (!ref.read(channelsProvider.notifier).hasCachedSnapshot) {
if (channel.isMember && !channel.isArchived && channel.isDm) final channels = ref.read(channelsProvider).requireValue;
...channel.participantPubkeys, final dmPubkeys = {
}; for (final channel in channels)
await Future.wait([ if (channel.isMember && !channel.isArchived && channel.isDm)
ref.read(channelsProvider.notifier).waitForUnreadCatchUp(), ...channel.participantPubkeys,
if (dmPubkeys.isNotEmpty) };
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()), await Future.wait([
]); ref.read(channelsProvider.notifier).waitForUnreadCatchUp(),
if (dmPubkeys.isNotEmpty)
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()),
]);
}
if (!context.mounted || !communityFlightActive.value) return null; if (!context.mounted || !communityFlightActive.value) return null;
// Settle Home's scale and scroll while the loading backdrop is opaque. // Settle Home's scale and scroll while the loading backdrop is opaque.
onSettingsTransitionProgress(0); onSettingsTransitionProgress(0);

View File

@@ -9,12 +9,15 @@ const _communityRevealDuration = Duration(milliseconds: 160);
const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5; const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5;
// Readiness follows the data that determines the landing page's layout. Live // Readiness follows the data that determines the landing page's layout. Live
// messages continue to sync normally after this first usable snapshot. // messages continue to sync normally after this first usable snapshot. The
// header's own profile avatar hydrates independently and never gates landing.
// Cached destinations use local layout/read preferences immediately. Remote
// changes may reorder the list or update badges after reveal; offline landing
// must not wait for those relay reads.
final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>(( final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
ref, ref,
) { ) {
final channels = ref.watch(channelsProvider); final channels = ref.watch(channelsProvider);
final profile = ref.watch(profileProvider);
final community = ref.watch(activeCommunityProvider).unwrapPrevious().value; final community = ref.watch(activeCommunityProvider).unwrapPrevious().value;
final sections = ref.watch(channelSectionsProvider).isReady; final sections = ref.watch(channelSectionsProvider).isReady;
final stars = ref.watch(channelStarsProvider).isReady; final stars = ref.watch(channelStarsProvider).isReady;
@@ -22,6 +25,10 @@ final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
final mutes = ref.watch(channelMutesProvider).isReady; final mutes = ref.watch(channelMutesProvider).isReady;
final readState = ref.watch(readStateProvider).isReady; final readState = ref.watch(readStateProvider).isReady;
final hasIdentity = ref.watch(myPubkeyProvider) != null; final hasIdentity = ref.watch(myPubkeyProvider) != null;
// A restored snapshot lands with the local section/star/sort/mute/read state
// already applied; their remote catch-up continues after reveal. Watching
// channelsProvider above re-evaluates this when the snapshot is installed.
final cached = ref.read(channelsProvider.notifier).hasCachedSnapshot;
if (!channels.isLoading && channels.hasError) { if (!channels.isLoading && channels.hasError) {
return AsyncError(channels.error!, channels.stackTrace!); return AsyncError(channels.error!, channels.stackTrace!);
} }
@@ -29,8 +36,9 @@ final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
community != null && community != null &&
channels.hasValue && channels.hasValue &&
!channels.isLoading && !channels.isLoading &&
!profile.isLoading && (!hasIdentity ||
(!hasIdentity || (sections && stars && sort && mutes && readState)), cached ||
(sections && stars && sort && mutes && readState)),
); );
}); });
@@ -168,21 +176,19 @@ class CommunitySwitcherPage extends HookConsumerWidget {
if (!arriving) unawaited(HapticFeedback.selectionClick()); if (!arriving) unawaited(HapticFeedback.selectionClick());
final animate = !reducedMotion && (arriving || destination != null); final animate = !reducedMotion && (arriving || destination != null);
try { try {
// Relay teardown, credential changes and provider hydration must not // Storage and relay teardown are asynchronous I/O, so they overlap the
// compete with the grid-to-center motion on the UI isolate. // grid-to-center motion. Both settle before any failure is handled, so
if (animate && !arriving) { // the picker never resets while one of them is still running.
await centering.forward(from: 0).orCancel; await Future.wait<void>([
} else { if (animate && !arriving)
centering.value = 1; centering.forward(from: 0).orCancel
} else
if (!context.mounted) return; Future.sync(() => centering.value = 1),
await WidgetsBinding.instance.endOfFrame; if (!arriving)
if (!context.mounted) return; ref
if (!arriving) { .read(communityListProvider.notifier)
await ref .switchCommunity(community.id),
.read(communityListProvider.notifier) ]);
.switchCommunity(community.id);
}
if (!context.mounted) return; if (!context.mounted) return;
await ref.read(activeCommunityProvider.future); await ref.read(activeCommunityProvider.future);
if (!context.mounted) return; if (!context.mounted) return;

View File

@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart'; import '../../shared/relay/relay.dart';
import '../../shared/channels/channel_list_snapshot.dart';
import '../../shared/theme/theme_provider.dart'; import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart'; import '../../shared/utils/string_utils.dart';
import 'channel.dart'; import 'channel.dart';
@@ -15,12 +16,14 @@ import 'channel_management_provider.dart'
import 'channel_mutes/channel_mutes_provider.dart'; import 'channel_mutes/channel_mutes_provider.dart';
import 'huddle_channel_filter.dart'; import 'huddle_channel_filter.dart';
import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'thread_follows/thread_follows_provider.dart'; import 'thread_follows/thread_follows_provider.dart';
import 'unread_badge/is_high_priority_event.dart'; import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/observed_unread_event.dart';
import 'unread_badge/should_notify_for_event.dart'; import 'unread_badge/should_notify_for_event.dart';
part 'channel_directory.dart'; part 'channel_directory.dart';
part 'channel_snapshot.dart';
part 'channel_member_snapshots.dart'; part 'channel_member_snapshots.dart';
part 'channels_provider_lifecycle.dart'; part 'channels_provider_lifecycle.dart';
@@ -63,6 +66,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Set<String> _authoredRootIds = {}; Set<String> _authoredRootIds = {};
String? _threadInterestPubkey; String? _threadInterestPubkey;
bool _hasLoaded = false; bool _hasLoaded = false;
bool _hasCachedSnapshot = false;
bool _needsSnapshotRevalidation = false;
bool _backgroundRefreshInFlight = false;
bool _backgroundRefreshQueued = false;
int _buildGeneration = 0;
void Function(List<Channel>, Set<String>)? _saveSnapshot;
String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey; String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {}; Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
@@ -91,6 +100,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
bool get hasLoaded => _hasLoaded; bool get hasLoaded => _hasLoaded;
/// Whether this scope has a reusable list from memory or local storage.
/// Remains true during background revalidation and later refreshes.
bool get hasCachedSnapshot => _hasCachedSnapshot;
/// Waits for the current list's initial unread history to settle. /// Waits for the current list's initial unread history to settle.
/// ///
/// Presentation transitions can await this without blocking ordinary cached /// Presentation transitions can await this without blocking ordinary cached
@@ -130,13 +143,19 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
@override @override
Future<List<Channel>> build() async { Future<List<Channel>> build() async {
final buildGeneration = ++_buildGeneration;
_backgroundRefreshInFlight = false;
_backgroundRefreshQueued = false;
_refreshCoordinator.retireInFlight();
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl; final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_memberSnapshotRelayBaseUrl != relayBaseUrl || if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
_memberSnapshotPubkey != pubkey) { _memberSnapshotPubkey != pubkey) {
// A cached snapshot is usable only within its relay and identity. // A cached snapshot is usable only within its relay and identity.
// A new scope must await its own connection and first channel fetch. // Never reuse another community or identity’s in-memory content.
_hasLoaded = false; _hasLoaded = false;
_hasCachedSnapshot = false;
_needsSnapshotRevalidation = false;
_memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey; _memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {}; _memberSnapshotsByChannelId = const {};
@@ -146,21 +165,28 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// previous relay or identity and must not reach this scope's state. // previous relay or identity and must not reach this scope's state.
_refreshCoordinator.retireInFlight(); _refreshCoordinator.retireInFlight();
} }
final snapshot =
(_hasLoaded ? state.value : null) ??
_restoreSnapshot(pubkey, relayBaseUrl);
if (snapshot != null) _hasCachedSnapshot = true;
_saveSnapshot = pubkey == null
? null
: _captureSnapshotWriter(pubkey, relayBaseUrl);
final connected = Completer<void>(); final connected = Completer<void>();
var connectionVersion = 0;
final sessionState = ref.read(relaySessionProvider); final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection = final waitingForInitialConnection =
sessionState.status != SessionStatus.connected; sessionState.status != SessionStatus.connected;
ref.listen(relaySessionProvider, (previous, next) { ref.listen(relaySessionProvider, (previous, next) {
if (next.status != SessionStatus.connected) return; if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection && if (previous?.status != SessionStatus.connected) connectionVersion++;
!_hasLoaded && if (waitingForInitialConnection && !connected.isCompleted) {
!connected.isCompleted) {
connected.complete(); connected.complete();
} else if (previous?.status != SessionStatus.connected) { } else if (previous?.status != SessionStatus.connected) {
// A new authenticated connection can change relay admission policy. // A new authenticated connection can change relay admission policy.
// Ordinary refreshes must not retry an unchanged terminal rejection. // Ordinary refreshes must not retry an unchanged terminal rejection.
_terminallyClosedLiveChunks.clear(); _terminallyClosedLiveChunks.clear();
unawaited(_backstopRefresh()); unawaited(_refreshInBackground(queueIfBusy: true));
} }
}); });
@@ -171,15 +197,20 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Resume is published before the session hears it. Let the session // Resume is published before the session hears it. Let the session
// decide first: one that reconnects reconciles when it connects, so // decide first: one that reconnects reconciles when it connects, so
// refreshing here too would fetch the same list twice. // refreshing here too would fetch the same list twice.
final versionBeforeResume = connectionVersion;
scheduleMicrotask(() { scheduleMicrotask(() {
if (!ref.mounted) return; if (!ref.mounted) return;
if (ref.read(relaySessionProvider).status == SessionStatus.connected) { // A fast reconnect may already have reconciled before this callback.
if (connectionVersion == versionBeforeResume &&
ref.read(relaySessionProvider).status == SessionStatus.connected) {
refresh(); refresh();
} }
}); });
}); });
ref.onDispose(() { ref.onDispose(() {
_buildGeneration++;
_refreshCoordinator.retireInFlight();
_clearLiveSubscriptions(); _clearLiveSubscriptions();
_latestObservedByChannel.clear(); _latestObservedByChannel.clear();
_observedUnreadEventsByChannel.clear(); _observedUnreadEventsByChannel.clear();
@@ -187,11 +218,25 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_backstopTimer = null; _backstopTimer = null;
}); });
if (snapshot != null) {
_startBackstopTimer();
// Install local data before starting work that can notify listeners.
unawaited(
Future<void>.microtask(() async {
if (waitingForInitialConnection) await connected.future;
if (!ref.mounted || buildGeneration != _buildGeneration) return;
await _refreshInBackground();
}),
);
return snapshot;
}
if (sessionState.status != SessionStatus.connected) { if (sessionState.status != SessionStatus.connected) {
// Preserve cached channels only for a reconnect within the same scope.
if (_hasLoaded) return state.value ?? const [];
await connected.future; await connected.future;
} }
if (!ref.mounted || buildGeneration != _buildGeneration) {
throw const _StaleChannelRefresh();
}
return _fetch(subscribeLive: true); return _fetch(subscribeLive: true);
} }
@@ -218,6 +263,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final myPk = ref.read(myPubkeyProvider); final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available'); if (myPk == null) throw StateError('No signing identity available');
_loadThreadInterestStores(myPk); _loadThreadInterestStores(myPk);
final saveSnapshot = _saveSnapshot;
final session = ref.read(relaySessionProvider.notifier); final session = ref.read(relaySessionProvider.notifier);
@@ -229,15 +275,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
); );
// Step 1: find the channels I'm a member of via kind:39002. // Step 1: find the channels I'm a member of via kind:39002.
final memberships = await _fenced( final memberships = _latestChannelEvents(
fence, await _fenced(fence, _fetchChannelMemberships(session, myPk)),
_fetchChannelMemberships(session, myPk), kind: 39002,
); );
final memberChannelIds = memberships final memberChannelIds = memberships
.map((e) => e.getTagValue('d')) .map((e) => e.getTagValue('d'))
.whereType<String>() .whereType<String>()
.toSet(); .toSet();
_cacheMemberSnapshots(memberships, replaceAll: true);
// Step 2: everything below the membership list depends only on it, so // Step 2: everything below the membership list depends only on it, so
// send those reads together instead of one round trip after another. A // send those reads together instead of one round trip after another. A
@@ -249,44 +294,27 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
NostrFilters.channelMetadata(memberChannelIdList), NostrFilters.channelMetadata(memberChannelIdList),
); );
final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk); final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk);
// The authoritative membership snapshots are needed to filter Huddle
// backing channels. The relay-signed kind:39000 metadata identifies the
// relay, not the channel creator; the owner role in kind:39002 is the
// canonical creator identity used to reject forged Huddle links.
final memberEventsRequest = memberChannelIds.isEmpty
? Future.value(const <NostrEvent>[])
: session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': memberChannelIdList},
limit: memberChannelIdList.length,
),
);
final huddleStartsRequest = _fetchHuddleStarts( final huddleStartsRequest = _fetchHuddleStarts(
session, session,
memberChannelIdList, memberChannelIdList,
); );
final ( final (memberMetas, hiddenDmIds, huddleStarts) = await _fenced(
memberMetas, fence,
hiddenDmIds, () async {
memberEvents, // Future.wait handles every request's failure and rethrows the first
huddleStarts, // one unchanged, as soon as it happens.
) = await _fenced(fence, () async { await Future.wait(eagerError: true, [
// Future.wait handles every request's failure and rethrows the first memberMetasRequest,
// one unchanged, as soon as it happens. hiddenDmIdsRequest,
await Future.wait(eagerError: true, [ huddleStartsRequest,
memberMetasRequest, ]);
hiddenDmIdsRequest, return (
memberEventsRequest, await memberMetasRequest,
huddleStartsRequest, await hiddenDmIdsRequest,
]); await huddleStartsRequest,
return ( );
await memberMetasRequest, }(),
await hiddenDmIdsRequest, );
await memberEventsRequest,
await huddleStartsRequest,
);
}());
// Step 3: fetch the open-channel directory. The relay filters this global // Step 3: fetch the open-channel directory. The relay filters this global
// kind:39000 query by the caller's access, but the client still rejects // kind:39000 query by the caller's access, but the client still rejects
@@ -299,56 +327,73 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable, // Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable,
// but stale revisions from before the relay's d_tag backfill can linger. // but stale revisions from before the relay's d_tag backfill can linger.
final latestMetaPerId = <String, NostrEvent>{}; final dedupedMetas = _latestChannelEvents([
for (final event in [...memberMetas, ..._directoryMetas]) { ...memberMetas,
if (event.kind != 39000) continue; ..._directoryMetas,
final id = event.getTagValue('d'); ], kind: 39000);
if (id == null) continue; final parsedMetas = {
final existing = latestMetaPerId[id]; for (final event in dedupedMetas) event: ChannelData.fromEvent(event),
if (existing == null || };
event.createdAt > existing.createdAt || // Membership discovery already returned the full authoritative rosters.
(event.createdAt == existing.createdAt && // Their owner roles validate Huddle links without downloading them again.
event.id.compareTo(existing.id) < 0)) { final huddleBackingIds = huddleBackingChannelIds(huddleStarts, memberships);
latestMetaPerId[id] = event; final visibleMetas = parsedMetas.entries.where((entry) {
final data = entry.value;
final isMember = memberChannelIds.contains(data.id);
if (!isMember &&
(data.visibility == 'private' || data.channelType == 'dm')) {
return false;
} }
} if (data.channelType == 'dm' && hiddenDmIds.contains(data.id)) {
final dedupedMetas = latestMetaPerId.values.toList(); return false;
}
return !(huddleBackingIds.contains(data.id) &&
data.channelType == 'stream' &&
data.visibility == 'private');
}).toList();
// Resolve DM participant display names. Extracted into the part file so // Resolve DM participant display names. Extracted into the part file so
// `channels_provider.dart` stays under the 1200-line ceiling enforced by // `channels_provider.dart` stays under the 1200-line ceiling enforced by
// `just file-size-check`. // `just file-size-check`.
final displayNames = await _resolveDmDisplayNames( final displayNamesRequest = _resolveDmDisplayNames(
session, ref,
fence, fence,
dedupedMetas, parsedMetas.values,
myPk, myPk,
refreshCached: fetchLastMessage,
); );
// Latest-message filters depend on channel identity/privacy, not names.
// Start them alongside profiles, and observe both errors immediately.
final latestEventsRequest = fetchLastMessage
? _fetchLastMessageEvents(session, [
for (final entry in visibleMetas)
if (memberChannelIds.contains(entry.value.id) &&
!entry.value.isArchived)
entry.value,
])
: Future.value(const <NostrEvent>[]);
final (displayNames, latestEvents) = await _fenced(fence, () async {
await Future.wait(eagerError: true, [
displayNamesRequest,
latestEventsRequest,
]);
return (await displayNamesRequest, await latestEventsRequest);
}());
// Name resolution may return without a fenced read; check before writing. // Name resolution may return without a fenced read; check before writing.
fence.ensureCurrent(); fence.ensureCurrent();
_cacheMemberSnapshots(memberships, replaceAll: true);
_hiddenDmIds = Set.unmodifiable(hiddenDmIds); _hiddenDmIds = Set.unmodifiable(hiddenDmIds);
final huddleBackingIds = huddleBackingChannelIds(
huddleStarts,
memberEvents,
);
final channels = <Channel>[]; final channels = <Channel>[];
for (final event in dedupedMetas) { for (final entry in visibleMetas) {
final id = event.getTagValue('d'); final id = entry.value.id;
if (id == null) continue;
final isMember = memberChannelIds.contains(id); final isMember = memberChannelIds.contains(id);
final channel = _channelFromMeta( final channel = _channelFromMeta(
event, entry.key,
entry.value,
isMember: isMember, isMember: isMember,
displayNames: displayNames, displayNames: displayNames,
); );
if (!isMember && (channel.isPrivate || channel.isDm)) continue;
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
if (huddleBackingIds.contains(channel.id) &&
channel.isStream &&
channel.isPrivate) {
continue;
}
// Ephemeral (TTL) channels are surfaced in the list with an // Ephemeral (TTL) channels are surfaced in the list with an
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be // `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
// hidden. Desktop shows them too. Previously dropped here unconditionally, // hidden. Desktop shows them too. Previously dropped here unconditionally,
@@ -358,8 +403,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Use the membership snapshots already fetched above for both Huddle // Use the membership snapshots already fetched above for both Huddle
// linkage validation and member-count hydration. // linkage validation and member-count hydration.
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); final memberCounts = _memberCountsByChannelId(memberships);
final memberCounts = _memberCountsByChannelId(memberEvents);
for (var i = 0; i < channels.length; i++) { for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id]; final count = memberCounts[channels[i].id];
if (count != null) { if (count != null) {
@@ -380,10 +424,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channelById = { final channelById = {
for (final channel in activeChannels) channel.id: channel, for (final channel in activeChannels) channel.id: channel,
}; };
final events = await _fenced( final events = latestEvents;
fence,
_fetchLastMessageEvents(session, activeChannels),
);
final lastMessageMap = <String, int>{}; final lastMessageMap = <String, int>{};
// An unavailable batch keeps the timestamps already known rather than // An unavailable batch keeps the timestamps already known rather than
// installing channels whose latest message looks absent. // installing channels whose latest message looks absent.
@@ -470,6 +511,18 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Guard the provider-state write in `retryDirectory` and `build`: the // Guard the provider-state write in `retryDirectory` and `build`: the
// caller assigns whatever this returns, so the last check belongs here. // caller assigns whatever this returns, so the last check belongs here.
fence.ensureCurrent(); fence.ensureCurrent();
if (!fetchLastMessage) {
// Backstop metadata refreshes retain live timestamps in the persisted
// snapshot as well as the visible list.
for (var i = 0; i < channels.length; i++) {
final previous = prevById[channels[i].id]?.lastMessageAt;
if (previous != null) {
channels[i] = channels[i].copyWith(lastMessageAt: previous);
}
}
}
if (fetchLastMessage) _needsSnapshotRevalidation = latestEvents == null;
saveSnapshot?.call(channels, _hiddenDmIds);
return channels; return channels;
} }
@@ -479,7 +532,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
/// burst of websocket REQs on communities with many channels. /// burst of websocket REQs on communities with many channels.
Future<List<NostrEvent>?> _fetchLastMessageEvents( Future<List<NostrEvent>?> _fetchLastMessageEvents(
RelaySessionNotifier session, RelaySessionNotifier session,
List<Channel> channels, List<ChannelData> channels,
) async { ) async {
if (channels.isEmpty) return const []; if (channels.isEmpty) return const [];
@@ -490,7 +543,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
tags: { tags: {
'#h': [channel.id], '#h': [channel.id],
}, },
limit: channel.isDm ? 1 : 20, limit: channel.channelType == 'dm' ? 1 : 20,
), ),
]; ];
@@ -613,11 +666,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
/// used to populate [Channel.participants] for DMs so [Channel.displayLabel] /// used to populate [Channel.participants] for DMs so [Channel.displayLabel]
/// can render real names instead of the relay-canonical "DM" name. /// can render real names instead of the relay-canonical "DM" name.
Channel _channelFromMeta( Channel _channelFromMeta(
NostrEvent event, { NostrEvent event,
ChannelData data, {
required bool isMember, required bool isMember,
Map<String, String> displayNames = const {}, Map<String, String> displayNames = const {},
}) { }) {
final data = ChannelData.fromEvent(event);
final participants = data.channelType == 'dm' final participants = data.channelType == 'dm'
? [ ? [
for (final pk in data.participantPubkeys) for (final pk in data.participantPubkeys)
@@ -904,22 +957,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<void> _backstopRefresh() async { Future<void> _backstopRefresh() async {
try { try {
final sessionState = ref.read(relaySessionProvider); final sessionState = ref.read(relaySessionProvider);
final prevChannels = state.value ?? const [];
final prevLastMessage = {
for (final c in prevChannels)
if (c.lastMessageAt != null) c.id: c.lastMessageAt,
};
final channels = await _fetch( final channels = await _fetch(
subscribeLive: sessionState.status == SessionStatus.connected, subscribeLive: sessionState.status == SessionStatus.connected,
fetchLastMessage: false, fetchLastMessage: false,
fetchDirectory: false, fetchDirectory: false,
); );
for (var i = 0; i < channels.length; i++) {
final prev = prevLastMessage[channels[i].id];
if (channels[i].lastMessageAt == null && prev != null) {
channels[i] = channels[i].copyWith(lastMessageAt: prev);
}
}
state = AsyncData(channels); state = AsyncData(channels);
} on _StaleChannelRefresh { } on _StaleChannelRefresh {
return; return;
@@ -928,9 +970,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
} }
} }
/// Refreshes memberships and, when [fetchDirectory], the open directory. /// Fetches current memberships for decisions that cannot use cached data.
/// Invite starter recovery opts in to converge on another identity's ///
/// starters instead of attempting duplicate creation from memberships. /// Unlike background refresh, failures (including disconnection or a retired
/// request) propagate. Cached UI state stays visible while the request runs.
Future<List<Channel>> fetchFresh({bool fetchDirectory = false}) async {
final generation = _buildGeneration;
try {
await future;
} catch (_) {
// A failed initial list must not prevent an explicit network retry.
}
if (!ref.mounted || generation != _buildGeneration) {
throw const _StaleChannelRefresh();
}
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
throw StateError('Connect to the community before refreshing channels.');
}
final channels = await _fetch(
subscribeLive: true,
fetchDirectory: fetchDirectory,
);
state = AsyncData(channels);
return channels;
}
/// Best-effort refresh of memberships and, optionally, the open directory.
/// Keeps usable UI state on failure. Decisions requiring fresh membership
/// must use [fetchFresh] to receive the fetch error instead.
Future<void> refresh({bool fetchDirectory = false}) async { Future<void> refresh({bool fetchDirectory = false}) async {
final sessionState = ref.read(relaySessionProvider); final sessionState = ref.read(relaySessionProvider);
// Don't attempt to fetch when the session isn't connected — fetchHistory // Don't attempt to fetch when the session isn't connected — fetchHistory
@@ -940,15 +1007,18 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// when the session transitions to connected. // when the session transitions to connected.
if (sessionState.status != SessionStatus.connected) return; if (sessionState.status != SessionStatus.connected) return;
try { try {
final channels = await _fetch( await fetchFresh(fetchDirectory: fetchDirectory);
subscribeLive: true,
fetchDirectory: fetchDirectory,
);
state = AsyncData(channels);
} on _StaleChannelRefresh { } on _StaleChannelRefresh {
return; return;
} catch (error, stackTrace) { } catch (error, stackTrace) {
state = AsyncError(error, stackTrace); // A refresh failure cannot make already-fetched content disappear.
if (state.value == null) {
state = AsyncError(error, stackTrace);
} else {
debugPrint(
'[ChannelsNotifier] refresh failed; retaining cached list: $error',
);
}
} }
} }

View File

@@ -165,20 +165,50 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
_notifyUnreadReadinessChanged(); _notifyUnreadReadinessChanged();
unawaited(_unreadCatchUp); unawaited(_unreadCatchUp);
_backstopTimer?.cancel(); _startBackstopTimer();
_backstopTimer = Timer.periodic(ChannelsNotifier._backstopInterval, (_) { }
// A backgrounded app polls nothing, and a reconnecting session
// reconciles when it connects. After an OS suspension the missed tick // Arm recovery before the first network load succeeds. A restored snapshot
// fires before the app hears it has resumed, so check both. // needs one full load; subsequent reconnects keep the metadata-only backstop.
void _startBackstopTimer() {
_backstopTimer ??= Timer.periodic(ChannelsNotifier._backstopInterval, (_) {
// After suspension, a missed tick can precede the resumed lifecycle
// event. Keep recovery armed, but never poll while backgrounded.
if (_lifecycleRef.read(appLifecycleProvider) == if (_lifecycleRef.read(appLifecycleProvider) ==
AppLifecycleState.resumed && AppLifecycleState.resumed) {
_lifecycleRef.read(relaySessionProvider).status == unawaited(_refreshInBackground());
SessionStatus.connected) {
unawaited(_backstopRefresh());
} }
}); });
} }
Future<void> _refreshInBackground({bool queueIfBusy = false}) async {
if (_lifecycleRef.read(relaySessionProvider).status !=
SessionStatus.connected) {
return;
}
if (_backgroundRefreshInFlight) {
if (queueIfBusy) _backgroundRefreshQueued = true;
return;
}
final generation = _buildGeneration;
_backgroundRefreshInFlight = true;
try {
if (_needsSnapshotRevalidation) {
await refresh();
} else {
await _backstopRefresh();
}
} finally {
if (generation == _buildGeneration) {
_backgroundRefreshInFlight = false;
if (_backgroundRefreshQueued) {
_backgroundRefreshQueued = false;
unawaited(_refreshInBackground());
}
}
}
}
void _handleLiveChunkClosed(String chunkKey, int generation, String message) { void _handleLiveChunkClosed(String chunkKey, int generation, String message) {
if (!_lifecycleRef.mounted) return; if (!_lifecycleRef.mounted) return;
final subscription = _liveSubscriptionsByChunk[chunkKey]; final subscription = _liveSubscriptionsByChunk[chunkKey];

View File

@@ -163,8 +163,7 @@ Future<Channel> _createMeChat(
return intent.complete<Channel>( return intent.complete<Channel>(
sync, sync,
findChannel: () async { findChannel: () async {
ref.invalidate(channelsProvider); final all = await ref.read(channelsProvider.notifier).fetchFresh();
final all = await ref.read(channelsProvider.future);
return all.where((c) => c.id == channelId).firstOrNull; return all.where((c) => c.id == channelId).firstOrNull;
}, },
createChannel: () => actions.createChannel( createChannel: () => actions.createChannel(

View File

@@ -75,16 +75,36 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
Future<void> _patchQueue = Future.value(); Future<void> _patchQueue = Future.value();
@override @override
Future<UserProfile?> build() { Future<UserProfile?> build() async {
final config = ref.watch(relayConfigProvider); final config = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider); final pubkey = ref.watch(myPubkeyProvider);
ref.watch(relaySessionProvider);
final context = _ProfileWriteContext( final context = _ProfileWriteContext(
config: config, config: config,
pubkey: pubkey, pubkey: pubkey,
session: ref.read(relaySessionProvider.notifier), session: ref.read(relaySessionProvider.notifier),
); );
_hasHydrated = false; _hasHydrated = false;
if (pubkey != null) {
final connected = Completer<void>();
ref.listen(relaySessionProvider.select((value) => value.status), (
previous,
next,
) {
if (next != SessionStatus.connected) return;
if (!connected.isCompleted) {
connected.complete();
} else if (previous != SessionStatus.connected) {
// Revalidate once per authenticated connection. Intermediate socket
// states must not enqueue duplicate profile reads or erase the avatar.
ref.invalidateSelf();
}
});
if (ref.read(relaySessionProvider).status == SessionStatus.connected) {
connected.complete();
}
await connected.future;
_requireCurrentWriteContext(context);
}
return _fetch(context); return _fetch(context);
} }

View File

@@ -0,0 +1,187 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../relay/relay_provider.dart' show RelayConfig;
import '../theme/community_theme_preference.dart';
import '../theme/theme_provider.dart';
const _keyPrefix = 'buzz.channel-list.v1';
const _version = 1;
/// Snapshots beyond these bounds are not stored; the list loads from the relay.
const maxChannelListSnapshotChannels = 2000;
const maxChannelListSnapshotBytes = 2 * 1024 * 1024;
/// A disposable, relay-derived channel list for one relay and identity.
class ChannelListSnapshot {
const ChannelListSnapshot({
required this.channels,
required this.hiddenDmIds,
});
/// Member channel JSON in the shape of `Channel.toJson`.
final List<Map<String, dynamic>> channels;
final Set<String> hiddenDmIds;
}
/// Permission to write one scope's snapshot, captured before a network fetch.
///
/// [ChannelListSnapshotStorage.remove] retires every token captured before
/// it, so a fetch in flight during removal cannot recreate the snapshot.
class ChannelListSnapshotToken {
const ChannelListSnapshotToken._(this._key, this._epoch);
final String _key;
final int _epoch;
}
/// Stores the last authoritative channel list per relay and identity.
///
/// The data is a cache derived from relay events: unreadable, unsupported or
/// oversized data is discarded rather than migrated.
class ChannelListSnapshotStorage {
ChannelListSnapshotStorage(this._prefs);
final SharedPreferences _prefs;
/// Retirement epochs shared by every storage over the same preferences.
static final _epochsByPrefs = Expando<Map<String, int>>();
static final _savedValuesByPrefs = Expando<Map<String, String>>();
Map<String, String> get _savedValues => _savedValuesByPrefs[_prefs] ??= {};
Map<String, int> get _epochs => _epochsByPrefs[_prefs] ??= {};
/// The scope key. `wss://`/`ws://` origins fold to `https://`/`http://`
/// like `RelayConfig.baseUrl`, so both onboarding paths share a snapshot.
static String key(String pubkey, String relayUrl) {
final relay = normalizeCommunityRelayUrl(
RelayConfig(baseUrl: normalizeCommunityRelayUrl(relayUrl)).baseUrl,
);
return '$_keyPrefix:${pubkey.toLowerCase()}:${Uri.encodeComponent(relay)}';
}
/// Returns the scope's snapshot, or null when absent or unusable.
ChannelListSnapshot? read(String pubkey, String relayUrl) {
final key = ChannelListSnapshotStorage.key(pubkey, relayUrl);
final raw = _prefs.getString(key);
if (raw == null) return null;
try {
// UTF-16 length is at most the UTF-8 length, so this skips encoding
// for the oversized case before measuring the exact bound.
if (raw.length > maxChannelListSnapshotBytes ||
utf8.encode(raw).length > maxChannelListSnapshotBytes) {
throw const FormatException('size');
}
final json = jsonDecode(raw) as Map<String, dynamic>;
if (json['v'] != _version) throw const FormatException('version');
final channels = json['channels'] as List<dynamic>;
if (channels.length > maxChannelListSnapshotChannels) {
throw const FormatException('count');
}
return ChannelListSnapshot(
channels: [
for (final channel in channels) channel as Map<String, dynamic>,
],
hiddenDmIds: {
for (final id in json['hidden_dm_ids'] as List<dynamic>) id as String,
},
);
} catch (_) {
// Unusable cache: drop it without retiring in-flight writers.
_savedValues.remove(key);
_prefs.remove(key).ignore();
return null;
}
}
/// Captures write permission for the scope before its fetch starts.
ChannelListSnapshotToken capture(String pubkey, String relayUrl) {
final key = ChannelListSnapshotStorage.key(pubkey, relayUrl);
return ChannelListSnapshotToken._(key, _epochs[key] ?? 0);
}
/// Replaces the scope's snapshot. Returns false when [token] was retired,
/// the snapshot exceeds the size bounds, or storage rejected the write.
Future<bool> write(
ChannelListSnapshotToken token,
ChannelListSnapshot snapshot,
) async {
if ((_epochs[token._key] ?? 0) != token._epoch) return false;
final raw = snapshot.channels.length > maxChannelListSnapshotChannels
? null
: jsonEncode({
'v': _version,
'channels': snapshot.channels,
'hidden_dm_ids': snapshot.hiddenDmIds.toList()..sort(),
});
if (raw == null || utf8.encode(raw).length > maxChannelListSnapshotBytes) {
// An older, smaller snapshot no longer describes this list.
_savedValues.remove(token._key);
await _prefs.remove(token._key);
return false;
}
if (_savedValues[token._key] == raw &&
_prefs.getString(token._key) == raw) {
return true;
}
// The token check and SharedPreferences' synchronous in-memory update run
// without an intervening await, so a later remove always wins.
// Failed platform writes still change SharedPreferences' memory cache.
// Only skip values known to have been successfully persisted.
_savedValues.remove(token._key);
final saved = await _prefs.setString(token._key, raw);
if (saved && (_epochs[token._key] ?? 0) == token._epoch) {
_savedValues[token._key] = raw;
}
return saved;
}
/// Deletes the scope's snapshot and retires its outstanding tokens.
///
/// Throws on storage failure. Community sign-out treats this as best-effort;
/// orphan cleanup retries the remaining disk entry.
Future<void> remove(String pubkey, String relayUrl) async {
await _removeKey(ChannelListSnapshotStorage.key(pubkey, relayUrl));
}
/// Retires and deletes snapshots without a stored community. Failed deletes
/// remain on disk for retry at the next startup or community mutation.
Future<void> removeOrphans(Set<String> retainedKeys) async {
final keys = {..._prefs.getKeys(), ..._epochs.keys};
// Start every deletion before yielding: later community additions must not
// have a new writer retired by an older cleanup's delayed next iteration.
await Future.wait([
for (final key in keys)
if (key.startsWith('$_keyPrefix:') && !retainedKeys.contains(key))
_removeKey(key).catchError((Object error) {
debugPrint('[ChannelListSnapshotStorage] cleanup failed: $error');
}),
]);
}
Future<void> _removeKey(String key) async {
_savedValues.remove(key);
_epochs[key] = (_epochs[key] ?? 0) + 1;
if (!await _prefs.remove(key)) {
throw StateError('Channel list snapshot could not be removed.');
}
}
}
/// The app's snapshot storage, or null where preferences were never provided
/// (isolated tests). Null means there is no snapshot to read, write or remove.
final channelListSnapshotStorageProvider =
Provider<ChannelListSnapshotStorage?>((ref) {
final SharedPreferences prefs;
try {
prefs = ref.watch(savedPrefsProvider);
} catch (_) {
// The unprovided override throws, wrapped by Riverpod.
return null;
}
return ChannelListSnapshotStorage(prefs);
});

View File

@@ -7,12 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr; import 'package:nostr/nostr.dart' as nostr;
import '../auth/auth_provider.dart'; import '../auth/auth_provider.dart';
import '../channels/channel_list_snapshot.dart';
import '../hosted/community_entry_requirements.dart'; import '../hosted/community_entry_requirements.dart';
import '../push/dev_push_lease.dart'; import '../push/dev_push_lease.dart';
import '../push/push_bridge.dart'; import '../push/push_bridge.dart';
import '../push/push_lease_revocation_outbox.dart'; import '../push/push_lease_revocation_outbox.dart';
import '../push/push_subscription.dart'; import '../push/push_subscription.dart';
import '../relay/signed_event_relay.dart'; import '../relay/signed_event_relay.dart';
import '../relay/relay_provider.dart' show pubkeyFromNsec;
import 'community.dart'; import 'community.dart';
import 'community_storage.dart'; import 'community_storage.dart';
@@ -248,6 +250,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
Future<List<Community>> build() async { Future<List<Community>> build() async {
final storage = ref.read(communityStorageProvider); final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll(); final communities = await storage.loadAll();
unawaited(_removeOrphanChannelListSnapshots(communities));
await syncCommunitySnapshot(ref, communities); await syncCommunitySnapshot(ref, communities);
return communities; return communities;
} }
@@ -273,10 +276,15 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
pubkey: community.pubkey, pubkey: community.pubkey,
nsec: community.nsec, nsec: community.nsec,
); );
if (_channelSnapshotPubkey(existing)?.toLowerCase() !=
_channelSnapshotPubkey(updated)?.toLowerCase()) {
await _removeChannelListSnapshot(existing);
}
await storage.save(updated); await storage.save(updated);
final updatedList = [...current]; final updatedList = [...current];
updatedList[existingIndex] = updated; updatedList[existingIndex] = updated;
state = AsyncData(updatedList); state = AsyncData(updatedList);
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList); await syncCommunitySnapshot(ref, updatedList);
return existing.id; return existing.id;
} }
@@ -284,6 +292,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
await storage.save(community); await storage.save(community);
final updatedList = [...current, community]; final updatedList = [...current, community];
state = AsyncData(updatedList); state = AsyncData(updatedList);
await _removeOrphanChannelListSnapshots(updatedList);
await syncCommunitySnapshot(ref, updatedList); await syncCommunitySnapshot(ref, updatedList);
return community.id; return community.id;
} }
@@ -314,6 +323,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
(community) => community.id == id, (community) => community.id == id,
); );
if (removedIndex >= 0) { if (removedIndex >= 0) {
await _removeChannelListSnapshot(current[removedIndex]);
// Persist every remote-cleanup dependency before erasing credentials. // Persist every remote-cleanup dependency before erasing credentials.
// This local transaction is the only removal prerequisite. Relay I/O // This local transaction is the only removal prerequisite. Relay I/O
// starts after the community and NSE snapshot have been removed. // starts after the community and NSE snapshot have been removed.
@@ -324,6 +334,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
await storage.remove(id); await storage.remove(id);
final updatedList = current.where((w) => w.id != id).toList(); final updatedList = current.where((w) => w.id != id).toList();
await _removeOrphanChannelListSnapshots(updatedList);
state = AsyncData(updatedList); state = AsyncData(updatedList);
await syncCommunitySnapshot(ref, updatedList); await syncCommunitySnapshot(ref, updatedList);
@@ -355,6 +366,39 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
}); });
} }
String? _channelSnapshotPubkey(Community community) =>
pubkeyFromNsec(community.nsec?.trim()) ?? community.pubkey;
Future<void> _removeChannelListSnapshot(Community community) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
final pubkey = _channelSnapshotPubkey(community);
if (snapshots == null || pubkey == null) return;
try {
// Retire writers before the revocation journal or credential changes.
// A disposable cache must never prevent local sign-out.
await snapshots.remove(pubkey, community.relayUrl);
} catch (error, stackTrace) {
developer.log(
'Channel snapshot deletion failed; orphan cleanup will retry',
name: 'buzz.community',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _removeOrphanChannelListSnapshots(
List<Community> communities,
) async {
final snapshots = ref.read(channelListSnapshotStorageProvider);
if (snapshots == null) return;
await snapshots.removeOrphans({
for (final community in communities)
if (_channelSnapshotPubkey(community) case final pubkey?)
ChannelListSnapshotStorage.key(pubkey, community.relayUrl),
});
}
Future<void> switchCommunity(String id) { Future<void> switchCommunity(String id) {
return ref.read(communityTransitionProvider).runExclusive(() async { return ref.read(communityTransitionProvider).runExclusive(() async {
final storage = ref.read(communityStorageProvider); final storage = ref.read(communityStorageProvider);

View File

@@ -53,6 +53,38 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
state = {...state, profile.pubkey.toLowerCase(): profile}; state = {...state, profile.pubkey.toLowerCase(): profile};
} }
/// Resolves profiles through verified batch admission.
///
/// Unlike [preload], failures propagate to callers whose result requires
/// profile hydration. Set [refreshCached] for a full revalidation; otherwise
/// cached profiles remain reusable within this scope.
Future<Map<String, UserProfile>> resolve(
List<String> pubkeys, {
bool refreshCached = false,
}) async {
final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet();
final toFetch = requested
.where((pubkey) => refreshCached || !state.containsKey(pubkey))
.toList();
final generation = _generation;
final session = ref.read(relaySessionProvider.notifier);
for (final batch in _profileQueryBatches(toFetch)) {
if (!_isCurrent(generation)) {
throw StateError('Profile request belongs to a retired cache');
}
final events = await session.fetchHistory(
NostrFilters.profilesBatch(batch),
);
if (!await _verifyAndMerge(events, generation)) {
throw StateError('Profile request belongs to a retired cache');
}
}
if (!_isCurrent(generation)) {
throw StateError('Profile request belongs to a retired cache');
}
return {for (final pubkey in requested) pubkey: ?state[pubkey]};
}
/// Preload profiles for a list of pubkeys (e.g. channel members). /// Preload profiles for a list of pubkeys (e.g. channel members).
/// Returns whether the batch completed successfully. /// Returns whether the batch completed successfully.
Future<bool> preload(List<String> pubkeys) { Future<bool> preload(List<String> pubkeys) {

View File

@@ -0,0 +1,503 @@
import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/channels/channel_list_snapshot.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('fresh read retries a failed initial list', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final session = _SnapshotSession(['alpha'])..fail = true;
final container = _container(prefs, session);
addTearDown(container.dispose);
await expectLater(
container.read(channelsProvider.future),
throwsStateError,
);
session.fail = false;
expect(
(await container.read(channelsProvider.notifier).fetchFresh()).single.id,
'alpha',
);
expect(container.read(channelsProvider).requireValue.single.id, 'alpha');
});
test(
'fresh membership reads propagate failures while retaining cached UI',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final session = _SnapshotSession(['alpha']);
final container = _container(prefs, session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
await _settle();
session.ids = ['alpha', 'me-chat'];
final notifier = container.read(channelsProvider.notifier);
final fresh = await notifier.fetchFresh();
expect(fresh.map((c) => c.id), ['alpha', 'me-chat']);
session.fail = true;
await expectLater(
notifier.fetchFresh(fetchDirectory: true),
throwsStateError,
);
expect(container.read(channelsProvider).requireValue.map((c) => c.id), [
'alpha',
'me-chat',
]);
session.disconnect();
await expectLater(notifier.fetchFresh(), throwsStateError);
expect(container.read(channelsProvider).hasError, isFalse);
},
);
test('history deadline keeps full snapshot revalidation pending', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelListSnapshotStorage(prefs);
await storage.write(
storage.capture('me', 'https://a.example'),
ChannelListSnapshot(
channels: [_channel('alpha').toJson()],
hiddenDmIds: {},
),
);
fakeAsync((clock) {
final session = _SnapshotSession(['alpha'])..latestTimeout = true;
final container = _container(prefs, session);
container.listen(channelsProvider, (_, _) {});
clock.flushMicrotasks();
expect(container.read(channelsProvider).requireValue.single.id, 'alpha');
expect(session.latestReads, 1);
session.latestTimeout = false;
clock.elapse(const Duration(minutes: 1));
clock.flushMicrotasks();
expect(session.latestReads, 2);
clock.elapse(const Duration(minutes: 1));
clock.flushMicrotasks();
expect(session.latestReads, 2);
container.dispose();
expect(clock.pendingTimers, isEmpty);
});
});
test(
'reconnect during background refresh queues one non-overlapping retry',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
fakeAsync((clock) {
final session = _SnapshotSession(['alpha']);
final container = _container(prefs, session);
container.listen(channelsProvider, (_, _) {});
clock.flushMicrotasks();
final latestReads = session.latestReads;
final gate = session.gate = Completer<void>();
clock.elapse(const Duration(minutes: 1));
clock.flushMicrotasks();
final parkedRequests = session.membershipRequests;
session.disconnect();
session.connect();
clock.flushMicrotasks();
expect(session.membershipRequests, parkedRequests);
session.gate = null;
gate.complete();
clock.flushMicrotasks();
expect(session.membershipRequests, parkedRequests + 1);
expect(session.latestReads, latestReads);
container.dispose();
clock.elapse(const Duration(minutes: 2));
expect(clock.pendingTimers, isEmpty);
});
},
);
test('same-scope rebuild keeps live timestamps ahead of disk', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final session = _SnapshotSession(['alpha']);
final container = _container(prefs, session);
addTearDown(container.dispose);
container.listen(channelsProvider, (_, _) {});
await container.read(channelsProvider.future);
await _settle();
final event = NostrEvent(
id: 'live',
pubkey: 'other',
createdAt: 100,
kind: 9,
tags: [
['h', 'alpha'],
['p', 'me'],
],
content: 'hello',
sig: 'sig',
);
for (final listener in session.listeners.toList()) {
listener(event);
}
final latest = container
.read(channelsProvider)
.requireValue
.single
.lastMessageAt;
expect(latest, DateTime.fromMillisecondsSinceEpoch(100000, isUtc: true));
expect(
ChannelListSnapshotStorage(
prefs,
).read('me', 'https://a.example')!.channels.single['last_message_at'],
isNull,
);
session.disconnect();
container.invalidate(channelsProvider);
expect(
(await container.read(channelsProvider.future)).single.lastMessageAt,
latest,
);
});
test(
'cached discovery survives offline restart and failed refresh, then replaces removed channels',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final session = _SnapshotSession(['alpha', 'bravo']);
var container = _container(prefs, session);
expect((await container.read(channelsProvider.future)).map((c) => c.id), [
'alpha',
'bravo',
]);
await _settle();
container.dispose();
fakeAsync((clock) {
final offline = _SnapshotSession([], connected: false);
container = _container(prefs, offline);
container.listen(channelsProvider, (_, _) {});
clock.flushMicrotasks();
expect(container.read(channelsProvider).requireValue.map((c) => c.id), [
'alpha',
'bravo',
]);
expect(
container.read(channelsProvider.notifier).hasCachedSnapshot,
isTrue,
);
expect(container.read(channelsProvider.notifier).hiddenDmIds, {
'hidden-dm',
});
expect(offline.membershipRequests, 0);
offline.fail = true;
offline.connect();
clock.flushMicrotasks();
clock.elapse(const Duration(minutes: 2));
clock.flushMicrotasks();
expect(
offline.membershipRequests,
3,
); // One bounded attempt per minute.
expect(offline.listeners, isEmpty);
// A slow attempt cannot accumulate overlapping periodic retries.
final gate = Completer<void>();
offline.gate = gate;
clock.elapse(const Duration(minutes: 1));
clock.flushMicrotasks();
final parkedRequests = offline.membershipRequests;
clock.elapse(const Duration(minutes: 2));
clock.flushMicrotasks();
expect(offline.membershipRequests, parkedRequests);
gate.complete();
clock.flushMicrotasks();
offline.gate = null;
expect(container.read(channelsProvider).hasError, isFalse);
expect(container.read(channelsProvider).requireValue, hasLength(2));
offline.fail = false;
offline.ids = ['bravo'];
clock.elapse(const Duration(minutes: 1));
clock.flushMicrotasks();
expect(
container.read(channelsProvider).requireValue.single.id,
'bravo',
);
expect(offline.listeners, isNotEmpty);
final latestReads = offline.latestReads;
expect(latestReads, greaterThan(0));
// Live updates work after recovery, and ordinary reconnects stay light.
final live = offline.event(9, [
['h', 'bravo'],
['p', 'me'],
]);
for (final listener in offline.listeners.toList()) {
listener(live);
}
expect(
container.read(channelsProvider).requireValue.single.lastMessageAt,
DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true),
);
offline.disconnect();
offline.connect();
clock.flushMicrotasks();
expect(offline.latestReads, latestReads);
expect(
container.read(channelsProvider).requireValue.single.lastMessageAt,
DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true),
);
container.dispose();
clock.elapse(const Duration(minutes: 2));
expect(clock.pendingTimers, isEmpty);
});
container = _container(prefs, _SnapshotSession([], connected: false));
addTearDown(container.dispose);
expect(
(await container.read(channelsProvider.future)).single.id,
'bravo',
);
},
);
test(
'removal retires both a parked fetch and later refreshes of that build',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelListSnapshotStorage(prefs);
final session = _SnapshotSession(['alpha']);
final container = _container(prefs, session);
addTearDown(container.dispose);
container.listen(channelsProvider, (_, _) {});
await container.read(channelsProvider.future);
await _settle();
expect(storage.read('me', 'https://a.example'), isNotNull);
final gate = Completer<void>();
session.gate = gate;
final refresh = container.read(channelsProvider.notifier).refresh();
await _settle();
await storage.remove('me', 'https://a.example');
gate.complete();
await refresh;
session.gate = null;
await container.read(channelsProvider.notifier).refresh();
await _settle();
expect(storage.read('me', 'https://a.example'), isNull);
},
);
test(
'relay and identity isolate restored lists and retire a parked refresh',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelListSnapshotStorage(prefs);
for (final scope in [
('me', 'https://a.example', 'alpha'),
('other', 'https://a.example', 'bravo'),
('me', 'https://b.example', 'charlie'),
]) {
await storage.write(
storage.capture(scope.$1, scope.$2),
ChannelListSnapshot(
channels: [_channel(scope.$3).toJson()],
hiddenDmIds: {},
),
);
}
for (final scope in [
('me', 'https://a.example', 'alpha'),
('other', 'https://a.example', 'bravo'),
('me', 'https://b.example', 'charlie'),
]) {
final container = _container(
prefs,
_SnapshotSession([], connected: false),
pubkey: scope.$1,
relay: scope.$2,
);
expect(
(await container.read(channelsProvider.future)).single.id,
scope.$3,
);
container.dispose();
}
final session = _SnapshotSession(['late-alpha'], connected: false);
final gate = Completer<void>();
session.gate = gate;
final container = _container(prefs, session);
addTearDown(container.dispose);
container.listen(channelsProvider, (_, _) {});
expect(
(await container.read(channelsProvider.future)).single.id,
'alpha',
);
session.connect();
await _settle();
expect(session.membershipRequests, 1);
session.gate = null;
session.disconnect();
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://b.example');
expect(
(await container.read(channelsProvider.future)).single.id,
'charlie',
);
gate.complete();
await _settle();
expect(
container.read(channelsProvider).requireValue.single.id,
'charlie',
);
expect(
storage.read('me', 'https://a.example')!.channels.single['id'],
'alpha',
);
expect(
storage.read('me', 'https://b.example')!.channels.single['id'],
'charlie',
);
},
);
}
Channel _channel(String id) => Channel(
id: id,
name: id,
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'me',
createdAt: DateTime.utc(2026),
memberCount: 1,
isMember: true,
);
ProviderContainer _container(
SharedPreferences prefs,
_SnapshotSession session, {
String pubkey = 'me',
String relay = 'https://a.example',
}) => ProviderContainer(
retry: (_, _) => null,
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(pubkey),
relayConfigProvider.overrideWith(() => _Config(relay)),
],
);
class _Config extends RelayConfigNotifier {
_Config(this.relay);
final String relay;
@override
RelayConfig build() => RelayConfig(baseUrl: relay);
}
class _SnapshotSession extends RelaySessionNotifier {
_SnapshotSession(this.ids, {this.connected = true});
List<String> ids;
final bool connected;
bool fail = false;
bool latestTimeout = false;
Completer<void>? gate;
int membershipRequests = 0;
int latestReads = 0;
final listeners = <void Function(NostrEvent)>[];
@override
SessionState build() => SessionState(
status: connected ? SessionStatus.connected : SessionStatus.disconnected,
);
void connect() => state = const SessionState(status: SessionStatus.connected);
void disconnect() =>
state = const SessionState(status: SessionStatus.disconnected);
NostrEvent event(int kind, List<List<String>> tags) => NostrEvent(
id: '$kind-${tags.first.last}',
pubkey: 'relay',
createdAt: 1,
kind: kind,
tags: tags,
content: '',
sig: 'sig',
);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.any((filter) => filter.limit == 20)) {
latestReads++;
if (latestTimeout) {
throw RelayException(503, '{"error":"query timed out"}');
}
}
if (filters.singleOrNull?.kinds.contains(39002) == true) {
if (filters.single.until != null) return [];
membershipRequests++;
final capturedIds = List<String>.of(ids);
if (gate != null) await gate!.future;
if (fail) throw StateError('offline');
return [
for (final id in capturedIds)
event(39002, [
['d', id],
['p', 'me'],
]),
];
}
return [];
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filter.kinds.contains(39000)) {
return [
for (final id in ids)
event(39000, [
['d', id],
['name', id],
['t', 'stream'],
['public'],
]),
];
}
if (filter.kinds.contains(30622)) {
return [
event(30622, [
['d', 'hidden'],
['h', 'hidden-dm'],
]),
];
}
return [];
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async {
listeners.add(onEvent);
return () => listeners.remove(onEvent);
}
}
Future<void> _settle() async {
for (var i = 0; i < 30; i++) {
await Future<void>.delayed(Duration.zero);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:buzz/shared/community/paired_community_landing.dart';
import 'dart:async'; import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/shared/channels/channel_list_snapshot.dart';
import '../profile/presence_snapshot_test.dart' import '../profile/presence_snapshot_test.dart'
show PresenceTestRelay, presenceEvent; show PresenceTestRelay, presenceEvent;
@@ -158,6 +159,7 @@ void main() {
} }
_communityUnreadLandingTests(buildTestable); _communityUnreadLandingTests(buildTestable);
_communityCachedLandingTests(buildTestable);
final testChannels = [ final testChannels = [
Channel( Channel(
@@ -2375,7 +2377,8 @@ void main() {
); );
await tester.pump(); await tester.pump();
final flyingAvatar = find.byKey(const Key('community-flying-avatar')); final flyingAvatar = find.byKey(const Key('community-flying-avatar'));
expect(notifier.switchedIds, reduceMotion ? ['bravo'] : isEmpty); // The switch starts with the centering motion, not after it.
expect(notifier.switchedIds, ['bravo']);
if (nativeHeader) { if (nativeHeader) {
expect( expect(
tester tester
@@ -2388,7 +2391,6 @@ void main() {
final origin = tester.getCenter(flyingAvatar); final origin = tester.getCenter(flyingAvatar);
await tester.pump(const Duration(milliseconds: 105)); await tester.pump(const Duration(milliseconds: 105));
if (!reduceMotion) { if (!reduceMotion) {
expect(notifier.switchedIds, isEmpty);
final inFlight = tester.getCenter(flyingAvatar); final inFlight = tester.getCenter(flyingAvatar);
const center = Offset(195, 422); const center = Offset(195, 422);
expect( expect(

View File

@@ -1,7 +1,11 @@
part of '../channels_page_test.dart'; part of '../channels_page_test.dart';
void _communityUnreadLandingTests( void _communityUnreadLandingTests(
Widget Function({required List<Override> overrides}) buildTestable, Widget Function({
required List<Override> overrides,
_FakeProfileNotifier? profile,
})
buildTestable,
) { ) {
testWidgets('unread deadline lands the destination without a 20-second wait', ( testWidgets('unread deadline lands the destination without a 20-second wait', (
tester, tester,
@@ -17,8 +21,11 @@ void _communityUnreadLandingTests(
]; ];
final communitiesNotifier = _FakeCommunityListNotifier(communities); final communitiesNotifier = _FakeCommunityListNotifier(communities);
final session = _UnreadDeadlineLandingSession(communitiesNotifier); final session = _UnreadDeadlineLandingSession(communitiesNotifier);
// The header avatar's own profile never arrives. It must not gate landing.
final profile = _StalledProfileNotifier();
await tester.pumpWidget( await tester.pumpWidget(
buildTestable( buildTestable(
profile: profile,
overrides: [ overrides: [
channelSectionsProvider.overrideWith(_LandingSections.new), channelSectionsProvider.overrideWith(_LandingSections.new),
channelStarsProvider.overrideWith(_LandingStars.new), channelStarsProvider.overrideWith(_LandingStars.new),
@@ -65,6 +72,7 @@ void _communityUnreadLandingTests(
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
} }
expect(session.destinationUnreadRequests, greaterThan(0)); expect(session.destinationUnreadRequests, greaterThan(0));
expect(profile.stalled, isTrue);
expect(find.byKey(const Key('community-switcher-page')), findsNothing); expect(find.byKey(const Key('community-switcher-page')), findsNothing);
expect(find.byKey(const Key('community-switch-loading')), findsNothing); expect(find.byKey(const Key('community-switch-loading')), findsNothing);
expect(find.text('bravo'), findsNothing); expect(find.text('bravo'), findsNothing);
@@ -158,3 +166,144 @@ class _LandingSort extends ChannelSortNotifier {
@override @override
ChannelSortState build() => const ChannelSortState(isReady: true); ChannelSortState build() => const ChannelSortState(isReady: true);
} }
class _StalledProfileNotifier extends _FakeProfileNotifier {
bool stalled = false;
int _builds = 0;
@override
Future<UserProfile?> build() {
// Rebuild on the community switch, as the real profile does per relay.
ref.watch(activeCommunityProvider);
// Origin Home loads normally; every later rebuild hangs forever.
if (_builds++ == 0) return super.build();
stalled = true;
return Completer<UserProfile?>().future;
}
}
void _communityCachedLandingTests(
Widget Function({required List<Override> overrides}) buildTestable,
) {
testWidgets('a cached destination lands while its relay never answers', (
tester,
) async {
final communities = [
for (final id in ['alpha', 'bravo'])
Community(
id: id,
name: id,
relayUrl: 'wss://$id.example.com',
addedAt: DateTime(2025),
),
];
final cached = Channel(
id: 'bravo-cached',
name: 'bravo-cached-channel',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'pk',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelListSnapshotStorage(prefs);
expect(
await storage.write(
storage.capture('pk', 'wss://bravo.example.com'),
ChannelListSnapshot(channels: [cached.toJson()], hiddenDmIds: {}),
),
isTrue,
);
final communitiesNotifier = _FakeCommunityListNotifier(communities);
final session = _StalledDestinationSession(communitiesNotifier);
await tester.pumpWidget(
buildTestable(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
// Remote preference state never becomes ready for the destination.
channelSectionsProvider.overrideWith(_UnreadyLandingSections.new),
channelStarsProvider.overrideWith(_LandingStars.new),
channelMutesProvider.overrideWith(_LandingMutes.new),
channelSortProvider.overrideWith(_LandingSort.new),
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
),
),
myPubkeyProvider.overrideWith((ref) => 'pk'),
relaySessionProvider.overrideWith(() => session),
communityListProvider.overrideWith(() => communitiesNotifier),
activeCommunityProvider.overrideWith((ref) async {
await ref.watch(communityListProvider.future);
return communities.firstWhere(
(c) => c.id == communitiesNotifier.activeId,
);
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(widget) =>
widget is Semantics &&
widget.properties.label == 'Community settings',
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('community-menu-switch')));
await tester.pumpAndSettle();
session.stallDestination = true;
await tester.tap(find.byKey(const Key('community-switcher-row-bravo')));
// Only animation frames: the relay never answers, nothing hits a deadline.
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(session.stalledRequests, greaterThan(0));
expect(find.byKey(const Key('community-switcher-page')), findsNothing);
expect(find.byKey(const Key('community-switch-loading')), findsNothing);
expect(find.text('bravo-cached-channel'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
/// Answers the origin normally; destination requests never complete.
class _StalledDestinationSession extends _UnreadDeadlineLandingSession {
_StalledDestinationSession(super.communities);
bool stallDestination = false;
int stalledRequests = 0;
Future<T>? _stall<T>() {
if (!stallDestination || communities.activeId != 'bravo') return null;
stalledRequests++;
return Completer<T>().future;
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) => _stall() ?? super.queryRelay(filters, timeout: timeout);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) => _stall() ?? super.fetchHistory(filter, timeout: timeout);
}
class _UnreadyLandingSections extends ChannelSectionsNotifier {
@override
ChannelSectionsState build() => const ChannelSectionsState(isReady: false);
}

View File

@@ -0,0 +1,289 @@
part of 'channels_provider_test.dart';
void _requestPathCases() {
test('DM hydration overlaps history and shares admitted profiles', () async {
final session = _RequestPathSession();
final profilesGate = session.profilesGate = Completer<void>();
final latestGate = session.latestGate = Completer<void>();
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final load = container.read(channelsProvider.future);
await _settle();
expect(session.profileRequests, 1);
expect(session.latestRequests, 1);
expect(container.read(channelsProvider).hasValue, isFalse);
final cacheReady = Completer<void>();
final cacheSubscription = container.listen(userCacheProvider, (_, next) {
if (next.containsKey('alice') && !cacheReady.isCompleted) {
cacheReady.complete();
}
});
addTearDown(cacheSubscription.close);
profilesGate.complete();
await cacheReady.future;
expect(container.read(channelsProvider).hasValue, isFalse);
latestGate.complete();
final channel = (await load).single;
expect(channel.displayLabel(currentPubkey: 'me'), 'Alice');
expect(channel.memberCount, 2);
expect(channel.lastMessageAt?.millisecondsSinceEpoch, 50000);
expect(
container.read(userCacheProvider)['alice']?.avatarUrl,
'https://example.com/alice.png',
);
expect(
await container.read(userCacheProvider.notifier).preload(['alice']),
isTrue,
);
expect(session.profileRequests, 1);
// Full refreshes discover relay-side profile changes without another
// surface injecting a live cache event. Blank names retain the fallback.
session.profiles = [
_requestProfile(
'alice',
name: ' ',
handle: ' alice@example.com ',
createdAt: 2,
),
];
// Ordinary reconnects stay metadata-only and reuse the admitted profile.
session.setStatus(SessionStatus.disconnected);
await _settle();
session.setStatus(SessionStatus.connected);
await _settle();
expect(session.profileRequests, 1);
expect(
container
.read(channelsProvider)
.requireValue
.single
.displayLabel(currentPubkey: 'me'),
'Alice',
);
await container.read(channelsProvider.notifier).refresh();
expect(
container
.read(channelsProvider)
.requireValue
.single
.displayLabel(currentPubkey: 'me'),
'alice@example.com',
);
expect(session.profileRequests, 2);
expect(
container.read(userCacheProvider)['alice']?.nip05Handle?.trim(),
'alice@example.com',
);
session.profiles = [
_requestProfile('alice', name: ' ', handle: ' ', createdAt: 3),
];
await container.read(channelsProvider.notifier).refresh();
expect(
container
.read(channelsProvider)
.requireValue
.single
.displayLabel(currentPubkey: 'me'),
'Unknown identity',
);
expect(session.profileRequests, 3);
});
test(
'late DM profiles cannot enter the destination cache or channel list',
() async {
final session = _RequestPathSession();
final profilesGate = session.profilesGate = Completer<void>();
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final oldLoad = container.read(channelsProvider.future);
await _settle();
expect(session.profileRequests, 1);
session.profiles = [_requestProfile('alice', name: 'Destination Alice')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://destination.example');
final destination = await container.read(channelsProvider.future);
expect(
destination.single.displayLabel(currentPubkey: 'me'),
'Destination Alice',
);
profilesGate.complete();
await oldLoad;
await _settle();
expect(
container.read(userCacheProvider)['alice']?.displayName,
'Destination Alice',
);
expect(
container
.read(channelsProvider)
.requireValue
.single
.displayLabel(currentPubkey: 'me'),
'Destination Alice',
);
},
);
test(
'DM profile failures propagate while concurrent history is pending',
() async {
final failure = StateError('profile request failed');
final session = _RequestPathSession()..profileFailure = failure;
final latestGate = session.latestGate = Completer<void>();
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await expectLater(
container.read(channelsProvider.future),
throwsA(same(failure)),
);
expect(session.latestRequests, 1);
latestGate.complete();
await _settle();
expect(container.read(channelsProvider).error, same(failure));
},
);
test(
'latest discovered roster owns Huddle filtering and member counts',
() async {
final session = _FakeRelaySession(
memberships: [
_membership(
_channelA,
'me',
additionalPubkey: 'alice',
createdAt: 2,
eventId: 'new-a',
),
_membership(
_channelB,
'me',
ownerPubkey: 'me',
createdAt: 2,
eventId: 'new-b',
),
_membership(_channelA, 'me', eventId: 'old-a'),
_membership(
_channelB,
'me',
ownerPubkey: 'imposter',
eventId: 'old-b',
),
],
metadata: [
_meta(id: _channelA, name: 'General'),
_meta(id: _channelB, name: 'Huddle', visibility: 'private'),
],
huddleStarts: [
_huddleStart(pubkey: 'me', parent: _channelA, backing: _channelB),
],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final channels = await container.read(channelsProvider.future);
expect(channels.single.id, _channelA);
expect(channels.single.memberCount, 2);
expect(
container
.read(channelsProvider.notifier)
.cachedMembersForChannel(_channelA)
.map((member) => member.pubkey),
['me', 'alice'],
);
},
);
}
NostrEvent _requestProfile(
String pubkey, {
required String name,
String? handle,
int createdAt = 1,
}) => NostrEvent(
id: 'profile-$pubkey-$createdAt',
pubkey: pubkey,
createdAt: createdAt,
kind: 0,
tags: const [],
content: jsonEncode({
'display_name': name,
'nip05': handle,
'picture': 'https://example.com/$pubkey.png',
}),
sig: 'sig',
);
class _RequestPathSession extends _FakeRelaySession {
_RequestPathSession()
: super(
memberships: [_membership(_channelA, 'me', additionalPubkey: 'alice')],
metadata: [
_meta(
id: _channelA,
name: 'DM',
channelType: 'dm',
participants: ['me', 'alice'],
),
],
recentMessages: const [
NostrEvent(
id: 'dm-latest',
pubkey: 'alice',
createdAt: 50,
kind: 9,
tags: [
['h', _channelA],
],
content: 'Hello',
sig: 'sig',
),
],
);
List<NostrEvent> profiles = [_requestProfile('alice', name: 'Alice')];
Completer<void>? profilesGate;
Completer<void>? latestGate;
Object? profileFailure;
int profileRequests = 0;
int latestRequests = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (!filter.kinds.contains(0)) {
return super.fetchHistory(filter, timeout: timeout);
}
profileRequests++;
final snapshot = List<NostrEvent>.of(profiles);
final gate = profilesGate;
profilesGate = null;
await gate?.future;
if (profileFailure case final failure?) throw failure;
return snapshot;
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filters.isNotEmpty &&
filters.every(
(filter) => filter.kinds.contains(9) && filter.since == null,
)) {
latestRequests++;
final gate = latestGate;
latestGate = null;
await gate?.future;
}
return super.queryRelay(filters, timeout: timeout);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:fake_async/fake_async.dart'; import 'package:fake_async/fake_async.dart';
@@ -9,10 +10,12 @@ import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
part 'channels_provider_live_cases.dart'; part 'channels_provider_live_cases.dart';
part 'channels_provider_terminal_cases.dart'; part 'channels_provider_terminal_cases.dart';
part 'channels_provider_readiness_cases.dart'; part 'channels_provider_readiness_cases.dart';
part 'channels_provider_request_path_cases.dart';
/// Tests for [ChannelsNotifier] in the pure-Nostr world. /// Tests for [ChannelsNotifier] in the pure-Nostr world.
/// ///
@@ -28,6 +31,7 @@ part 'channels_provider_readiness_cases.dart';
/// events on demand. /// events on demand.
void main() { void main() {
_unreadReadinessCases(); _unreadReadinessCases();
_requestPathCases();
const myPk = 'me'; const myPk = 'me';
test( test(
@@ -630,15 +634,15 @@ void main() {
expect(await container.read(channelsProvider.future), isEmpty); expect(await container.read(channelsProvider.future), isEmpty);
// The directory refresh parks on the member-count query and fails there // The directory refresh parks on the member-metadata query and fails there
// after the switch. A retired failure must not // after the switch. A retired failure must not
// overwrite the new scope's list or push it into an error state. // overwrite the new scope's list or push it into an error state.
session.memberships = [_membership(_channelA, myPk)]; session.memberships = [_membership(_channelA, myPk)];
session.pauseNextMemberCountQuery(); session.pauseNextMemberMetadataQuery();
final staleRefresh = container final staleRefresh = container
.read(channelsProvider.notifier) .read(channelsProvider.notifier)
.retryDirectory(); .retryDirectory();
await session.nextMemberCountQueryStarted; await session.nextMemberMetadataQueryStarted;
session.memberships = [_membership(_channelB, myPk)]; session.memberships = [_membership(_channelB, myPk)];
session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
@@ -647,8 +651,8 @@ void main() {
.update(baseUrl: 'https://community-b.example'); .update(baseUrl: 'https://community-b.example');
await container.read(channelsProvider.future); await container.read(channelsProvider.future);
session.failClaimedMemberCountQuery = true; session.failClaimedMemberMetadataQuery = true;
session.resumePausedMemberCountQuery(); session.resumePausedMemberMetadataQuery();
await staleRefresh; await staleRefresh;
await _settle(); await _settle();
@@ -722,19 +726,19 @@ void main() {
expect(await container.read(channelsProvider.future), isEmpty); expect(await container.read(channelsProvider.future), isEmpty);
session.memberships = [_membership(_channelA, myPk)]; session.memberships = [_membership(_channelA, myPk)];
session.pauseNextMemberCountQuery(); session.pauseNextMemberMetadataQuery();
final staleRefresh = container final staleRefresh = container
.read(channelsProvider.notifier) .read(channelsProvider.notifier)
.retryDirectory(); .retryDirectory();
await session.nextMemberCountQueryStarted; await session.nextMemberMetadataQueryStarted;
session.memberships = [_membership(_channelB, _otherPk)]; session.memberships = [_membership(_channelB, _otherPk)];
session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')];
container.read(_testPubkeyProvider.notifier).set(_otherPk); container.read(_testPubkeyProvider.notifier).set(_otherPk);
await container.read(channelsProvider.future); await container.read(channelsProvider.future);
session.failClaimedMemberCountQuery = true; session.failClaimedMemberMetadataQuery = true;
session.resumePausedMemberCountQuery(); session.resumePausedMemberMetadataQuery();
await staleRefresh; await staleRefresh;
await _settle(); await _settle();
@@ -754,11 +758,10 @@ void main() {
final container = _buildContainer(session: session); final container = _buildContainer(session: session);
addTearDown(container.dispose); addTearDown(container.dispose);
// Park all four reads. Any of them waiting on another would leave that one // Park all three reads. Any of them waiting on another would leave that one
// unsent. // unsent.
final metadataGate = session.metadataGate = Completer<void>(); final metadataGate = session.metadataGate = Completer<void>();
session.pauseNextHiddenDmQuery(); session.pauseNextHiddenDmQuery();
session.pauseNextMemberCountQuery();
session.pauseNextHuddleStartQuery(); session.pauseNextHuddleStartQuery();
final load = container.read(channelsProvider.future); final load = container.read(channelsProvider.future);
await _settle(); await _settle();
@@ -771,17 +774,11 @@ void main() {
}; };
expect( expect(
sentKinds, sentKinds,
containsAll([ containsAll([39000, EventKind.dmVisibility, EventKind.huddleStarted]),
39000,
39002,
EventKind.dmVisibility,
EventKind.huddleStarted,
]),
); );
metadataGate.complete(); metadataGate.complete();
session.resumePausedHiddenDmQuery(); session.resumePausedHiddenDmQuery();
session.resumePausedMemberCountQuery();
session.resumePausedHuddleStartQuery(); session.resumePausedHuddleStartQuery();
expect((await load).map((channel) => channel.id), [_channelA]); expect((await load).map((channel) => channel.id), [_channelA]);
}); });
@@ -881,8 +878,8 @@ void main() {
await container.read(channelsProvider.future); await container.read(channelsProvider.future);
final notifier = container.read(channelsProvider.notifier); final notifier = container.read(channelsProvider.notifier);
// Park an older refresh on its Huddle-start query. Both of its membership // Park an older refresh on its Huddle-start query. Its membership snapshot
// fetches have already landed, so channel A is the list it is carrying. // has already landed, so channel A is the list it is carrying.
session.pauseNextHuddleStartQuery(); session.pauseNextHuddleStartQuery();
final olderRefresh = notifier.refresh(); final olderRefresh = notifier.refresh();
await session.nextHuddleStartQueryStarted; await session.nextHuddleStartQueryStarted;
@@ -2566,10 +2563,12 @@ NostrEvent _membership(
String pubkey, { String pubkey, {
String? additionalPubkey, String? additionalPubkey,
String? ownerPubkey, String? ownerPubkey,
int createdAt = 1,
String? eventId,
}) => NostrEvent( }) => NostrEvent(
id: 'mem-$channelId', id: eventId ?? 'mem-$channelId',
pubkey: 'creator', pubkey: 'creator',
createdAt: 1, createdAt: createdAt,
kind: 39002, kind: 39002,
tags: [ tags: [
['d', channelId], ['d', channelId],
@@ -2605,6 +2604,7 @@ NostrEvent _meta({
int createdAt = 1, int createdAt = 1,
int? ttlSeconds, int? ttlSeconds,
bool archived = false, bool archived = false,
List<String> participants = const [],
}) => NostrEvent( }) => NostrEvent(
id: 'meta-$id', id: 'meta-$id',
pubkey: 'creator', pubkey: 'creator',
@@ -2617,6 +2617,7 @@ NostrEvent _meta({
[visibility == 'private' ? 'private' : 'public'], [visibility == 'private' ? 'private' : 'public'],
if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
if (archived) ['archived', 'true'], if (archived) ['archived', 'true'],
for (final participant in participants) ['p', participant],
], ],
content: '', content: '',
sig: 'sig', sig: 'sig',
@@ -2700,7 +2701,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> recentMessages; List<NostrEvent> recentMessages;
int membershipFailures; int membershipFailures;
int directoryFailures = 0; int directoryFailures = 0;
bool failClaimedMemberCountQuery = false; bool failClaimedMemberMetadataQuery = false;
bool failClaimedUnreadCatchUpQuery = false; bool failClaimedUnreadCatchUpQuery = false;
int membershipRequestCount = 0; int membershipRequestCount = 0;
int metadataPageRequestCount = 0; int metadataPageRequestCount = 0;
@@ -2731,9 +2732,9 @@ class _FakeRelaySession extends RelaySessionNotifier {
Completer<void>? _pausedHiddenDm; Completer<void>? _pausedHiddenDm;
Completer<void>? _hiddenDmStarted; Completer<void>? _hiddenDmStarted;
Completer<void>? _claimedHiddenDm; Completer<void>? _claimedHiddenDm;
Completer<void>? _pausedMemberCount; Completer<void>? _pausedMemberMetadata;
Completer<void>? _memberCountStarted; Completer<void>? _memberMetadataStarted;
Completer<void>? _claimedMemberCount; Completer<void>? _claimedMemberMetadata;
Completer<void>? _pausedHuddleStarts; Completer<void>? _pausedHuddleStarts;
Completer<void>? _huddleStartsStarted; Completer<void>? _huddleStartsStarted;
Completer<void>? _claimedHuddleStarts; Completer<void>? _claimedHuddleStarts;
@@ -2831,7 +2832,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
/// Holds the next Huddle-start query open, one shot only. /// Holds the next Huddle-start query open, one shot only.
/// ///
/// Parks the older refresh AFTER both membership fetches have landed, so the /// Parks the older refresh AFTER membership discovery has landed, so the
/// only guard left between the park and the member-snapshot write is the /// only guard left between the park and the member-snapshot write is the
/// fence on this leg. /// fence on this leg.
void pauseNextHuddleStartQuery() { void pauseNextHuddleStartQuery() {
@@ -2860,28 +2861,28 @@ class _FakeRelaySession extends RelaySessionNotifier {
paused.complete(); paused.complete();
} }
/// Holds the next member-count query open, one shot only. /// Holds the next member-metadata query open, one shot only.
void pauseNextMemberCountQuery() { void pauseNextMemberMetadataQuery() {
if (_pausedMemberCount != null) { if (_pausedMemberMetadata != null) {
throw StateError('A member-count query is already paused'); throw StateError('A member-metadata query is already paused');
} }
_pausedMemberCount = Completer<void>(); _pausedMemberMetadata = Completer<void>();
_memberCountStarted = Completer<void>(); _memberMetadataStarted = Completer<void>();
} }
/// Completes once the parked member-count query has been requested. /// Completes once the parked member-metadata query has been requested.
Future<void> get nextMemberCountQueryStarted async { Future<void> get nextMemberMetadataQueryStarted async {
final started = _memberCountStarted; final started = _memberMetadataStarted;
if (started == null) { if (started == null) {
throw StateError('No member-count query is pending'); throw StateError('No member-metadata query is pending');
} }
await started.future; await started.future;
} }
/// Releases the parked member-count query so its response lands. /// Releases the parked member-metadata query so its response lands.
void resumePausedMemberCountQuery() { void resumePausedMemberMetadataQuery() {
final paused = _claimedMemberCount ?? _pausedMemberCount; final paused = _claimedMemberMetadata ?? _pausedMemberMetadata;
if (paused == null) throw StateError('No member-count query is paused'); if (paused == null) throw StateError('No member-metadata query is paused');
paused.complete(); paused.complete();
} }
@@ -2929,21 +2930,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
throw Exception('restricted: too many explicit channels'); throw Exception('restricted: too many explicit channels');
} }
if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { if (filter.kinds.contains(39002) && filter.tags['#d'] != null) {
final paused = _pausedMemberCount; throw StateError('Membership rosters must reuse discovery events');
if (paused != null) {
_claimedMemberCount = paused;
_pausedMemberCount = null;
_memberCountStarted!.complete();
_memberCountStarted = null;
await paused.future;
if (failClaimedMemberCountQuery) {
throw Exception('member-count fetch failed');
}
}
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();
return memberships
.where((event) => ids.contains(event.getTagValue('d')))
.toList();
} }
if (filter.kinds.contains(39002) && filter.tags['#p'] != null) { if (filter.kinds.contains(39002) && filter.tags['#p'] != null) {
membershipRequestCount++; membershipRequestCount++;
@@ -2974,7 +2961,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
} }
if (filter.kinds.contains(EventKind.huddleStarted)) { if (filter.kinds.contains(EventKind.huddleStarted)) {
// Claim the parked slot so the newer refresh's own Huddle query runs // Claim the parked slot so the newer refresh's own Huddle query runs
// unblocked: one shot, exactly like the hidden-DM and member-count hooks. // unblocked: one shot, exactly like the hidden-DM and member-metadata hooks.
final paused = _pausedHuddleStarts; final paused = _pausedHuddleStarts;
if (paused != null) { if (paused != null) {
_claimedHuddleStarts = paused; _claimedHuddleStarts = paused;
@@ -2993,9 +2980,22 @@ class _FakeRelaySession extends RelaySessionNotifier {
if (ids == null) { if (ids == null) {
throw StateError('Directory queries must use the HTTP query bridge'); throw StateError('Directory queries must use the HTTP query bridge');
} }
final snapshot = metadata
.where((e) => ids.contains(e.getTagValue('d')))
.toList();
final paused = _pausedMemberMetadata;
if (paused != null) {
_claimedMemberMetadata = paused;
_pausedMemberMetadata = null;
_memberMetadataStarted!.complete();
_memberMetadataStarted = null;
await paused.future;
if (failClaimedMemberMetadataQuery) {
throw Exception('member-metadata fetch failed');
}
}
await metadataGate?.future; await metadataGate?.future;
// Member metadata query — return only matching `d` tags. return snapshot;
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
} }
// Per-filter websocket fallback of a message batch. // Per-filter websocket fallback of a message batch.
messageHistoryCount++; messageHistoryCount++;

View File

@@ -5,6 +5,7 @@ import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/relay/relay.dart';
@@ -19,8 +20,29 @@ import 'package:pointycastle/digests/sha256.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class _EmptyChannels extends ChannelsNotifier { class _EmptyChannels extends ChannelsNotifier {
_EmptyChannels(this.existing);
final bool existing;
int freshReads = 0;
@override @override
Future<List<Channel>> build() async => []; Future<List<Channel>> build() async => [];
@override
Future<List<Channel>> fetchFresh({bool fetchDirectory = false}) async {
freshReads++;
return [
if (existing)
Channel(
id: '10000000-0000-4000-8000-000000000001',
name: 'Mobile Solo',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'me',
createdAt: DateTime.utc(2026),
memberCount: 1,
isMember: true,
),
];
}
} }
class _Relay extends RelaySessionNotifier { class _Relay extends RelaySessionNotifier {
@@ -32,7 +54,8 @@ class _Relay extends RelaySessionNotifier {
Future<List<NostrEvent>> queryRelay( Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, { List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8), Duration timeout = const Duration(seconds: 8),
}) async => [profile]; }) async =>
filters.any((filter) => filter.kinds.contains(0)) ? [profile] : [];
@override @override
Future<List<NostrEvent>> fetchHistory( Future<List<NostrEvent>> fetchHistory(
@@ -53,94 +76,120 @@ class _Relay extends RelaySessionNotifier {
} }
void main() { void main() {
testWidgets( for (final recoverExisting in [false, true]) {
'New Solo chat submits its discovery label through the production action', testWidgets(
(tester) async { recoverExisting
SharedPreferences.setMockInitialValues({}); ? 'interrupted Solo recovery uses fresh membership before creating'
final prefs = await SharedPreferences.getInstance(); : 'New Solo chat submits its discovery label through the production action',
final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); (tester) async {
final digest = SHA256Digest() SharedPreferences.setMockInitialValues({});
.process( final prefs = await SharedPreferences.getInstance();
Uint8List.fromList( final owner = nostr.Keys.generate(), agent = nostr.Keys.generate();
utf8.encode('nostr:agent-auth:${agent.public}:'), final digest = SHA256Digest()
), .process(
) Uint8List.fromList(
.map((v) => v.toRadixString(16).padLeft(2, '0')) utf8.encode('nostr:agent-auth:${agent.public}:'),
.join();
final profile = NostrEvent.fromJson(
nostr.Event.from(
kind: 0,
content: jsonEncode({'display_name': 'Owned agent'}),
secretKey: agent.secret,
tags: [
[
'auth',
owner.public,
'',
nostr.Schnorr.sign(secretKey: owner.secret, message: digest),
],
],
).toMap(),
);
final relay = _Relay(profile);
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: owner.nsec,
);
final sync = MeSync(relay, config, prefs: prefs);
const bridge = MethodChannel('buzz/profile_text_editor');
var forms = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
if (++forms > 1) return null;
return {'name': 'Mobile Solo', 'assistant': agent.public};
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
bridge,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_EmptyChannels.new),
channelActionsProvider.overrideWith(
(ref) => ChannelActions(
ref: ref,
session: relay,
signedEventRelay: SignedEventRelay(
session: relay,
nsec: owner.nsec,
),
currentPubkey: owner.public,
), ),
), )
], .map((v) => v.toRadixString(16).padLeft(2, '0'))
child: MaterialApp(theme: AppTheme.light(), home: const MePage()), .join();
), final profile = NostrEvent.fromJson(
); nostr.Event.from(
await tester.pumpAndSettle(); kind: 0,
tester content: jsonEncode({'display_name': 'Owned agent'}),
.widget<FrostedAppBar>(find.byType(FrostedAppBar)) secretKey: agent.secret,
.nativeActions! tags: [
.firstWhere((action) => action.label == 'New chat') [
.onPressed!(); 'auth',
await tester.pumpAndSettle(); owner.public,
final command = relay.submitted.single; '',
expect(command.kind, 9007); nostr.Schnorr.sign(secretKey: owner.secret, message: digest),
expect(command.pubkey, owner.public); ],
expect(command.tags, contains(equals(['t', 'buzz.solo']))); ],
expect(command.tags, contains(equals(['channel_type', 'stream']))); ).toMap(),
expect(command.tags, contains(equals(['visibility', 'private']))); );
expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); final relay = _Relay(profile);
}, final config = RelayConfig(
variant: TargetPlatformVariant.only(TargetPlatform.iOS), baseUrl: 'https://relay.test',
); nsec: owner.nsec,
);
final sync = MeSync(relay, config, prefs: prefs);
final channels = _EmptyChannels(recoverExisting);
if (recoverExisting) {
await MeChatCreation(
'10000000-0000-4000-8000-000000000001',
'Mobile Solo',
agent.public,
).save(sync);
}
const bridge = MethodChannel('buzz/profile_text_editor');
var forms = 0;
final formErrors = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
formErrors.add((call.arguments as Map)['error']);
if (++forms > 1) return null;
return {'name': 'Mobile Solo', 'assistant': agent.public};
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
bridge,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(() => channels),
channelMembersProvider(
'10000000-0000-4000-8000-000000000001',
).overrideWith((ref) async => []),
channelActionsProvider.overrideWith(
(ref) => ChannelActions(
ref: ref,
session: relay,
signedEventRelay: SignedEventRelay(
session: relay,
nsec: owner.nsec,
),
currentPubkey: owner.public,
),
),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((action) => action.label == 'New chat')
.onPressed!();
await tester.pumpAndSettle();
if (recoverExisting) {
await tester.tap(find.text('Resume'));
await tester.pumpAndSettle();
// Continue to member setup using the existing channel. A stale lookup
// would instead submit a second kind:9007 creation command.
expect(channels.freshReads, 1, reason: formErrors.toString());
expect(relay.submitted.where((e) => e.kind == 9007), isEmpty);
return;
}
final command = relay.submitted.single;
expect(command.kind, 9007);
expect(command.pubkey, owner.public);
expect(command.tags, contains(equals(['t', 'buzz.solo'])));
expect(command.tags, contains(equals(['channel_type', 'stream'])));
expect(command.tags, contains(equals(['visibility', 'private'])));
expect(command.tags.where((tag) => tag.first == 'P'), isEmpty);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
} }

View File

@@ -0,0 +1,95 @@
import 'package:buzz/app.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/invites/invite_join_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
test(
'invite recovery propagates fresh-read failure despite cached memberships',
() async {
final keys = nostr.Keys.generate();
final scope = InviteJoinRecoveryScope(
relayHttpOrigin: 'https://relay.example',
nsec: keys.nsec,
);
final channels = _CachedChannels();
final recovery = Provider(
(ref) => buildMobileInviteJoinRecovery(ref, scope),
);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => _Config(keys.nsec)),
relaySessionProvider.overrideWith(_Connected.new),
activeCommunityProvider.overrideWith(
(ref) async => Community.create(
name: 'Test',
relayUrl: scope.relayHttpOrigin,
nsec: keys.nsec,
),
),
channelsProvider.overrideWith(() => channels),
],
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
await expectLater(
container.read(recovery).ensureStarterChannels(),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
'membership fetch failed',
),
),
);
expect(channels.freshReads, 1);
expect(container.read(channelsProvider).requireValue, hasLength(2));
},
);
}
class _CachedChannels extends ChannelsNotifier {
int freshReads = 0;
@override
Future<List<Channel>> build() async => [
for (final name in ['general', 'welcome-everyone'])
Channel(
id: name,
name: name,
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'me',
createdAt: DateTime.utc(2026),
memberCount: 1,
isMember: true,
),
];
// A best-effort refresh can retain stale data without failing its caller.
@override
Future<void> refresh({bool fetchDirectory = false}) async {}
@override
Future<List<Channel>> fetchFresh({bool fetchDirectory = false}) async {
freshReads++;
throw StateError('membership fetch failed');
}
}
class _Config extends RelayConfigNotifier {
_Config(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _Connected extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
}

View File

@@ -52,7 +52,29 @@ void main() {
); );
addTearDown(container.dispose); addTearDown(container.dispose);
// The profile survives reconnect transitions without launching reads on
// each intermediate socket state, then revalidates once connected.
container.read(relaySessionProvider);
relaySession.setStatus(SessionStatus.disconnected);
final subscription = container.listen(profileProvider, (_, _) {});
addTearDown(subscription.close);
await container.pump();
relaySession.setStatus(SessionStatus.connecting);
await container.pump();
expect(relaySession.reads, 0);
relaySession.setStatus(SessionStatus.connected);
await container.read(profileProvider.future); await container.read(profileProvider.future);
final initialReads = relaySession.reads;
relaySession.setStatus(SessionStatus.disconnected);
await container.pump();
relaySession.setStatus(SessionStatus.connecting);
await container.pump();
expect(container.read(profileProvider).requireValue?.displayName, 'Alice');
expect(relaySession.reads, initialReads);
relaySession.setStatus(SessionStatus.connected);
await container.pump();
await container.read(profileProvider.future);
expect(relaySession.reads, initialReads + 1);
await container.read(profileProvider.notifier).updateDisplayName('Alice L'); await container.read(profileProvider.notifier).updateDisplayName('Alice L');
expect(relaySession.published, hasLength(1)); expect(relaySession.published, hasLength(1));
@@ -681,6 +703,9 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier {
class _ProfileRelaySession extends RelaySessionNotifier { class _ProfileRelaySession extends RelaySessionNotifier {
_ProfileRelaySession(this.profile); _ProfileRelaySession(this.profile);
int reads = 0;
void setStatus(SessionStatus status) => state = SessionState(status: status);
final NostrEvent profile; final NostrEvent profile;
final List<NostrEvent> published = []; final List<NostrEvent> published = [];
@@ -691,7 +716,10 @@ class _ProfileRelaySession extends RelaySessionNotifier {
Future<List<NostrEvent>> fetchHistory( Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, { NostrFilter filter, {
Duration timeout = const Duration(seconds: 8), Duration timeout = const Duration(seconds: 8),
}) async => [profile, ...published]; }) async {
reads++;
return [profile, ...published];
}
@override @override
Future<NostrEvent> publish( Future<NostrEvent> publish(

View File

@@ -0,0 +1,190 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/shared/relay/relay_provider.dart' show RelayConfig;
import 'package:buzz/shared/channels/channel_list_snapshot.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
const _pubkey = 'ABCDEF';
const _relay = 'https://relay.example';
final _channel = Channel(
id: 'dm-1',
name: 'DM',
channelType: 'dm',
labels: const ['buzz.solo'],
visibility: 'private',
description: '',
topic: 'topic',
purpose: 'purpose',
createdBy: 'creator',
createdAt: DateTime.utc(2026, 1, 2),
memberCount: 2,
lastMessageAt: DateTime.utc(2026, 1, 3),
archivedAt: DateTime.utc(2026, 1, 4),
participants: const ['Alice'],
participantPubkeys: const ['alice'],
isMember: true,
ttlSeconds: 60,
ttlDeadline: DateTime.utc(2026, 1, 5),
);
ChannelListSnapshot _snapshot([List<Channel>? channels]) => ChannelListSnapshot(
channels: [
for (final c in channels ?? [_channel]) c.toJson(),
],
hiddenDmIds: const {'dm-2'},
);
void main() {
late SharedPreferences prefs;
late ChannelListSnapshotStorage storage;
late _SnapshotStore store;
setUp(() async {
SharedPreferences.setMockInitialValues({});
store = _SnapshotStore();
SharedPreferencesStorePlatform.instance = store;
prefs = await SharedPreferences.getInstance();
storage = ChannelListSnapshotStorage(prefs);
});
test('round-trips every channel field and hidden DMs', () async {
final token = storage.capture(_pubkey, _relay);
expect(await storage.write(token, _snapshot()), isTrue);
final restored = storage.read(_pubkey, _relay)!;
final channel = Channel.fromJson(restored.channels.single);
expect(channel.toJson(), _channel.toJson());
expect(channel.isArchived, isTrue);
expect(channel.isEphemeral, isTrue);
expect(restored.hiddenDmIds, {'dm-2'});
});
test('scopes by identity and relay, folding websocket origins', () async {
await storage.write(storage.capture(_pubkey, _relay), _snapshot());
for (final relay in [
'wss://RELAY.example/',
'wss://relay.example:443',
' https://RELAY.example/ ',
]) {
expect(storage.read('abcdef', relay), isNotNull);
}
expect(storage.read(_pubkey, 'wss://relay.example:8443'), isNull);
expect(
ChannelListSnapshotStorage.key(_pubkey, 'ws://relay.example:80'),
ChannelListSnapshotStorage.key(_pubkey, 'http://relay.example'),
);
expect(storage.read('other', _relay), isNull);
expect(storage.read(_pubkey, 'https://other.example'), isNull);
});
test('escaped relay paths have idempotent writer and cleanup keys', () async {
for (final relay in [
'wss://relay.example/a%2Fb',
'wss://relay.example/a%2fb/',
'wss://RELAY.example:443/a%20b',
]) {
final canonical = RelayConfig(baseUrl: relay).baseUrl;
expect(
ChannelListSnapshotStorage.key(_pubkey, relay),
ChannelListSnapshotStorage.key(_pubkey, canonical),
);
final token = storage.capture(_pubkey, canonical);
await storage.write(token, _snapshot());
await storage.removeOrphans({
ChannelListSnapshotStorage.key(_pubkey, relay),
});
expect(storage.read(_pubkey, canonical), isNotNull);
expect(await storage.write(token, _snapshot()), isTrue);
}
});
test(
'unchanged snapshots skip disk writes, failed writes remain retryable',
() async {
final token = storage.capture(_pubkey, _relay);
expect(await storage.write(token, _snapshot()), isTrue);
expect(
await ChannelListSnapshotStorage(prefs).write(token, _snapshot()),
isTrue,
);
expect(store.writes, 1);
final changed = ChannelListSnapshot(channels: [], hiddenDmIds: {});
for (final failure in ['false', 'throw']) {
store.failure = failure;
final result = storage.write(token, changed);
if (failure == 'throw') {
await expectLater(result, throwsStateError);
} else {
expect(await result, isFalse);
}
store.failure = null;
final beforeRetry = store.writes;
expect(
await ChannelListSnapshotStorage(prefs).write(token, changed),
isTrue,
);
expect(store.writes, beforeRetry + 1);
expect(await storage.write(token, _snapshot()), isTrue);
}
},
);
test('removal retires tokens captured before it, on any instance', () async {
final stale = storage.capture(_pubkey, _relay);
await storage.write(stale, _snapshot());
await ChannelListSnapshotStorage(prefs).remove(_pubkey, _relay);
expect(storage.read(_pubkey, _relay), isNull);
expect(await storage.write(stale, _snapshot()), isFalse);
expect(storage.read(_pubkey, _relay), isNull);
final fresh = storage.capture(_pubkey, _relay);
expect(await storage.write(fresh, _snapshot()), isTrue);
});
test('corrupt, unsupported and oversized data is discarded', () async {
final key = ChannelListSnapshotStorage.key(_pubkey, _relay);
final tooMany = List.filled(maxChannelListSnapshotChannels + 1, '{}');
for (final raw in [
'not json',
'{"v":2,"channels":[],"hidden_dm_ids":[]}',
'{"v":1,"channels":[${tooMany.join(',')}],"hidden_dm_ids":[]}',
// Fits in UTF-16 code units, exceeds the cap in UTF-8 bytes.
'{"v":1,"channels":[],"hidden_dm_ids":'
'["${'é' * (maxChannelListSnapshotBytes ~/ 2 + 1)}"]}',
]) {
await prefs.setString(key, raw);
expect(storage.read(_pubkey, _relay), isNull);
expect(prefs.containsKey(key), isFalse);
}
await storage.write(storage.capture(_pubkey, _relay), _snapshot());
final oversized = [
for (var i = 0; i <= maxChannelListSnapshotChannels; i++) _channel,
];
expect(
await storage.write(
storage.capture(_pubkey, _relay),
_snapshot(oversized),
),
isFalse,
);
expect(storage.read(_pubkey, _relay), isNull);
});
}
class _SnapshotStore extends InMemorySharedPreferencesStore {
_SnapshotStore() : super.empty();
int writes = 0;
String? failure;
@override
Future<bool> setValue(String type, String key, Object value) async {
writes++;
if (failure == 'false') return false;
if (failure == 'throw') throw StateError('disk failure');
return super.setValue(type, key, value);
}
}

View File

@@ -3,6 +3,12 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:buzz/shared/channels/channel_list_snapshot.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import 'package:buzz/shared/relay/relay_provider.dart' show RelayConfig;
import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart'; import 'package:buzz/shared/community/community_storage.dart';
@@ -42,7 +48,10 @@ void main() {
tearDown(() => container.dispose()); tearDown(() => container.dispose());
ProviderContainer createContainer() { ProviderContainer createContainer({
List<Override> overrides = const [],
CommunityPushLeaseRevocationEnqueuer? enqueueRevocation,
}) {
Future<void> writeSnapshot(List<Community> communities) async { Future<void> writeSnapshot(List<Community> communities) async {
snapshots.add(List.of(communities)); snapshots.add(List.of(communities));
} }
@@ -52,15 +61,17 @@ void main() {
communityStorageProvider.overrideWithValue(communityStorage), communityStorageProvider.overrideWithValue(communityStorage),
communitySnapshotWriterProvider.overrideWithValue(writeSnapshot), communitySnapshotWriterProvider.overrideWithValue(writeSnapshot),
communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator),
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(
community, enqueueRevocation ??
) async { (community) async {
journaledCommunityIds.add(community.id); journaledCommunityIds.add(community.id);
return true; return true;
}), },
),
communityPushLeaseRevocationTriggerProvider.overrideWithValue( communityPushLeaseRevocationTriggerProvider.overrideWithValue(
revocationTrigger, revocationTrigger,
), ),
...overrides,
], ],
); );
} }
@@ -428,6 +439,187 @@ void main() {
expect(revocationTriggers, 1); expect(revocationTriggers, 1);
}); });
test(
'removal deletes the channel list snapshot and retires writers',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final community = Community.create(
name: 'Cached',
relayUrl: 'wss://cached.example.com:443',
pubkey: 'incorrect-declared-key',
nsec: ' ${keys.nsec} ',
);
final otherScope = Community.create(
name: 'Other',
relayUrl: 'https://other.example.com',
nsec: keys.nsec,
);
final snapshots = ChannelListSnapshotStorage(prefs);
const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {});
await communityStorage.save(community);
await communityStorage.save(otherScope);
// Use the actual provider writer origin, not the raw persisted URL.
final writerRelay = RelayConfig(baseUrl: community.relayUrl).baseUrl;
// A refresh captured its token before removal and writes after it.
final inFlight = snapshots.capture(keys.public, writerRelay);
await snapshots.write(inFlight, snapshot);
await snapshots.write(
snapshots.capture(keys.public, otherScope.relayUrl),
snapshot,
);
container = createContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
final notifier = container.read(communityListProvider.notifier);
await container.read(communityListProvider.future);
await notifier.removeCommunity(community.id);
expect(snapshots.read(keys.public, writerRelay), isNull);
expect(await snapshots.write(inFlight, snapshot), isFalse);
expect(snapshots.read(keys.public, writerRelay), isNull);
expect(snapshots.read(keys.public, otherScope.relayUrl), isNotNull);
},
);
test('startup does not wait for orphan disk deletes', () async {
SharedPreferences.setMockInitialValues({});
final store = _SnapshotStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final cache = ChannelListSnapshotStorage(prefs);
const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {});
for (final id in ['orphan-a', 'orphan-b']) {
await cache.write(
cache.capture(id, 'https://orphan.example'),
snapshot,
);
}
final gate = store.removeGate = Completer<void>();
container = createContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
expect(await container.read(communityListProvider.future), isEmpty);
// All old deletions are submitted before new scopes can start writers.
expect(store.removeAttempts, 2);
final token = cache.capture('orphan-b', 'https://orphan.example');
expect(await cache.write(token, snapshot), isTrue);
gate.complete();
await Future<void>.delayed(Duration.zero);
expect(await cache.write(token, snapshot), isTrue);
expect((await store.getAll()).values, [
prefs.getString(
ChannelListSnapshotStorage.key('orphan-b', 'https://orphan.example'),
),
]);
});
test('startup and credential replacement discard orphan snapshots', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final cache = ChannelListSnapshotStorage(prefs);
const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {});
final oldKeys = nostr.Keys.generate();
final newKeys = nostr.Keys.generate();
final community = Community.create(
name: 'Stored',
relayUrl: 'wss://cached.example.com:443',
nsec: oldKeys.nsec,
);
await communityStorage.save(community);
final oldToken = cache.capture(oldKeys.public, community.relayUrl);
final restoredToken = cache.capture('restored', 'https://orphan.example');
await cache.write(oldToken, snapshot);
await cache.write(restoredToken, snapshot);
await prefs.setString('unrelated', 'keep');
container = createContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
await container.read(communityListProvider.future);
expect(cache.read(oldKeys.public, community.relayUrl), isNotNull);
expect(cache.read('restored', 'https://orphan.example'), isNull);
expect(await cache.write(restoredToken, snapshot), isFalse);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(community);
expect(cache.read(oldKeys.public, community.relayUrl), isNotNull);
expect(await cache.write(oldToken, snapshot), isTrue);
await notifier.addCommunity(community.copyWith(nsec: newKeys.nsec));
expect(cache.read(oldKeys.public, community.relayUrl), isNull);
expect(await cache.write(oldToken, snapshot), isFalse);
final newToken = cache.capture(newKeys.public, community.relayUrl);
await cache.write(newToken, snapshot);
await communityStorage.saveActiveId(community.id);
await notifier.removeActiveCommunityForSignOut();
expect(await communityStorage.loadAll(), isEmpty);
expect(prefs.getKeys(), {'unrelated'});
expect(await cache.write(newToken, snapshot), isFalse);
// A backup restored without secure community records is swept at startup.
container.dispose();
await prefs.setString(
ChannelListSnapshotStorage.key('restored', 'https://orphan.example'),
'{"v":1,"channels":[],"hidden_dm_ids":[]}',
);
container = createContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
await container.read(communityListProvider.future);
expect(prefs.getKeys(), {'unrelated'});
});
for (final failure in ['false', 'throw']) {
test(
'cache delete $failure cannot block sign-out; cleanup retries',
() async {
SharedPreferences.setMockInitialValues({});
final store = _SnapshotStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final community = Community.create(
name: 'Cached',
relayUrl: 'https://cache.example',
nsec: keys.nsec,
);
await communityStorage.save(community);
await communityStorage.saveActiveId(community.id);
final cache = ChannelListSnapshotStorage(prefs);
final token = cache.capture(keys.public, community.relayUrl);
const snapshot = ChannelListSnapshot(channels: [], hiddenDmIds: {});
await cache.write(token, snapshot);
container = createContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
enqueueRevocation: (community) async {
expect(store.removeAttempts, greaterThan(0));
journaledCommunityIds.add(community.id);
return true;
},
);
await container.read(communityListProvider.future);
store.failure = failure;
await container
.read(communityListProvider.notifier)
.removeActiveCommunityForSignOut();
expect(await communityStorage.loadAll(), isEmpty);
expect(await communityStorage.loadActiveId(), isNull);
expect(journaledCommunityIds, [community.id]);
expect(revocationTriggers, 1);
expect(await cache.write(token, snapshot), isFalse);
expect(
(await store.getAll()).keys,
isNotEmpty,
); // Disk deletion failed.
store.failure = null;
container.invalidate(communityListProvider);
await container.read(communityListProvider.future);
expect(await store.getAll(), isEmpty);
},
);
}
test('remote tombstone attempt cannot block local removal', () async { test('remote tombstone attempt cannot block local removal', () async {
final remoteAttempt = Completer<void>(); final remoteAttempt = Completer<void>();
revocationTrigger = () { revocationTrigger = () {
@@ -722,3 +914,19 @@ void main() {
}); });
}); });
} }
class _SnapshotStore extends InMemorySharedPreferencesStore {
_SnapshotStore() : super.empty();
String? failure;
int removeAttempts = 0;
Completer<void>? removeGate;
@override
Future<bool> remove(String key) async {
removeAttempts++;
if (failure == 'false') return false;
if (failure == 'throw') throw StateError('disk failure');
final removed = super.remove(key);
await removeGate?.future;
return removed;
}
}

View File

@@ -141,89 +141,91 @@ void main() {
expect(cache.state['agent']?.displayName, 'Valid'); expect(cache.state['agent']?.displayName, 'Valid');
}); });
for (final preload in [false, true]) { for (final operation in ['refresh', 'preload', 'resolve']) {
test( test('$operation merges after parsing against newer live state', () async {
'${preload ? 'preload' : 'refresh'} merges after parsing against newer live state', final started = Completer<void>();
() async { final release = Completer<void>();
final started = Completer<void>(); final session = _RecordingProfileSession(
final release = Completer<void>(); result: Future.value([
final session = _RecordingProfileSession( _profileEvent(id: 'old', createdAt: 1, name: 'Old'),
result: Future.value([ ]),
_profileEvent(id: 'old', createdAt: 1, name: 'Old'), );
]), final container = ProviderContainer(
); overrides: [
final container = ProviderContainer( relaySessionProvider.overrideWith(() => session),
overrides: [ profileEventBatchParserProvider.overrideWithValue((events) async {
relaySessionProvider.overrideWith(() => session), final parsed = await parseProfileEventBatch(events);
profileEventBatchParserProvider.overrideWithValue((events) async { started.complete();
final parsed = await parseProfileEventBatch(events); await release.future;
started.complete(); return parsed;
await release.future; }),
return parsed; ],
}), );
], addTearDown(container.dispose);
); final cache = container.read(userCacheProvider.notifier);
addTearDown(container.dispose); final result = switch (operation) {
final cache = container.read(userCacheProvider.notifier); 'preload' => cache.preload(['agent']),
final result = preload 'resolve' => cache.resolve(['agent']).then((_) => true),
? cache.preload(['agent']) _ => cache.refresh(['agent']),
: cache.refresh(['agent']); };
await started.future; await started.future;
cache.cacheProfileEvent( cache.cacheProfileEvent(
_profileEvent(id: 'new', createdAt: 2, name: 'Live'), _profileEvent(id: 'new', createdAt: 2, name: 'Live'),
); );
cache.cacheProfileEvent( cache.cacheProfileEvent(
_profileEvent( _profileEvent(
id: 'other', id: 'other',
pubkey: 'other', pubkey: 'other',
createdAt: 1, createdAt: 1,
name: 'Other', name: 'Other',
), ),
); );
release.complete(); release.complete();
expect(await result, isTrue); expect(await result, isTrue);
expect(cache.state['agent']?.displayName, 'Live'); expect(cache.state['agent']?.displayName, 'Live');
expect(cache.state['other']?.displayName, 'Other'); expect(cache.state['other']?.displayName, 'Other');
}, });
);
test( test('$operation rejects a retired parsing completion', () async {
'${preload ? 'preload' : 'refresh'} rejects a retired parsing completion', final started = Completer<void>();
() async { final release = Completer<void>();
final started = Completer<void>(); final session = _RecordingProfileSession(
final release = Completer<void>(); result: Future.value([
final session = _RecordingProfileSession( _profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
result: Future.value([ ]),
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'), );
]), final container = ProviderContainer(
); overrides: [
final container = ProviderContainer( relaySessionProvider.overrideWith(() => session),
overrides: [ profileEventBatchParserProvider.overrideWithValue((events) async {
relaySessionProvider.overrideWith(() => session), final parsed = await parseProfileEventBatch(events);
profileEventBatchParserProvider.overrideWithValue((events) async { started.complete();
final parsed = await parseProfileEventBatch(events); await release.future;
started.complete(); return parsed;
await release.future; }),
return parsed; ],
}), );
], addTearDown(container.dispose);
); final cache = container.read(userCacheProvider.notifier);
addTearDown(container.dispose); final result = switch (operation) {
final cache = container.read(userCacheProvider.notifier); 'preload' => cache.preload(['agent']),
final result = preload 'resolve' => cache.resolve(['agent']).then((_) => true),
? cache.preload(['agent']) _ => cache.refresh(['agent']),
: cache.refresh(['agent']); };
await started.future; await started.future;
// This is the same dependency invalidation caused by community config. // This is the same dependency invalidation caused by community config.
container container
.read(relayConfigProvider.notifier) .read(relayConfigProvider.notifier)
.update(baseUrl: 'https://next-community.invalid'); .update(baseUrl: 'https://next-community.invalid');
container.read(userCacheProvider); container.read(userCacheProvider);
release.complete(); release.complete();
if (operation == 'resolve') {
await expectLater(result, throwsStateError);
} else {
expect(await result, isFalse); expect(await result, isFalse);
expect(container.read(userCacheProvider), isEmpty); }
}, expect(container.read(userCacheProvider), isEmpty);
); });
} }
test( test(