mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -247,9 +247,9 @@ InviteJoinRecovery buildMobileInviteJoinRecovery(
|
||||
.read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
ensureScopeCurrent();
|
||||
await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true);
|
||||
ensureScopeCurrent();
|
||||
final channels = await ref.read(channelsProvider.future);
|
||||
final channels = await ref
|
||||
.read(channelsProvider.notifier)
|
||||
.fetchFresh(fetchDirectory: true);
|
||||
ensureScopeCurrent();
|
||||
return channels;
|
||||
},
|
||||
|
||||
@@ -81,6 +81,28 @@ class Channel {
|
||||
: 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;
|
||||
|
||||
/// Solo presentation is carried by NIP-CL metadata, not project filing.
|
||||
|
||||
@@ -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
|
||||
/// repository-wide 1200-line file ceiling enforced by `just file-size-check`.
|
||||
Future<Map<String, String>> _resolveDmDisplayNames(
|
||||
RelaySessionNotifier session,
|
||||
Ref ref,
|
||||
_ChannelRefreshFence fence,
|
||||
Iterable<NostrEvent> dedupedMetas,
|
||||
String myPk,
|
||||
) async {
|
||||
Iterable<ChannelData> channelMetas,
|
||||
String myPk, {
|
||||
required bool refreshCached,
|
||||
}) async {
|
||||
final dmParticipants = <String>{};
|
||||
final myPkLower = myPk.toLowerCase();
|
||||
for (final event in dedupedMetas) {
|
||||
final data = ChannelData.fromEvent(event);
|
||||
for (final data in channelMetas) {
|
||||
if (data.channelType != 'dm') continue;
|
||||
for (final pk in data.participantPubkeys) {
|
||||
final lower = pk.toLowerCase();
|
||||
@@ -212,24 +212,44 @@ Future<Map<String, String>> _resolveDmDisplayNames(
|
||||
}
|
||||
if (dmParticipants.isEmpty) return const {};
|
||||
|
||||
final profileEvents = await _fenced(
|
||||
final profiles = await _fenced(
|
||||
fence,
|
||||
session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())),
|
||||
ref
|
||||
.read(userCacheProvider.notifier)
|
||||
.resolve(dmParticipants.toList(), refreshCached: refreshCached),
|
||||
);
|
||||
final displayNames = <String, String>{};
|
||||
for (final event in profileEvents) {
|
||||
if (event.kind != 0) continue;
|
||||
final profile = ProfileData.fromEvent(event);
|
||||
for (final profile in profiles.values) {
|
||||
final label = profile.displayName?.trim().isNotEmpty == true
|
||||
? profile.displayName!.trim()
|
||||
: profile.nip05?.trim().isNotEmpty == true
|
||||
? profile.nip05!.trim()
|
||||
: profile.nip05Handle?.trim().isNotEmpty == true
|
||||
? profile.nip05Handle!.trim()
|
||||
: shortPubkey(profile.pubkey);
|
||||
displayNames[profile.pubkey.toLowerCase()] = label;
|
||||
}
|
||||
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(
|
||||
RelaySessionNotifier session,
|
||||
String myPk,
|
||||
|
||||
70
mobile/lib/features/channels/channel_snapshot.dart
Normal file
70
mobile/lib/features/channels/channel_snapshot.dart
Normal 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');
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -290,17 +290,21 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
Future<Rect?> prepareCommunityLanding() async {
|
||||
// The list snapshot precedes unread history and DM profile hydration.
|
||||
// Both can still change weight, labels, and sorting behind the picker.
|
||||
final channels = ref.read(channelsProvider).requireValue;
|
||||
final dmPubkeys = {
|
||||
for (final channel in channels)
|
||||
if (channel.isMember && !channel.isArchived && channel.isDm)
|
||||
...channel.participantPubkeys,
|
||||
};
|
||||
await Future.wait([
|
||||
ref.read(channelsProvider.notifier).waitForUnreadCatchUp(),
|
||||
if (dmPubkeys.isNotEmpty)
|
||||
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()),
|
||||
]);
|
||||
// A restored list is already usable offline. Refresh its unread state
|
||||
// and participant profiles behind the page instead of gating navigation.
|
||||
if (!ref.read(channelsProvider.notifier).hasCachedSnapshot) {
|
||||
final channels = ref.read(channelsProvider).requireValue;
|
||||
final dmPubkeys = {
|
||||
for (final channel in channels)
|
||||
if (channel.isMember && !channel.isArchived && channel.isDm)
|
||||
...channel.participantPubkeys,
|
||||
};
|
||||
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;
|
||||
// Settle Home's scale and scroll while the loading backdrop is opaque.
|
||||
onSettingsTransitionProgress(0);
|
||||
|
||||
@@ -9,12 +9,15 @@ const _communityRevealDuration = Duration(milliseconds: 160);
|
||||
const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5;
|
||||
|
||||
// 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>>((
|
||||
ref,
|
||||
) {
|
||||
final channels = ref.watch(channelsProvider);
|
||||
final profile = ref.watch(profileProvider);
|
||||
final community = ref.watch(activeCommunityProvider).unwrapPrevious().value;
|
||||
final sections = ref.watch(channelSectionsProvider).isReady;
|
||||
final stars = ref.watch(channelStarsProvider).isReady;
|
||||
@@ -22,6 +25,10 @@ final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
|
||||
final mutes = ref.watch(channelMutesProvider).isReady;
|
||||
final readState = ref.watch(readStateProvider).isReady;
|
||||
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) {
|
||||
return AsyncError(channels.error!, channels.stackTrace!);
|
||||
}
|
||||
@@ -29,8 +36,9 @@ final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
|
||||
community != null &&
|
||||
channels.hasValue &&
|
||||
!channels.isLoading &&
|
||||
!profile.isLoading &&
|
||||
(!hasIdentity || (sections && stars && sort && mutes && readState)),
|
||||
(!hasIdentity ||
|
||||
cached ||
|
||||
(sections && stars && sort && mutes && readState)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -168,21 +176,19 @@ class CommunitySwitcherPage extends HookConsumerWidget {
|
||||
if (!arriving) unawaited(HapticFeedback.selectionClick());
|
||||
final animate = !reducedMotion && (arriving || destination != null);
|
||||
try {
|
||||
// Relay teardown, credential changes and provider hydration must not
|
||||
// compete with the grid-to-center motion on the UI isolate.
|
||||
if (animate && !arriving) {
|
||||
await centering.forward(from: 0).orCancel;
|
||||
} else {
|
||||
centering.value = 1;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) return;
|
||||
if (!arriving) {
|
||||
await ref
|
||||
.read(communityListProvider.notifier)
|
||||
.switchCommunity(community.id);
|
||||
}
|
||||
// Storage and relay teardown are asynchronous I/O, so they overlap the
|
||||
// grid-to-center motion. Both settle before any failure is handled, so
|
||||
// the picker never resets while one of them is still running.
|
||||
await Future.wait<void>([
|
||||
if (animate && !arriving)
|
||||
centering.forward(from: 0).orCancel
|
||||
else
|
||||
Future.sync(() => centering.value = 1),
|
||||
if (!arriving)
|
||||
ref
|
||||
.read(communityListProvider.notifier)
|
||||
.switchCommunity(community.id),
|
||||
]);
|
||||
if (!context.mounted) return;
|
||||
await ref.read(activeCommunityProvider.future);
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/channels/channel_list_snapshot.dart';
|
||||
import '../../shared/theme/theme_provider.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import 'channel.dart';
|
||||
@@ -15,12 +16,14 @@ import 'channel_management_provider.dart'
|
||||
import 'channel_mutes/channel_mutes_provider.dart';
|
||||
import 'huddle_channel_filter.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'unread_badge/is_high_priority_event.dart';
|
||||
import 'unread_badge/observed_unread_event.dart';
|
||||
import 'unread_badge/should_notify_for_event.dart';
|
||||
|
||||
part 'channel_directory.dart';
|
||||
part 'channel_snapshot.dart';
|
||||
part 'channel_member_snapshots.dart';
|
||||
part 'channels_provider_lifecycle.dart';
|
||||
|
||||
@@ -63,6 +66,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
Set<String> _authoredRootIds = {};
|
||||
String? _threadInterestPubkey;
|
||||
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? _memberSnapshotPubkey;
|
||||
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
|
||||
@@ -91,6 +100,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
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.
|
||||
///
|
||||
/// Presentation transitions can await this without blocking ordinary cached
|
||||
@@ -130,13 +143,19 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() async {
|
||||
final buildGeneration = ++_buildGeneration;
|
||||
_backgroundRefreshInFlight = false;
|
||||
_backgroundRefreshQueued = false;
|
||||
_refreshCoordinator.retireInFlight();
|
||||
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
|
||||
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
|
||||
if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
|
||||
_memberSnapshotPubkey != pubkey) {
|
||||
// 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;
|
||||
_hasCachedSnapshot = false;
|
||||
_needsSnapshotRevalidation = false;
|
||||
_memberSnapshotRelayBaseUrl = relayBaseUrl;
|
||||
_memberSnapshotPubkey = pubkey;
|
||||
_memberSnapshotsByChannelId = const {};
|
||||
@@ -146,21 +165,28 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
// previous relay or identity and must not reach this scope's state.
|
||||
_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>();
|
||||
var connectionVersion = 0;
|
||||
final sessionState = ref.read(relaySessionProvider);
|
||||
final waitingForInitialConnection =
|
||||
sessionState.status != SessionStatus.connected;
|
||||
ref.listen(relaySessionProvider, (previous, next) {
|
||||
if (next.status != SessionStatus.connected) return;
|
||||
if (waitingForInitialConnection &&
|
||||
!_hasLoaded &&
|
||||
!connected.isCompleted) {
|
||||
if (previous?.status != SessionStatus.connected) connectionVersion++;
|
||||
if (waitingForInitialConnection && !connected.isCompleted) {
|
||||
connected.complete();
|
||||
} else if (previous?.status != SessionStatus.connected) {
|
||||
// A new authenticated connection can change relay admission policy.
|
||||
// Ordinary refreshes must not retry an unchanged terminal rejection.
|
||||
_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
|
||||
// decide first: one that reconnects reconciles when it connects, so
|
||||
// refreshing here too would fetch the same list twice.
|
||||
final versionBeforeResume = connectionVersion;
|
||||
scheduleMicrotask(() {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
_buildGeneration++;
|
||||
_refreshCoordinator.retireInFlight();
|
||||
_clearLiveSubscriptions();
|
||||
_latestObservedByChannel.clear();
|
||||
_observedUnreadEventsByChannel.clear();
|
||||
@@ -187,11 +218,25 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
_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) {
|
||||
// Preserve cached channels only for a reconnect within the same scope.
|
||||
if (_hasLoaded) return state.value ?? const [];
|
||||
await connected.future;
|
||||
}
|
||||
if (!ref.mounted || buildGeneration != _buildGeneration) {
|
||||
throw const _StaleChannelRefresh();
|
||||
}
|
||||
|
||||
return _fetch(subscribeLive: true);
|
||||
}
|
||||
@@ -218,6 +263,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) throw StateError('No signing identity available');
|
||||
_loadThreadInterestStores(myPk);
|
||||
final saveSnapshot = _saveSnapshot;
|
||||
|
||||
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.
|
||||
final memberships = await _fenced(
|
||||
fence,
|
||||
_fetchChannelMemberships(session, myPk),
|
||||
final memberships = _latestChannelEvents(
|
||||
await _fenced(fence, _fetchChannelMemberships(session, myPk)),
|
||||
kind: 39002,
|
||||
);
|
||||
final memberChannelIds = memberships
|
||||
.map((e) => e.getTagValue('d'))
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
_cacheMemberSnapshots(memberships, replaceAll: true);
|
||||
|
||||
// Step 2: everything below the membership list depends only on it, so
|
||||
// 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),
|
||||
);
|
||||
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(
|
||||
session,
|
||||
memberChannelIdList,
|
||||
);
|
||||
final (
|
||||
memberMetas,
|
||||
hiddenDmIds,
|
||||
memberEvents,
|
||||
huddleStarts,
|
||||
) = await _fenced(fence, () async {
|
||||
// Future.wait handles every request's failure and rethrows the first
|
||||
// one unchanged, as soon as it happens.
|
||||
await Future.wait(eagerError: true, [
|
||||
memberMetasRequest,
|
||||
hiddenDmIdsRequest,
|
||||
memberEventsRequest,
|
||||
huddleStartsRequest,
|
||||
]);
|
||||
return (
|
||||
await memberMetasRequest,
|
||||
await hiddenDmIdsRequest,
|
||||
await memberEventsRequest,
|
||||
await huddleStartsRequest,
|
||||
);
|
||||
}());
|
||||
final (memberMetas, hiddenDmIds, huddleStarts) = await _fenced(
|
||||
fence,
|
||||
() async {
|
||||
// Future.wait handles every request's failure and rethrows the first
|
||||
// one unchanged, as soon as it happens.
|
||||
await Future.wait(eagerError: true, [
|
||||
memberMetasRequest,
|
||||
hiddenDmIdsRequest,
|
||||
huddleStartsRequest,
|
||||
]);
|
||||
return (
|
||||
await memberMetasRequest,
|
||||
await hiddenDmIdsRequest,
|
||||
await huddleStartsRequest,
|
||||
);
|
||||
}(),
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -299,56 +327,73 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
// Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable,
|
||||
// but stale revisions from before the relay's d_tag backfill can linger.
|
||||
final latestMetaPerId = <String, NostrEvent>{};
|
||||
for (final event in [...memberMetas, ..._directoryMetas]) {
|
||||
if (event.kind != 39000) continue;
|
||||
final id = event.getTagValue('d');
|
||||
if (id == null) continue;
|
||||
final existing = latestMetaPerId[id];
|
||||
if (existing == null ||
|
||||
event.createdAt > existing.createdAt ||
|
||||
(event.createdAt == existing.createdAt &&
|
||||
event.id.compareTo(existing.id) < 0)) {
|
||||
latestMetaPerId[id] = event;
|
||||
final dedupedMetas = _latestChannelEvents([
|
||||
...memberMetas,
|
||||
..._directoryMetas,
|
||||
], kind: 39000);
|
||||
final parsedMetas = {
|
||||
for (final event in dedupedMetas) event: ChannelData.fromEvent(event),
|
||||
};
|
||||
// Membership discovery already returned the full authoritative rosters.
|
||||
// Their owner roles validate Huddle links without downloading them again.
|
||||
final huddleBackingIds = huddleBackingChannelIds(huddleStarts, memberships);
|
||||
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;
|
||||
}
|
||||
}
|
||||
final dedupedMetas = latestMetaPerId.values.toList();
|
||||
if (data.channelType == 'dm' && hiddenDmIds.contains(data.id)) {
|
||||
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
|
||||
// `channels_provider.dart` stays under the 1200-line ceiling enforced by
|
||||
// `just file-size-check`.
|
||||
final displayNames = await _resolveDmDisplayNames(
|
||||
session,
|
||||
final displayNamesRequest = _resolveDmDisplayNames(
|
||||
ref,
|
||||
fence,
|
||||
dedupedMetas,
|
||||
parsedMetas.values,
|
||||
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.
|
||||
fence.ensureCurrent();
|
||||
_cacheMemberSnapshots(memberships, replaceAll: true);
|
||||
_hiddenDmIds = Set.unmodifiable(hiddenDmIds);
|
||||
final huddleBackingIds = huddleBackingChannelIds(
|
||||
huddleStarts,
|
||||
memberEvents,
|
||||
);
|
||||
|
||||
final channels = <Channel>[];
|
||||
for (final event in dedupedMetas) {
|
||||
final id = event.getTagValue('d');
|
||||
if (id == null) continue;
|
||||
for (final entry in visibleMetas) {
|
||||
final id = entry.value.id;
|
||||
final isMember = memberChannelIds.contains(id);
|
||||
final channel = _channelFromMeta(
|
||||
event,
|
||||
entry.key,
|
||||
entry.value,
|
||||
isMember: isMember,
|
||||
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
|
||||
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
|
||||
// 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
|
||||
// linkage validation and member-count hydration.
|
||||
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
|
||||
final memberCounts = _memberCountsByChannelId(memberEvents);
|
||||
final memberCounts = _memberCountsByChannelId(memberships);
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
final count = memberCounts[channels[i].id];
|
||||
if (count != null) {
|
||||
@@ -380,10 +424,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final channelById = {
|
||||
for (final channel in activeChannels) channel.id: channel,
|
||||
};
|
||||
final events = await _fenced(
|
||||
fence,
|
||||
_fetchLastMessageEvents(session, activeChannels),
|
||||
);
|
||||
final events = latestEvents;
|
||||
final lastMessageMap = <String, int>{};
|
||||
// An unavailable batch keeps the timestamps already known rather than
|
||||
// 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
|
||||
// caller assigns whatever this returns, so the last check belongs here.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -479,7 +532,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
/// burst of websocket REQs on communities with many channels.
|
||||
Future<List<NostrEvent>?> _fetchLastMessageEvents(
|
||||
RelaySessionNotifier session,
|
||||
List<Channel> channels,
|
||||
List<ChannelData> channels,
|
||||
) async {
|
||||
if (channels.isEmpty) return const [];
|
||||
|
||||
@@ -490,7 +543,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
tags: {
|
||||
'#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]
|
||||
/// can render real names instead of the relay-canonical "DM" name.
|
||||
Channel _channelFromMeta(
|
||||
NostrEvent event, {
|
||||
NostrEvent event,
|
||||
ChannelData data, {
|
||||
required bool isMember,
|
||||
Map<String, String> displayNames = const {},
|
||||
}) {
|
||||
final data = ChannelData.fromEvent(event);
|
||||
final participants = data.channelType == 'dm'
|
||||
? [
|
||||
for (final pk in data.participantPubkeys)
|
||||
@@ -904,22 +957,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
Future<void> _backstopRefresh() async {
|
||||
try {
|
||||
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(
|
||||
subscribeLive: sessionState.status == SessionStatus.connected,
|
||||
fetchLastMessage: 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);
|
||||
} on _StaleChannelRefresh {
|
||||
return;
|
||||
@@ -928,9 +970,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes memberships and, when [fetchDirectory], the open directory.
|
||||
/// Invite starter recovery opts in to converge on another identity's
|
||||
/// starters instead of attempting duplicate creation from memberships.
|
||||
/// Fetches current memberships for decisions that cannot use cached data.
|
||||
///
|
||||
/// 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 {
|
||||
final sessionState = ref.read(relaySessionProvider);
|
||||
// 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.
|
||||
if (sessionState.status != SessionStatus.connected) return;
|
||||
try {
|
||||
final channels = await _fetch(
|
||||
subscribeLive: true,
|
||||
fetchDirectory: fetchDirectory,
|
||||
);
|
||||
state = AsyncData(channels);
|
||||
await fetchFresh(fetchDirectory: fetchDirectory);
|
||||
} on _StaleChannelRefresh {
|
||||
return;
|
||||
} 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,20 +165,50 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier {
|
||||
_notifyUnreadReadinessChanged();
|
||||
unawaited(_unreadCatchUp);
|
||||
|
||||
_backstopTimer?.cancel();
|
||||
_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
|
||||
// fires before the app hears it has resumed, so check both.
|
||||
_startBackstopTimer();
|
||||
}
|
||||
|
||||
// Arm recovery before the first network load succeeds. A restored snapshot
|
||||
// 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) ==
|
||||
AppLifecycleState.resumed &&
|
||||
_lifecycleRef.read(relaySessionProvider).status ==
|
||||
SessionStatus.connected) {
|
||||
unawaited(_backstopRefresh());
|
||||
AppLifecycleState.resumed) {
|
||||
unawaited(_refreshInBackground());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!_lifecycleRef.mounted) return;
|
||||
final subscription = _liveSubscriptionsByChunk[chunkKey];
|
||||
|
||||
@@ -163,8 +163,7 @@ Future<Channel> _createMeChat(
|
||||
return intent.complete<Channel>(
|
||||
sync,
|
||||
findChannel: () async {
|
||||
ref.invalidate(channelsProvider);
|
||||
final all = await ref.read(channelsProvider.future);
|
||||
final all = await ref.read(channelsProvider.notifier).fetchFresh();
|
||||
return all.where((c) => c.id == channelId).firstOrNull;
|
||||
},
|
||||
createChannel: () => actions.createChannel(
|
||||
|
||||
@@ -75,16 +75,36 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
Future<void> _patchQueue = Future.value();
|
||||
|
||||
@override
|
||||
Future<UserProfile?> build() {
|
||||
Future<UserProfile?> build() async {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final pubkey = ref.watch(myPubkeyProvider);
|
||||
ref.watch(relaySessionProvider);
|
||||
final context = _ProfileWriteContext(
|
||||
config: config,
|
||||
pubkey: pubkey,
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
);
|
||||
_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);
|
||||
}
|
||||
|
||||
|
||||
187
mobile/lib/shared/channels/channel_list_snapshot.dart
Normal file
187
mobile/lib/shared/channels/channel_list_snapshot.dart
Normal 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);
|
||||
});
|
||||
@@ -7,12 +7,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../channels/channel_list_snapshot.dart';
|
||||
import '../hosted/community_entry_requirements.dart';
|
||||
import '../push/dev_push_lease.dart';
|
||||
import '../push/push_bridge.dart';
|
||||
import '../push/push_lease_revocation_outbox.dart';
|
||||
import '../push/push_subscription.dart';
|
||||
import '../relay/signed_event_relay.dart';
|
||||
import '../relay/relay_provider.dart' show pubkeyFromNsec;
|
||||
import 'community.dart';
|
||||
import 'community_storage.dart';
|
||||
|
||||
@@ -248,6 +250,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
Future<List<Community>> build() async {
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
final communities = await storage.loadAll();
|
||||
unawaited(_removeOrphanChannelListSnapshots(communities));
|
||||
await syncCommunitySnapshot(ref, communities);
|
||||
return communities;
|
||||
}
|
||||
@@ -273,10 +276,15 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
pubkey: community.pubkey,
|
||||
nsec: community.nsec,
|
||||
);
|
||||
if (_channelSnapshotPubkey(existing)?.toLowerCase() !=
|
||||
_channelSnapshotPubkey(updated)?.toLowerCase()) {
|
||||
await _removeChannelListSnapshot(existing);
|
||||
}
|
||||
await storage.save(updated);
|
||||
final updatedList = [...current];
|
||||
updatedList[existingIndex] = updated;
|
||||
state = AsyncData(updatedList);
|
||||
await _removeOrphanChannelListSnapshots(updatedList);
|
||||
await syncCommunitySnapshot(ref, updatedList);
|
||||
return existing.id;
|
||||
}
|
||||
@@ -284,6 +292,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
await storage.save(community);
|
||||
final updatedList = [...current, community];
|
||||
state = AsyncData(updatedList);
|
||||
await _removeOrphanChannelListSnapshots(updatedList);
|
||||
await syncCommunitySnapshot(ref, updatedList);
|
||||
return community.id;
|
||||
}
|
||||
@@ -314,6 +323,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
(community) => community.id == id,
|
||||
);
|
||||
if (removedIndex >= 0) {
|
||||
await _removeChannelListSnapshot(current[removedIndex]);
|
||||
// Persist every remote-cleanup dependency before erasing credentials.
|
||||
// This local transaction is the only removal prerequisite. Relay I/O
|
||||
// starts after the community and NSE snapshot have been removed.
|
||||
@@ -324,6 +334,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
await storage.remove(id);
|
||||
|
||||
final updatedList = current.where((w) => w.id != id).toList();
|
||||
await _removeOrphanChannelListSnapshots(updatedList);
|
||||
state = AsyncData(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) {
|
||||
return ref.read(communityTransitionProvider).runExclusive(() async {
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
|
||||
@@ -53,6 +53,38 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
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).
|
||||
/// Returns whether the batch completed successfully.
|
||||
Future<bool> preload(List<String> pubkeys) {
|
||||
|
||||
503
mobile/test/features/channels/channel_snapshot_test.dart
Normal file
503
mobile/test/features/channels/channel_snapshot_test.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:buzz/shared/community/paired_community_landing.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/shared/channels/channel_list_snapshot.dart';
|
||||
|
||||
import '../profile/presence_snapshot_test.dart'
|
||||
show PresenceTestRelay, presenceEvent;
|
||||
@@ -158,6 +159,7 @@ void main() {
|
||||
}
|
||||
|
||||
_communityUnreadLandingTests(buildTestable);
|
||||
_communityCachedLandingTests(buildTestable);
|
||||
|
||||
final testChannels = [
|
||||
Channel(
|
||||
@@ -2375,7 +2377,8 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
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) {
|
||||
expect(
|
||||
tester
|
||||
@@ -2388,7 +2391,6 @@ void main() {
|
||||
final origin = tester.getCenter(flyingAvatar);
|
||||
await tester.pump(const Duration(milliseconds: 105));
|
||||
if (!reduceMotion) {
|
||||
expect(notifier.switchedIds, isEmpty);
|
||||
final inFlight = tester.getCenter(flyingAvatar);
|
||||
const center = Offset(195, 422);
|
||||
expect(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
part of '../channels_page_test.dart';
|
||||
|
||||
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', (
|
||||
tester,
|
||||
@@ -17,8 +21,11 @@ void _communityUnreadLandingTests(
|
||||
];
|
||||
final communitiesNotifier = _FakeCommunityListNotifier(communities);
|
||||
final session = _UnreadDeadlineLandingSession(communitiesNotifier);
|
||||
// The header avatar's own profile never arrives. It must not gate landing.
|
||||
final profile = _StalledProfileNotifier();
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
profile: profile,
|
||||
overrides: [
|
||||
channelSectionsProvider.overrideWith(_LandingSections.new),
|
||||
channelStarsProvider.overrideWith(_LandingStars.new),
|
||||
@@ -65,6 +72,7 @@ void _communityUnreadLandingTests(
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(session.destinationUnreadRequests, greaterThan(0));
|
||||
expect(profile.stalled, isTrue);
|
||||
expect(find.byKey(const Key('community-switcher-page')), findsNothing);
|
||||
expect(find.byKey(const Key('community-switch-loading')), findsNothing);
|
||||
expect(find.text('bravo'), findsNothing);
|
||||
@@ -158,3 +166,144 @@ class _LandingSort extends ChannelSortNotifier {
|
||||
@override
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
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/shared/relay/relay.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_terminal_cases.dart';
|
||||
part 'channels_provider_readiness_cases.dart';
|
||||
part 'channels_provider_request_path_cases.dart';
|
||||
|
||||
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
|
||||
///
|
||||
@@ -28,6 +31,7 @@ part 'channels_provider_readiness_cases.dart';
|
||||
/// events on demand.
|
||||
void main() {
|
||||
_unreadReadinessCases();
|
||||
_requestPathCases();
|
||||
const myPk = 'me';
|
||||
|
||||
test(
|
||||
@@ -630,15 +634,15 @@ void main() {
|
||||
|
||||
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
|
||||
// overwrite the new scope's list or push it into an error state.
|
||||
session.memberships = [_membership(_channelA, myPk)];
|
||||
session.pauseNextMemberCountQuery();
|
||||
session.pauseNextMemberMetadataQuery();
|
||||
final staleRefresh = container
|
||||
.read(channelsProvider.notifier)
|
||||
.retryDirectory();
|
||||
await session.nextMemberCountQueryStarted;
|
||||
await session.nextMemberMetadataQueryStarted;
|
||||
|
||||
session.memberships = [_membership(_channelB, myPk)];
|
||||
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
||||
@@ -647,8 +651,8 @@ void main() {
|
||||
.update(baseUrl: 'https://community-b.example');
|
||||
await container.read(channelsProvider.future);
|
||||
|
||||
session.failClaimedMemberCountQuery = true;
|
||||
session.resumePausedMemberCountQuery();
|
||||
session.failClaimedMemberMetadataQuery = true;
|
||||
session.resumePausedMemberMetadataQuery();
|
||||
await staleRefresh;
|
||||
await _settle();
|
||||
|
||||
@@ -722,19 +726,19 @@ void main() {
|
||||
expect(await container.read(channelsProvider.future), isEmpty);
|
||||
|
||||
session.memberships = [_membership(_channelA, myPk)];
|
||||
session.pauseNextMemberCountQuery();
|
||||
session.pauseNextMemberMetadataQuery();
|
||||
final staleRefresh = container
|
||||
.read(channelsProvider.notifier)
|
||||
.retryDirectory();
|
||||
await session.nextMemberCountQueryStarted;
|
||||
await session.nextMemberMetadataQueryStarted;
|
||||
|
||||
session.memberships = [_membership(_channelB, _otherPk)];
|
||||
session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')];
|
||||
container.read(_testPubkeyProvider.notifier).set(_otherPk);
|
||||
await container.read(channelsProvider.future);
|
||||
|
||||
session.failClaimedMemberCountQuery = true;
|
||||
session.resumePausedMemberCountQuery();
|
||||
session.failClaimedMemberMetadataQuery = true;
|
||||
session.resumePausedMemberMetadataQuery();
|
||||
await staleRefresh;
|
||||
await _settle();
|
||||
|
||||
@@ -754,11 +758,10 @@ void main() {
|
||||
final container = _buildContainer(session: session);
|
||||
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.
|
||||
final metadataGate = session.metadataGate = Completer<void>();
|
||||
session.pauseNextHiddenDmQuery();
|
||||
session.pauseNextMemberCountQuery();
|
||||
session.pauseNextHuddleStartQuery();
|
||||
final load = container.read(channelsProvider.future);
|
||||
await _settle();
|
||||
@@ -771,17 +774,11 @@ void main() {
|
||||
};
|
||||
expect(
|
||||
sentKinds,
|
||||
containsAll([
|
||||
39000,
|
||||
39002,
|
||||
EventKind.dmVisibility,
|
||||
EventKind.huddleStarted,
|
||||
]),
|
||||
containsAll([39000, EventKind.dmVisibility, EventKind.huddleStarted]),
|
||||
);
|
||||
|
||||
metadataGate.complete();
|
||||
session.resumePausedHiddenDmQuery();
|
||||
session.resumePausedMemberCountQuery();
|
||||
session.resumePausedHuddleStartQuery();
|
||||
expect((await load).map((channel) => channel.id), [_channelA]);
|
||||
});
|
||||
@@ -881,8 +878,8 @@ void main() {
|
||||
await container.read(channelsProvider.future);
|
||||
final notifier = container.read(channelsProvider.notifier);
|
||||
|
||||
// Park an older refresh on its Huddle-start query. Both of its membership
|
||||
// fetches have already landed, so channel A is the list it is carrying.
|
||||
// Park an older refresh on its Huddle-start query. Its membership snapshot
|
||||
// has already landed, so channel A is the list it is carrying.
|
||||
session.pauseNextHuddleStartQuery();
|
||||
final olderRefresh = notifier.refresh();
|
||||
await session.nextHuddleStartQueryStarted;
|
||||
@@ -2566,10 +2563,12 @@ NostrEvent _membership(
|
||||
String pubkey, {
|
||||
String? additionalPubkey,
|
||||
String? ownerPubkey,
|
||||
int createdAt = 1,
|
||||
String? eventId,
|
||||
}) => NostrEvent(
|
||||
id: 'mem-$channelId',
|
||||
id: eventId ?? 'mem-$channelId',
|
||||
pubkey: 'creator',
|
||||
createdAt: 1,
|
||||
createdAt: createdAt,
|
||||
kind: 39002,
|
||||
tags: [
|
||||
['d', channelId],
|
||||
@@ -2605,6 +2604,7 @@ NostrEvent _meta({
|
||||
int createdAt = 1,
|
||||
int? ttlSeconds,
|
||||
bool archived = false,
|
||||
List<String> participants = const [],
|
||||
}) => NostrEvent(
|
||||
id: 'meta-$id',
|
||||
pubkey: 'creator',
|
||||
@@ -2617,6 +2617,7 @@ NostrEvent _meta({
|
||||
[visibility == 'private' ? 'private' : 'public'],
|
||||
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
|
||||
if (archived) ['archived', 'true'],
|
||||
for (final participant in participants) ['p', participant],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
@@ -2700,7 +2701,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
List<NostrEvent> recentMessages;
|
||||
int membershipFailures;
|
||||
int directoryFailures = 0;
|
||||
bool failClaimedMemberCountQuery = false;
|
||||
bool failClaimedMemberMetadataQuery = false;
|
||||
bool failClaimedUnreadCatchUpQuery = false;
|
||||
int membershipRequestCount = 0;
|
||||
int metadataPageRequestCount = 0;
|
||||
@@ -2731,9 +2732,9 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
Completer<void>? _pausedHiddenDm;
|
||||
Completer<void>? _hiddenDmStarted;
|
||||
Completer<void>? _claimedHiddenDm;
|
||||
Completer<void>? _pausedMemberCount;
|
||||
Completer<void>? _memberCountStarted;
|
||||
Completer<void>? _claimedMemberCount;
|
||||
Completer<void>? _pausedMemberMetadata;
|
||||
Completer<void>? _memberMetadataStarted;
|
||||
Completer<void>? _claimedMemberMetadata;
|
||||
Completer<void>? _pausedHuddleStarts;
|
||||
Completer<void>? _huddleStartsStarted;
|
||||
Completer<void>? _claimedHuddleStarts;
|
||||
@@ -2831,7 +2832,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
|
||||
/// 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
|
||||
/// fence on this leg.
|
||||
void pauseNextHuddleStartQuery() {
|
||||
@@ -2860,28 +2861,28 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
paused.complete();
|
||||
}
|
||||
|
||||
/// Holds the next member-count query open, one shot only.
|
||||
void pauseNextMemberCountQuery() {
|
||||
if (_pausedMemberCount != null) {
|
||||
throw StateError('A member-count query is already paused');
|
||||
/// Holds the next member-metadata query open, one shot only.
|
||||
void pauseNextMemberMetadataQuery() {
|
||||
if (_pausedMemberMetadata != null) {
|
||||
throw StateError('A member-metadata query is already paused');
|
||||
}
|
||||
_pausedMemberCount = Completer<void>();
|
||||
_memberCountStarted = Completer<void>();
|
||||
_pausedMemberMetadata = Completer<void>();
|
||||
_memberMetadataStarted = Completer<void>();
|
||||
}
|
||||
|
||||
/// Completes once the parked member-count query has been requested.
|
||||
Future<void> get nextMemberCountQueryStarted async {
|
||||
final started = _memberCountStarted;
|
||||
/// Completes once the parked member-metadata query has been requested.
|
||||
Future<void> get nextMemberMetadataQueryStarted async {
|
||||
final started = _memberMetadataStarted;
|
||||
if (started == null) {
|
||||
throw StateError('No member-count query is pending');
|
||||
throw StateError('No member-metadata query is pending');
|
||||
}
|
||||
await started.future;
|
||||
}
|
||||
|
||||
/// Releases the parked member-count query so its response lands.
|
||||
void resumePausedMemberCountQuery() {
|
||||
final paused = _claimedMemberCount ?? _pausedMemberCount;
|
||||
if (paused == null) throw StateError('No member-count query is paused');
|
||||
/// Releases the parked member-metadata query so its response lands.
|
||||
void resumePausedMemberMetadataQuery() {
|
||||
final paused = _claimedMemberMetadata ?? _pausedMemberMetadata;
|
||||
if (paused == null) throw StateError('No member-metadata query is paused');
|
||||
paused.complete();
|
||||
}
|
||||
|
||||
@@ -2929,21 +2930,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
throw Exception('restricted: too many explicit channels');
|
||||
}
|
||||
if (filter.kinds.contains(39002) && filter.tags['#d'] != null) {
|
||||
final paused = _pausedMemberCount;
|
||||
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();
|
||||
throw StateError('Membership rosters must reuse discovery events');
|
||||
}
|
||||
if (filter.kinds.contains(39002) && filter.tags['#p'] != null) {
|
||||
membershipRequestCount++;
|
||||
@@ -2974,7 +2961,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
}
|
||||
if (filter.kinds.contains(EventKind.huddleStarted)) {
|
||||
// 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;
|
||||
if (paused != null) {
|
||||
_claimedHuddleStarts = paused;
|
||||
@@ -2993,9 +2980,22 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
if (ids == null) {
|
||||
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;
|
||||
// Member metadata query — return only matching `d` tags.
|
||||
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
||||
return snapshot;
|
||||
}
|
||||
// Per-filter websocket fallback of a message batch.
|
||||
messageHistoryCount++;
|
||||
|
||||
@@ -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/me/me_page.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_sync.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';
|
||||
|
||||
class _EmptyChannels extends ChannelsNotifier {
|
||||
_EmptyChannels(this.existing);
|
||||
final bool existing;
|
||||
int freshReads = 0;
|
||||
@override
|
||||
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 {
|
||||
@@ -32,7 +54,8 @@ class _Relay extends RelaySessionNotifier {
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [profile];
|
||||
}) async =>
|
||||
filters.any((filter) => filter.kinds.contains(0)) ? [profile] : [];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
@@ -53,94 +76,120 @@ class _Relay extends RelaySessionNotifier {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'New Solo chat submits its discovery label through the production action',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final owner = nostr.Keys.generate(), agent = nostr.Keys.generate();
|
||||
final digest = SHA256Digest()
|
||||
.process(
|
||||
Uint8List.fromList(
|
||||
utf8.encode('nostr:agent-auth:${agent.public}:'),
|
||||
),
|
||||
)
|
||||
.map((v) => v.toRadixString(16).padLeft(2, '0'))
|
||||
.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,
|
||||
for (final recoverExisting in [false, true]) {
|
||||
testWidgets(
|
||||
recoverExisting
|
||||
? 'interrupted Solo recovery uses fresh membership before creating'
|
||||
: 'New Solo chat submits its discovery label through the production action',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final owner = nostr.Keys.generate(), agent = nostr.Keys.generate();
|
||||
final digest = SHA256Digest()
|
||||
.process(
|
||||
Uint8List.fromList(
|
||||
utf8.encode('nostr:agent-auth:${agent.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();
|
||||
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),
|
||||
);
|
||||
)
|
||||
.map((v) => v.toRadixString(16).padLeft(2, '0'))
|
||||
.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);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -52,7 +52,29 @@ void main() {
|
||||
);
|
||||
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);
|
||||
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');
|
||||
|
||||
expect(relaySession.published, hasLength(1));
|
||||
@@ -681,6 +703,9 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier {
|
||||
class _ProfileRelaySession extends RelaySessionNotifier {
|
||||
_ProfileRelaySession(this.profile);
|
||||
|
||||
int reads = 0;
|
||||
void setStatus(SessionStatus status) => state = SessionState(status: status);
|
||||
|
||||
final NostrEvent profile;
|
||||
final List<NostrEvent> published = [];
|
||||
|
||||
@@ -691,7 +716,10 @@ class _ProfileRelaySession extends RelaySessionNotifier {
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [profile, ...published];
|
||||
}) async {
|
||||
reads++;
|
||||
return [profile, ...published];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NostrEvent> publish(
|
||||
|
||||
190
mobile/test/shared/channels/channel_list_snapshot_test.dart
Normal file
190
mobile/test/shared/channels/channel_list_snapshot_test.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.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_provider.dart';
|
||||
import 'package:buzz/shared/community/community_storage.dart';
|
||||
@@ -42,7 +48,10 @@ void main() {
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
ProviderContainer createContainer() {
|
||||
ProviderContainer createContainer({
|
||||
List<Override> overrides = const [],
|
||||
CommunityPushLeaseRevocationEnqueuer? enqueueRevocation,
|
||||
}) {
|
||||
Future<void> writeSnapshot(List<Community> communities) async {
|
||||
snapshots.add(List.of(communities));
|
||||
}
|
||||
@@ -52,15 +61,17 @@ void main() {
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue(writeSnapshot),
|
||||
communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator),
|
||||
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue((
|
||||
community,
|
||||
) async {
|
||||
journaledCommunityIds.add(community.id);
|
||||
return true;
|
||||
}),
|
||||
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(
|
||||
enqueueRevocation ??
|
||||
(community) async {
|
||||
journaledCommunityIds.add(community.id);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
communityPushLeaseRevocationTriggerProvider.overrideWithValue(
|
||||
revocationTrigger,
|
||||
),
|
||||
...overrides,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -428,6 +439,187 @@ void main() {
|
||||
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 {
|
||||
final remoteAttempt = Completer<void>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,89 +141,91 @@ void main() {
|
||||
expect(cache.state['agent']?.displayName, 'Valid');
|
||||
});
|
||||
|
||||
for (final preload in [false, true]) {
|
||||
test(
|
||||
'${preload ? 'preload' : 'refresh'} merges after parsing against newer live state',
|
||||
() async {
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
final session = _RecordingProfileSession(
|
||||
result: Future.value([
|
||||
_profileEvent(id: 'old', createdAt: 1, name: 'Old'),
|
||||
]),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
profileEventBatchParserProvider.overrideWithValue((events) async {
|
||||
final parsed = await parseProfileEventBatch(events);
|
||||
started.complete();
|
||||
await release.future;
|
||||
return parsed;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
final result = preload
|
||||
? cache.preload(['agent'])
|
||||
: cache.refresh(['agent']);
|
||||
await started.future;
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(id: 'new', createdAt: 2, name: 'Live'),
|
||||
);
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(
|
||||
id: 'other',
|
||||
pubkey: 'other',
|
||||
createdAt: 1,
|
||||
name: 'Other',
|
||||
),
|
||||
);
|
||||
release.complete();
|
||||
expect(await result, isTrue);
|
||||
expect(cache.state['agent']?.displayName, 'Live');
|
||||
expect(cache.state['other']?.displayName, 'Other');
|
||||
},
|
||||
);
|
||||
for (final operation in ['refresh', 'preload', 'resolve']) {
|
||||
test('$operation merges after parsing against newer live state', () async {
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
final session = _RecordingProfileSession(
|
||||
result: Future.value([
|
||||
_profileEvent(id: 'old', createdAt: 1, name: 'Old'),
|
||||
]),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
profileEventBatchParserProvider.overrideWithValue((events) async {
|
||||
final parsed = await parseProfileEventBatch(events);
|
||||
started.complete();
|
||||
await release.future;
|
||||
return parsed;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
final result = switch (operation) {
|
||||
'preload' => cache.preload(['agent']),
|
||||
'resolve' => cache.resolve(['agent']).then((_) => true),
|
||||
_ => cache.refresh(['agent']),
|
||||
};
|
||||
await started.future;
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(id: 'new', createdAt: 2, name: 'Live'),
|
||||
);
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(
|
||||
id: 'other',
|
||||
pubkey: 'other',
|
||||
createdAt: 1,
|
||||
name: 'Other',
|
||||
),
|
||||
);
|
||||
release.complete();
|
||||
expect(await result, isTrue);
|
||||
expect(cache.state['agent']?.displayName, 'Live');
|
||||
expect(cache.state['other']?.displayName, 'Other');
|
||||
});
|
||||
|
||||
test(
|
||||
'${preload ? 'preload' : 'refresh'} rejects a retired parsing completion',
|
||||
() async {
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
final session = _RecordingProfileSession(
|
||||
result: Future.value([
|
||||
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
|
||||
]),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
profileEventBatchParserProvider.overrideWithValue((events) async {
|
||||
final parsed = await parseProfileEventBatch(events);
|
||||
started.complete();
|
||||
await release.future;
|
||||
return parsed;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
final result = preload
|
||||
? cache.preload(['agent'])
|
||||
: cache.refresh(['agent']);
|
||||
await started.future;
|
||||
// This is the same dependency invalidation caused by community config.
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://next-community.invalid');
|
||||
container.read(userCacheProvider);
|
||||
release.complete();
|
||||
test('$operation rejects a retired parsing completion', () async {
|
||||
final started = Completer<void>();
|
||||
final release = Completer<void>();
|
||||
final session = _RecordingProfileSession(
|
||||
result: Future.value([
|
||||
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
|
||||
]),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
profileEventBatchParserProvider.overrideWithValue((events) async {
|
||||
final parsed = await parseProfileEventBatch(events);
|
||||
started.complete();
|
||||
await release.future;
|
||||
return parsed;
|
||||
}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
final result = switch (operation) {
|
||||
'preload' => cache.preload(['agent']),
|
||||
'resolve' => cache.resolve(['agent']).then((_) => true),
|
||||
_ => cache.refresh(['agent']),
|
||||
};
|
||||
await started.future;
|
||||
// This is the same dependency invalidation caused by community config.
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://next-community.invalid');
|
||||
container.read(userCacheProvider);
|
||||
release.complete();
|
||||
if (operation == 'resolve') {
|
||||
await expectLater(result, throwsStateError);
|
||||
} else {
|
||||
expect(await result, isFalse);
|
||||
expect(container.read(userCacheProvider), isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
expect(container.read(userCacheProvider), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
|
||||
Reference in New Issue
Block a user