mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Pinky is opening this PR on Wes’s behalf. ## Summary Reduce two separately measured mobile delays without changing the relay API or removing rich message rendering: - Publish the finite channel-list snapshot without waiting for live subscription setup. - Batch active channel-list subscriptions into sorted, deterministic chunks of at most 128 explicit channel IDs, retaining unchanged chunks. - Install replacement chunks before retiring old coverage. Retain old chunks across thrown replacement failures; filter callbacks to the current relay/identity and still-desired channels; clean up retired/in-flight work across disconnect and disposal. - Scope the custom-emoji Markdown matcher to known shortcodes actually referenced in the rendered content, rather than embedding the whole community palette in every message’s regex. Preserve unknown literals, shared-colon token boundaries, event-tag URL priority, content edits, and code literals. - Honor explicit zero retry hints without inventing a ten-second session-wide gate, while preserving the ordinary live-subscription retry backoff and any already-active gate. ## Matched performance results Medians of three before and three after process-cold launches, alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator. Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`; after is this production patch before two documentation-only comment fixes. First channel-list frame: 11.617s → 3.179s · 73% lower latency Live setup duration: 8.475s → 0.185s · 98% lower latency Channel-open first message-list frame: 2.754s → 1.230s · 55% lower latency Message data ready → first frame: 1.977s → 0.286s · 86% lower latency Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency Channel-open data readiness: 0.770s → 0.944s · 23% higher latency The gain is client-side orchestration/rendering, not a claim that the relay became faster. First channel-list frame ranges were 10.835–11.788s before and 2.872–3.395s after; channel-open first-frame ranges were 1.560–2.906s before and 1.149–1.317s after. ### Measurement boundaries - Debug simulator builds, CPU sampling disabled, bounded timestamp probes enabled identically. These are not release/physical-device measurements. - Startup clock starts at Dart `main`; build/install/native pre-main time is excluded. Auth/preferences and OS/disk caches are retained between new processes. - Same account scale: 113 active channels. Latest-message events varied slightly with live activity (1543–1546). - Channel-open uses the same initial 50-row history window, 97 query events, and 67 provider events. The 2306-entry emoji palette is explicitly loaded before navigation on both sides; palette preparation is excluded from the channel-open clock and happens after the startup frame measurement. - Both diagnostic builds temporarily disabled unused avatar segmentation to work around the existing Google ML Kit arm64-simulator slice limitation. The workaround, dependency/native changes, auto-navigation, and all probes are excluded from this PR. ## Validation - Full mobile package suite: `flutter test` — 1890 passed. - `just mobile-check` — 506 files unchanged; analyzer clean. - `just file-size-check` — policy tests and all client ratchets passed. - `git diff --check` — passed. - New lifecycle regressions cover front-sorting insertion across a chunk boundary while replacement readiness is paused, failure retention/departed-channel filtering, retired generation + disconnect cleanup, disposal, chunk limits, unchanged-set reuse, and scope switches. - Emoji unit/widget coverage includes a 2500-unused-emoji palette, unknown tokens, case matching at the component level, shared-colon boundaries, rich text, event URL priority, and content edits. - Fresh-frame source review traced the subscription queue/fences, callback scopes, duplicate-event paths, matcher/wiring, and retry scheduling. - At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`, all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer, and the full 1890-test mobile suite), `file-size-check`, `branch-skew`, and `push-head-scope`. The commit hook formatted 506 files with no changes. Runtime measurements preceded only the two documentation-comment fixes; no runtime source changed afterward. ## Limits / follow-ups - `RelaySession.subscribe` still settles under its existing EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an unconditional EOSE or live-delivery guarantee. This PR does not add status-aware replacement ownership. - The channel-message provider still awaits subscribe before fetching history; that separate serialization is not removed here. - Oversized Huddle queries and the separate history batching path above 128 active channels remain follow-ups, as do pre-existing read-state initialization/size warnings. - Palette-only widget refresh and upstream Markdown uppercase-dispatch behavior are not changed. - A clean source build still has the existing Google ML Kit arm64-simulator issue; the profiling workaround is not a proposed product fix. Originating Buzz conversation: buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789 --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2717 lines
90 KiB
Dart
2717 lines
90 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/features/channels/channel_management_provider.dart';
|
|
import 'package:buzz/features/channels/channels_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
|
|
part 'channels_provider_live_cases.dart';
|
|
part 'channels_provider_terminal_cases.dart';
|
|
|
|
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
|
|
///
|
|
/// The provider loads membership-backed channels first:
|
|
/// 1. paginated kind:39002 memberships tagged `#p:<my-pubkey>`
|
|
/// 2. kind:39000 metadata for those channel ids
|
|
/// then layers chunked live subscriptions on the `#h` tag. Browse channels
|
|
/// separately triggers paginated kind:39000 open-channel discovery.
|
|
///
|
|
/// Tests stub out the relay session by overriding [relaySessionProvider] with
|
|
/// a [_FakeRelaySession] that returns canned events from [fetchHistory] and
|
|
/// records [subscribe] calls so we can assert filter shapes and emit live
|
|
/// events on demand.
|
|
void main() {
|
|
const myPk = 'me';
|
|
|
|
test(
|
|
'discovers open channels for a user with zero channel memberships',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'staff', visibility: 'private'),
|
|
_meta(id: _channelD, name: 'DM', channelType: 'dm'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
expect(session.directoryQueryFilters, isEmpty);
|
|
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
final channels = container.read(channelsProvider).requireValue;
|
|
|
|
expect(channels, hasLength(1));
|
|
expect(channels.single.id, _channelA);
|
|
expect(channels.single.isMember, isFalse);
|
|
expect(session.subscribeFilters, isEmpty);
|
|
expect(session.directoryQueryFilters, isNotEmpty);
|
|
},
|
|
);
|
|
|
|
test('paginates channel discovery with a composite cursor', () async {
|
|
final firstPage = List.generate(
|
|
500,
|
|
(index) => _meta(
|
|
id: '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000',
|
|
name: 'channel-$index',
|
|
createdAt: 10,
|
|
),
|
|
);
|
|
final finalChannel = _meta(
|
|
id: '99999999-9999-4999-8999-999999999999',
|
|
name: 'last-page',
|
|
createdAt: 9,
|
|
);
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadataPages: [
|
|
firstPage,
|
|
[finalChannel],
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
final channels = container.read(channelsProvider).requireValue;
|
|
|
|
expect(channels, hasLength(501));
|
|
final directoryFilters = session.directoryQueryFilters;
|
|
expect(directoryFilters, hasLength(3));
|
|
expect(directoryFilters.first.until, isNull);
|
|
expect(directoryFilters.first.extensions, isEmpty);
|
|
expect(directoryFilters[1].until, firstPage.last.createdAt);
|
|
expect(directoryFilters[1].extensions['before_id'], firstPage.last.id);
|
|
expect(directoryFilters.last.until, finalChannel.createdAt);
|
|
expect(directoryFilters.last.extensions['before_id'], finalChannel.id);
|
|
});
|
|
|
|
test(
|
|
'paginates memberships when the relay caps responses below limit',
|
|
() async {
|
|
final firstPage = List.generate(
|
|
100,
|
|
(index) => _membership(
|
|
'${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000',
|
|
myPk,
|
|
),
|
|
);
|
|
final finalChannelId = '99999999-9999-4999-8999-999999999999';
|
|
final finalMembership = _membership(finalChannelId, myPk);
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
membershipPages: [
|
|
firstPage,
|
|
[finalMembership],
|
|
],
|
|
metadata: [_meta(id: finalChannelId, name: 'last-membership')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(channels.single.id, finalChannelId);
|
|
expect(channels.single.isMember, isTrue);
|
|
expect(session.membershipQueryFilters, hasLength(3));
|
|
expect(session.membershipQueryFilters.first.until, isNull);
|
|
expect(session.membershipQueryFilters.first.extensions, isEmpty);
|
|
expect(session.membershipQueryFilters[1].until, firstPage.last.createdAt);
|
|
expect(
|
|
session.membershipQueryFilters[1].extensions['before_id'],
|
|
firstPage.last.id,
|
|
);
|
|
expect(
|
|
session.membershipQueryFilters.last.extensions['before_id'],
|
|
finalMembership.id,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('stops membership pagination when the relay repeats a page', () async {
|
|
final repeatedPage = List.generate(
|
|
500,
|
|
(index) => _membership(
|
|
'${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000',
|
|
myPk,
|
|
),
|
|
);
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
membershipPages: [repeatedPage],
|
|
repeatLastMembershipPage: true,
|
|
maxMembershipPageRequests: 2,
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
expect(session.membershipRequestCount, 2);
|
|
});
|
|
|
|
test('stops channel discovery when the relay repeats a full page', () async {
|
|
final repeatedPage = List.generate(
|
|
500,
|
|
(index) => _meta(
|
|
id: 'repeated-channel-$index',
|
|
name: 'repeated-$index',
|
|
createdAt: 10,
|
|
),
|
|
);
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadataPages: [repeatedPage],
|
|
repeatLastMetadataPage: true,
|
|
maxMetadataPageRequests: 2,
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
final channels = container.read(channelsProvider).requireValue;
|
|
|
|
expect(channels, hasLength(500));
|
|
expect(session.metadataPageRequestCount, 2);
|
|
});
|
|
|
|
test(
|
|
'directory page-cap failure is distinct from an empty directory',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadataPageBuilder: (pageIndex) => List.generate(
|
|
500,
|
|
(eventIndex) => _meta(
|
|
id: 'channel-$pageIndex-$eventIndex',
|
|
name: 'channel-$pageIndex-$eventIndex',
|
|
createdAt: 1000 - pageIndex,
|
|
),
|
|
),
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
expect(session.metadataPageRequestCount, 100);
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.error,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'directory failure retains discovery while membership refreshes',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'discoverable'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(
|
|
(await container.read(
|
|
channelsProvider.future,
|
|
)).map((channel) => channel.id),
|
|
[_channelA],
|
|
);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
unorderedEquals([_channelA, _channelB]),
|
|
);
|
|
|
|
session.memberships = [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelD, myPk),
|
|
];
|
|
session.metadata = [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'discoverable'),
|
|
_meta(id: _channelD, name: 'newly joined'),
|
|
];
|
|
session.directoryFailures = 1;
|
|
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
|
|
final refreshed = container.read(channelsProvider).requireValue;
|
|
expect(
|
|
refreshed.map((channel) => channel.id),
|
|
unorderedEquals([_channelA, _channelB, _channelD]),
|
|
);
|
|
expect(
|
|
refreshed.firstWhere((channel) => channel.id == _channelD).isMember,
|
|
isTrue,
|
|
);
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.error,
|
|
);
|
|
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.loaded,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('directory retry failure retains the current channel list', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final initial = await container.read(channelsProvider.future);
|
|
session.membershipFailures = 1;
|
|
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
|
|
expect(container.read(channelsProvider).requireValue, initial);
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.error,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'ordinary refresh settles a superseded directory load for retry',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'discoverable'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(
|
|
(await container.read(
|
|
channelsProvider.future,
|
|
)).map((channel) => channel.id),
|
|
[_channelA],
|
|
);
|
|
|
|
session.pauseNextDirectoryQuery();
|
|
final directory = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextDirectoryQueryStarted;
|
|
|
|
// A foreground, reconnect, pull-to-refresh, or membership update can
|
|
// start an ordinary refresh while Browse is still loading discovery.
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
session.resumePausedDirectoryQuery();
|
|
await directory;
|
|
await _settle();
|
|
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.error,
|
|
);
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
[_channelA],
|
|
);
|
|
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).status,
|
|
ChannelDirectoryLoadStatus.loaded,
|
|
);
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
unorderedEquals([_channelA, _channelB]),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'community switch discards a stale directory success from the old relay',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.pauseNextDirectoryQuery();
|
|
final staleDirectory = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextDirectoryQueryStarted;
|
|
|
|
// Switch communities while community A's directory response is paused.
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.resumePausedDirectoryQuery();
|
|
await staleDirectory;
|
|
await _settle();
|
|
|
|
final ids = container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id);
|
|
expect(ids, isNot(contains(_channelA)));
|
|
expect(ids, contains(_channelB));
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).scope,
|
|
isNot(
|
|
channelDirectoryScope(
|
|
'https://community-b.example',
|
|
container.read(myPubkeyProvider),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'community switch discards a stale directory failure from the old relay',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.pauseNextDirectoryQuery();
|
|
final staleDirectory = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextDirectoryQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
|
|
// Community A's directory request fails after the switch.
|
|
session.directoryFailures = 1;
|
|
session.resumePausedDirectoryQuery();
|
|
await staleDirectory;
|
|
await _settle();
|
|
|
|
final ids = container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id);
|
|
expect(ids, contains(_channelB));
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).scope,
|
|
isNot(
|
|
channelDirectoryScope(
|
|
'https://community-b.example',
|
|
container.read(myPubkeyProvider),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'identity switch discards a stale directory success from the old identity',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.pauseNextDirectoryQuery();
|
|
final staleDirectory = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextDirectoryQueryStarted;
|
|
|
|
// Switch signing identity while the first identity's response is paused.
|
|
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.resumePausedDirectoryQuery();
|
|
await staleDirectory;
|
|
await _settle();
|
|
|
|
final ids = container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id);
|
|
expect(ids, isNot(contains(_channelA)));
|
|
expect(ids, contains(_channelB));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'identity switch discards a stale directory failure from the old identity',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.pauseNextDirectoryQuery();
|
|
final staleDirectory = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextDirectoryQueryStarted;
|
|
|
|
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.directoryFailures = 1;
|
|
session.resumePausedDirectoryQuery();
|
|
await staleDirectory;
|
|
await _settle();
|
|
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
contains(_channelB),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'community switch after directory success discards the late refresh',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
// Community A now has a joined channel, so the directory-triggered
|
|
// refresh reaches the live-subscribe step and parks there. That await is
|
|
// AFTER the loader's own directory fence, which is the window this arm
|
|
// covers: directory success, then retirement, then settlement.
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextSubscribe();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextSubscribeStarted;
|
|
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
// The old scope's finite snapshot is allowed to publish before its live
|
|
// subscription becomes ready. From the actual switch onward, however,
|
|
// neither that snapshot nor its detached live work may republish.
|
|
final emitted = <List<String>>[];
|
|
container.listen(channelsProvider, (previous, next) {
|
|
final value = next.asData?.value;
|
|
if (value != null) {
|
|
emitted.add(value.map((channel) => channel.id).toList());
|
|
}
|
|
});
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.resumePausedSubscribe();
|
|
await _settle();
|
|
|
|
expect(
|
|
emitted.where((ids) => ids.contains(_channelA)),
|
|
isEmpty,
|
|
reason: 'community A channel emitted into community B scope: $emitted',
|
|
);
|
|
// The retired refresh must not leave a subscription on the old channel.
|
|
expect(session.activeChannels, isNot(contains(_channelA)));
|
|
// Nor may it claim the new scope's directory status as its own.
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).scope,
|
|
isNot(
|
|
channelDirectoryScope(
|
|
'https://community-b.example',
|
|
container.read(myPubkeyProvider),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'community switch after directory success discards a late failure',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
// The directory query succeeds, then the refresh parks on the member-count
|
|
// 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();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextMemberCountQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.failClaimedMemberCountQuery = true;
|
|
session.resumePausedMemberCountQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final current = container.read(channelsProvider);
|
|
expect(current.hasError, isFalse);
|
|
final ids = current.requireValue.map((channel) => channel.id);
|
|
expect(ids, isNot(contains(_channelA)));
|
|
expect(ids, contains(_channelB));
|
|
// A retired failure must not claim the new scope's directory status.
|
|
expect(
|
|
container.read(channelDirectoryLoadStatusProvider).scope,
|
|
isNot(
|
|
channelDirectoryScope(
|
|
'https://community-b.example',
|
|
container.read(myPubkeyProvider),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'identity switch after directory success discards the late refresh',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
// Directory success, then the refresh parks on the hidden-DM query while
|
|
// the signing identity changes and the new identity finishes its rebuild.
|
|
session.pauseNextHiddenDmQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextHiddenDmQueryStarted;
|
|
|
|
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.resumePausedHiddenDmQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final ids = container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id);
|
|
expect(ids, isNot(contains(_channelA)));
|
|
expect(ids, contains(_channelB));
|
|
expect(session.activeChannels, isNot(contains(_channelA)));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'identity switch after directory success discards a late failure',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextMemberCountQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextMemberCountQueryStarted;
|
|
|
|
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();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final current = container.read(channelsProvider);
|
|
expect(current.hasError, isFalse);
|
|
final ids = current.requireValue.map((channel) => channel.id);
|
|
expect(ids, isNot(contains(_channelA)));
|
|
expect(ids, contains(_channelB));
|
|
},
|
|
);
|
|
|
|
test('a newer ordinary refresh owns the installed membership list', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.pauseNextHiddenDmQuery();
|
|
final olderRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await session.nextHiddenDmQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'joined-b')];
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
session.resumePausedHiddenDmQuery();
|
|
await olderRefresh;
|
|
await _settle();
|
|
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
[_channelB],
|
|
reason: 'an older ordinary refresh overwrote the newer membership list',
|
|
);
|
|
expect(session.activeChannels, {_channelB});
|
|
});
|
|
|
|
test(
|
|
'a newer refresh owns the list over an older reconnect backstop',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.pauseNextHiddenDmQuery();
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
session.setStatus(SessionStatus.connected);
|
|
await session.nextHiddenDmQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'joined-b')];
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
session.resumePausedHiddenDmQuery();
|
|
await _settle();
|
|
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
[_channelB],
|
|
reason:
|
|
'an older reconnect backstop overwrote the newer membership list',
|
|
);
|
|
expect(session.activeChannels, {_channelB});
|
|
},
|
|
);
|
|
|
|
test('a stale request\'s Huddle leg writes no member snapshot', () async {
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
huddleStarts: [
|
|
NostrEvent(
|
|
id: 'huddle-start',
|
|
pubkey: myPk,
|
|
createdAt: now,
|
|
kind: EventKind.huddleStarted,
|
|
tags: const [
|
|
['h', _channelA],
|
|
],
|
|
content: '{"ephemeral_channel_id":"$_channelB"}',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
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.
|
|
session.pauseNextHuddleStartQuery();
|
|
final olderRefresh = notifier.refresh();
|
|
await session.nextHuddleStartQueryStarted;
|
|
|
|
// A newer refresh completes on a disjoint membership set.
|
|
session.memberships = [
|
|
_membership(_channelB, myPk, additionalPubkey: _otherPk),
|
|
];
|
|
session.metadata = [_meta(id: _channelB, name: 'joined-b')];
|
|
await notifier.refresh();
|
|
|
|
// Release the older request. Its member-snapshot write sits AFTER the
|
|
// Huddle leg, so if that leg is unfenced the stale snapshot lands.
|
|
session.resumePausedHuddleStartQuery();
|
|
await olderRefresh;
|
|
await _settle();
|
|
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
[_channelB],
|
|
reason: 'a stale request installed its own channel list',
|
|
);
|
|
expect(
|
|
notifier.cachedMembersForChannel(_channelA),
|
|
isEmpty,
|
|
reason:
|
|
'a stale request wrote a member snapshot past the Huddle leg fence',
|
|
);
|
|
expect(
|
|
notifier.cachedMembersForChannel(_channelB).map((m) => m.pubkey),
|
|
containsAll([myPk, _otherPk]),
|
|
reason: 'the newer request\'s member snapshot was clobbered',
|
|
);
|
|
});
|
|
|
|
test('community switch discards a parked unread catch-up', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
recentMessages: const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in community A',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
// The directory query succeeds and the refresh completes, but the unread
|
|
// catch-up it kicks off stays parked across the community switch.
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread state landed in community B: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
expect(
|
|
notifier.observedUnreadEventsByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread events landed in community B: '
|
|
'${notifier.observedUnreadEventsByChannel}',
|
|
);
|
|
});
|
|
|
|
test('identity switch discards a parked unread catch-up', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
recentMessages: const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention for the first identity',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
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.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'first identity unread state landed on the second identity: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
expect(
|
|
notifier.observedUnreadEventsByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'first identity unread events landed on the second identity: '
|
|
'${notifier.observedUnreadEventsByChannel}',
|
|
);
|
|
});
|
|
|
|
test('community switch discards a parked catch-up failure', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
// Let community B's own refresh and its own catch-up settle first, so the
|
|
// emissions counted below can only come from the parked community A work.
|
|
await _settle();
|
|
|
|
// A retired catch-up that fails must publish nothing: the trailing `state`
|
|
// write belongs to whichever community is active now.
|
|
final staleEmissions = <int>[];
|
|
final subscription = container.listen(
|
|
channelsProvider,
|
|
(_, next) => staleEmissions.add(next.requireValue.length),
|
|
fireImmediately: false,
|
|
);
|
|
addTearDown(subscription.close);
|
|
|
|
session.failClaimedUnreadCatchUpQuery = true;
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final current = container.read(channelsProvider);
|
|
expect(current.hasError, isFalse);
|
|
expect(current.requireValue.map((channel) => channel.id), [_channelB]);
|
|
expect(
|
|
staleEmissions,
|
|
isEmpty,
|
|
reason:
|
|
'a retired catch-up failure republished community B provider state: '
|
|
'$staleEmissions',
|
|
);
|
|
});
|
|
|
|
test('identity switch discards a parked catch-up failure', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-open')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
session.memberships = [_membership(_channelA, myPk)];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container
|
|
.read(channelsProvider.notifier)
|
|
.retryDirectory();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
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);
|
|
await _settle();
|
|
|
|
final staleEmissions = <int>[];
|
|
final subscription = container.listen(
|
|
channelsProvider,
|
|
(_, next) => staleEmissions.add(next.requireValue.length),
|
|
fireImmediately: false,
|
|
);
|
|
addTearDown(subscription.close);
|
|
|
|
session.failClaimedUnreadCatchUpQuery = true;
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final current = container.read(channelsProvider);
|
|
expect(current.hasError, isFalse);
|
|
expect(current.requireValue.map((channel) => channel.id), [_channelB]);
|
|
expect(
|
|
staleEmissions,
|
|
isEmpty,
|
|
reason:
|
|
'a retired catch-up failure republished the second identity provider '
|
|
'state: $staleEmissions',
|
|
);
|
|
});
|
|
|
|
test('an ordinary membership refresh retires an older catch-up', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
// Same relay, same identity. Only the refresh generation separates the
|
|
// parked catch-up from the membership list the user is looking at, which
|
|
// is the window the post-join membership refresh opens.
|
|
session.recentMessages = const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in channel A',
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'joined-b')];
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
await _settle();
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'a superseded ordinary refresh wrote unread state for a channel the '
|
|
'user has left: ${notifier.latestObservedByChannel}',
|
|
);
|
|
expect(
|
|
notifier.observedUnreadEventsByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'a superseded ordinary refresh wrote unread events for a channel the '
|
|
'user has left: ${notifier.observedUnreadEventsByChannel}',
|
|
);
|
|
});
|
|
|
|
test('a newer refresh retires a parked backstop catch-up', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.recentMessages = const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in channel A',
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
// Reconnecting runs the backstop refresh, which never fetches the
|
|
// directory, so this is the path that carried no lifecycle token at all.
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
session.setStatus(SessionStatus.connected);
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'joined-b')];
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
await _settle();
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'a superseded backstop refresh wrote unread state for a channel the '
|
|
'user has left: ${notifier.latestObservedByChannel}',
|
|
);
|
|
expect(
|
|
notifier.observedUnreadEventsByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'a superseded backstop refresh wrote unread events for a channel the '
|
|
'user has left: ${notifier.observedUnreadEventsByChannel}',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'community switch discards a parked catch-up from an ordinary refresh',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.recentMessages = const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in community A',
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
await _settle();
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread state landed in community B: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'identity switch discards a parked catch-up from an ordinary refresh',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'first-identity-general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.recentMessages = const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention for the first identity',
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final staleRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await session.nextUnreadCatchUpQueryStarted;
|
|
|
|
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);
|
|
await _settle();
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'first identity unread state landed on the second identity: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a disconnected community switch retires a parked catch-up', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
session.recentMessages = const [
|
|
NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in community A',
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final started = session.nextUnreadCatchUpQueryStarted;
|
|
final staleRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await started;
|
|
|
|
// Switch community while the session is down, so the new scope runs no
|
|
// refresh of its own. This is the arm that shows the single generation
|
|
// counter is sufficient: the rebuild's disposal bumps it even though no
|
|
// new refresh does.
|
|
session.setStatus(SessionStatus.disconnected);
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await _settle();
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await staleRefresh;
|
|
await _settle();
|
|
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread state survived a disconnected switch: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
});
|
|
|
|
test('a catch-up that records nothing does not repaint the list', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
// No unread events to record, so the catch-up has nothing to publish.
|
|
// The catch-up is detached, so its query starts inside the refresh: hold
|
|
// the started future before awaiting the refresh or the one-shot slot is
|
|
// already claimed and reset by the time we ask for it.
|
|
session.pauseNextUnreadCatchUpQuery();
|
|
final started = session.nextUnreadCatchUpQueryStarted;
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
await started;
|
|
await _settle();
|
|
|
|
final emissions = <int>[];
|
|
final subscription = container.listen(
|
|
channelsProvider,
|
|
(_, next) => emissions.add(next.requireValue.length),
|
|
fireImmediately: false,
|
|
);
|
|
addTearDown(subscription.close);
|
|
|
|
session.resumePausedUnreadCatchUpQuery();
|
|
await _settle();
|
|
|
|
expect(
|
|
emissions,
|
|
isEmpty,
|
|
reason: 'an empty unread catch-up republished provider state: $emissions',
|
|
);
|
|
});
|
|
|
|
test('community switch drops unread state recorded before it', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'community-a-general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
// A live mention in community A records unread state the badges read.
|
|
session.emit(
|
|
const NostrEvent(
|
|
id: 'mention-in-a',
|
|
pubkey: 'alice',
|
|
createdAt: 50,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelA],
|
|
['p', myPk],
|
|
],
|
|
content: 'direct mention in community A',
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
await _settle();
|
|
final notifier = container.read(channelsProvider.notifier);
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
contains(_channelA),
|
|
reason: 'precondition: community A unread state was never recorded',
|
|
);
|
|
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'community-b-general')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://community-b.example');
|
|
await container.read(channelsProvider.future);
|
|
await _settle();
|
|
|
|
expect(
|
|
notifier.latestObservedByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread state survived into community B: '
|
|
'${notifier.latestObservedByChannel}',
|
|
);
|
|
expect(
|
|
notifier.observedUnreadEventsByChannel,
|
|
isNot(contains(_channelA)),
|
|
reason:
|
|
'community A unread events survived into community B: '
|
|
'${notifier.observedUnreadEventsByChannel}',
|
|
);
|
|
});
|
|
|
|
test('reconnect backstop does not refetch the channel directory', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
final initialDirectoryRequests = session.metadataPageRequestCount;
|
|
final initialMembershipRequests = session.membershipRequestCount;
|
|
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
session.setStatus(SessionStatus.connected);
|
|
await _waitUntil(
|
|
() => session.membershipRequestCount > initialMembershipRequests,
|
|
);
|
|
for (var i = 0; i < 10; i++) {
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
|
|
expect(session.metadataPageRequestCount, initialDirectoryRequests);
|
|
});
|
|
|
|
test('membership refresh does not refetch a loaded directory', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'discoverable'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
final directoryRequestCount = session.metadataPageRequestCount;
|
|
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
expect(session.metadataPageRequestCount, directoryRequestCount);
|
|
expect(
|
|
container
|
|
.read(channelsProvider)
|
|
.requireValue
|
|
.map((channel) => channel.id),
|
|
unorderedEquals([_channelA, _channelB]),
|
|
);
|
|
});
|
|
|
|
test('directory-aware refresh discovers open starter channels', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: const [],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'welcome-everyone'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
|
|
await container
|
|
.read(channelsProvider.notifier)
|
|
.refresh(fetchDirectory: true);
|
|
|
|
final channels = container.read(channelsProvider).requireValue;
|
|
expect(channels.map((channel) => channel.name), [
|
|
'general',
|
|
'welcome-everyone',
|
|
]);
|
|
expect(channels.every((channel) => channel.isMember), isFalse);
|
|
expect(session.directoryQueryFilters, isNotEmpty);
|
|
});
|
|
|
|
test('deduplicates joined channels from directory discovery', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(
|
|
(await container.read(
|
|
channelsProvider.future,
|
|
)).map((channel) => channel.id),
|
|
[_channelA],
|
|
);
|
|
await container.read(channelsProvider.notifier).retryDirectory();
|
|
final channels = container.read(channelsProvider).requireValue;
|
|
|
|
expect(channels.map((channel) => channel.id), [_channelA, _channelB]);
|
|
expect(channels.first.isMember, isTrue);
|
|
expect(channels.last.isMember, isFalse);
|
|
await _waitUntil(() => session.subscribeFilters.length == 1);
|
|
expect(session.subscribeFilters, hasLength(1));
|
|
});
|
|
|
|
test(
|
|
'seeds members from the channel-list snapshot during reconnect',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
final memberQueryCount = session.historyFilters
|
|
.where(
|
|
(filter) =>
|
|
filter.kinds.contains(39002) && filter.tags['#d'] != null,
|
|
)
|
|
.length;
|
|
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
final members = await container.read(
|
|
channelMembersProvider(_channelA).future,
|
|
);
|
|
|
|
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
|
|
expect(
|
|
session.historyFilters
|
|
.where(
|
|
(filter) =>
|
|
filter.kinds.contains(39002) && filter.tags['#d'] != null,
|
|
)
|
|
.length,
|
|
memberQueryCount,
|
|
);
|
|
},
|
|
);
|
|
|
|
_liveSubscriptionTests();
|
|
_terminalSubscriptionTests();
|
|
|
|
test('retains channel-list member snapshots for immediate reuse', () async {
|
|
final joinedAt = DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true);
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
final members = container
|
|
.read(channelsProvider.notifier)
|
|
.cachedMembersForChannel(_channelA);
|
|
|
|
expect(members, hasLength(2));
|
|
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
|
|
expect(members.every((member) => member.joinedAt == joinedAt), isTrue);
|
|
});
|
|
|
|
test('live channel events update channel lastMessageAt', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general', createdAt: 10)],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
// Emit a live message event on channelA.
|
|
session.emit(
|
|
NostrEvent(
|
|
id: 'event-1',
|
|
pubkey: 'alice',
|
|
createdAt: 20,
|
|
kind: EventKind.streamMessageV2,
|
|
tags: const [
|
|
['h', _channelA],
|
|
],
|
|
content: 'new message',
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
|
|
final channels = container.read(channelsProvider).value!;
|
|
expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000);
|
|
});
|
|
|
|
test(
|
|
'loads all channel timestamps through one batched relay query',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'direct', channelType: 'dm'),
|
|
],
|
|
recentMessages: const [
|
|
NostrEvent(
|
|
id: 'stream-message',
|
|
pubkey: 'alice',
|
|
createdAt: 30,
|
|
kind: EventKind.streamMessageV2,
|
|
tags: [
|
|
['h', _channelA],
|
|
],
|
|
content: 'hello',
|
|
sig: 'sig',
|
|
),
|
|
NostrEvent(
|
|
id: 'dm-message',
|
|
pubkey: 'alice',
|
|
createdAt: 40,
|
|
kind: 9,
|
|
tags: [
|
|
['h', _channelB],
|
|
],
|
|
content: 'hello privately',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
await _waitUntil(() => session.queryBatches.length == 2);
|
|
expect(session.queryBatches, hasLength(2));
|
|
expect(session.queryBatches.first, hasLength(2));
|
|
expect(
|
|
session.queryBatches.first
|
|
.map((filter) => filter.tags['#h']!.single)
|
|
.toSet(),
|
|
{_channelA, _channelB},
|
|
);
|
|
expect(session.queryBatches.last, hasLength(2));
|
|
expect(
|
|
session.queryBatches.last.every(
|
|
(filter) => filter.limit == 1000 && filter.since == 0,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
session.historyFilters.where((filter) {
|
|
final kinds = filter.kinds.toSet();
|
|
return kinds.length == EventKind.channelMessageEventKinds.length &&
|
|
kinds.containsAll(EventKind.channelMessageEventKinds);
|
|
}),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
channels.firstWhere((channel) => channel.id == _channelA).lastMessageAt,
|
|
DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true),
|
|
);
|
|
expect(
|
|
channels.firstWhere((channel) => channel.id == _channelB).lastMessageAt,
|
|
DateTime.fromMillisecondsSinceEpoch(40 * 1000, isUtc: true),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('ephemeral (TTL) channels appear in the list', () async {
|
|
// Regression: previously the provider unconditionally dropped any channel
|
|
// with a `ttl` tag, which made TTL channels invisible on iOS even when the
|
|
// user was a member. They should be included so the existing
|
|
// `_EphemeralBadge` UI in `channels_page.dart` can render them.
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(
|
|
id: _channelB,
|
|
name: 'agent-creation-deep-dive',
|
|
ttlSeconds: 86400,
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(
|
|
channels.map((c) => c.name),
|
|
containsAll(['general', 'agent-creation-deep-dive']),
|
|
);
|
|
final ephemeral = channels.firstWhere(
|
|
(c) => c.name == 'agent-creation-deep-dive',
|
|
);
|
|
expect(ephemeral.isEphemeral, isTrue);
|
|
expect(ephemeral.ttlSeconds, 86400);
|
|
});
|
|
|
|
test(
|
|
'Huddle-linked one-hour private streams stay out of channel lists',
|
|
() async {
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk, ownerPubkey: myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(
|
|
id: _channelB,
|
|
name: 'huddle-22222222',
|
|
ttlSeconds: 3600,
|
|
visibility: 'private',
|
|
),
|
|
],
|
|
huddleStarts: [
|
|
NostrEvent(
|
|
id: 'huddle-start',
|
|
pubkey: myPk,
|
|
createdAt: now,
|
|
kind: EventKind.huddleStarted,
|
|
tags: const [
|
|
['h', _channelA],
|
|
],
|
|
content: '{"ephemeral_channel_id":"$_channelB"}',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(channels.map((channel) => channel.id), [_channelA]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Huddle-linked private streams stay hidden when relay overrides the TTL',
|
|
() async {
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk, ownerPubkey: myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(
|
|
id: _channelB,
|
|
name: 'huddle-22222222',
|
|
ttlSeconds: 60,
|
|
visibility: 'private',
|
|
),
|
|
],
|
|
huddleStarts: [
|
|
NostrEvent(
|
|
id: 'huddle-start',
|
|
pubkey: myPk,
|
|
createdAt: now,
|
|
kind: EventKind.huddleStarted,
|
|
tags: const [
|
|
['h', _channelA],
|
|
],
|
|
content: '{"ephemeral_channel_id":"$_channelB"}',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(channels.map((channel) => channel.id), [_channelA]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'forged Huddle links do not hide unrelated one-hour private streams',
|
|
() async {
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk, ownerPubkey: 'actual-owner'),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(
|
|
id: _channelB,
|
|
name: 'private-hour-stream',
|
|
ttlSeconds: 3600,
|
|
visibility: 'private',
|
|
),
|
|
],
|
|
huddleStarts: [
|
|
NostrEvent(
|
|
id: 'forged-huddle-start',
|
|
pubkey: myPk,
|
|
createdAt: now,
|
|
kind: EventKind.huddleStarted,
|
|
tags: const [
|
|
['h', _channelA],
|
|
],
|
|
content: '{"ephemeral_channel_id":"$_channelB"}',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(channels.map((channel) => channel.id), contains(_channelB));
|
|
},
|
|
);
|
|
|
|
test('hidden DMs are filtered from the channel list', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'Alice', channelType: 'dm'),
|
|
_meta(id: _channelB, name: 'Bob', channelType: 'dm'),
|
|
],
|
|
hiddenDmEvents: [
|
|
_hiddenDms([_channelA], pubkey: myPk),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
|
|
expect(channels.map((c) => c.id), [_channelB]);
|
|
expect(
|
|
session.historyFilters.any(
|
|
(filter) =>
|
|
filter.kinds.contains(EventKind.dmVisibility) &&
|
|
filter.tags['#p']?.single == myPk,
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'archived kind:39000 metadata sets Channel.isArchived (covers TTL auto-archive)',
|
|
() async {
|
|
// The relay's TTL reaper auto-archives expired ephemeral channels and
|
|
// republishes kind:39000 with `["archived", "true"]`. The Channel needs
|
|
// `archivedAt != null` so the `_SliverChannelsList` filter
|
|
// (`!channel.isArchived`) hides it from the sidebar after expiry.
|
|
// Previously the mobile parser ignored the `archived` tag, so expired
|
|
// TTL channels would have stayed visible after the `!isEphemeral` guard
|
|
// was removed.
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'active'),
|
|
_meta(
|
|
id: _channelB,
|
|
name: 'expired-ttl',
|
|
ttlSeconds: 86400,
|
|
archived: true,
|
|
),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final channels = await container.read(channelsProvider.future);
|
|
final expired = channels.firstWhere((c) => c.name == 'expired-ttl');
|
|
expect(expired.isArchived, isTrue);
|
|
expect(expired.isEphemeral, isTrue);
|
|
// The active channel must not be flagged archived.
|
|
final active = channels.firstWhere((c) => c.name == 'active');
|
|
expect(active.isArchived, isFalse);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'archive transition invalidates cached channelDetailsProvider',
|
|
() async {
|
|
// Codex review v2 caught: if a TTL channel is opened (caching its
|
|
// ChannelDetails) and then the reaper archives it, the cached details
|
|
// — built from the pre-archive kind:39000 — would clobber the newer
|
|
// archivedAt set on the base Channel during `mergeDetails`. We invalidate
|
|
// the details provider when the archived state flips so the next
|
|
// mergeDetails sees fresh data.
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'active')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
// Initial load.
|
|
final initial = await container.read(channelsProvider.future);
|
|
expect(initial.single.isArchived, isFalse);
|
|
|
|
// Prime the detail cache.
|
|
final detailsFiltersBefore = session.historyFilters
|
|
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
|
|
.length;
|
|
await container.read(channelDetailsProvider(_channelA).future);
|
|
final detailsFetchesAfterPrime =
|
|
session.historyFilters
|
|
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
|
|
.length -
|
|
detailsFiltersBefore;
|
|
expect(detailsFetchesAfterPrime, 1);
|
|
|
|
// Simulate the reaper auto-archiving the channel by swapping the
|
|
// metadata the fake returns, then refreshing the channels provider.
|
|
session.metadata
|
|
..clear()
|
|
..add(_meta(id: _channelA, name: 'active', archived: true));
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
final refreshed = container.read(channelsProvider).value!;
|
|
expect(refreshed.single.isArchived, isTrue);
|
|
|
|
// Take a fresh baseline AFTER the refresh — the refresh itself issues a
|
|
// `kinds:[39000], #d:[id]` query as part of channel metadata refetch and
|
|
// we must not count that toward our invalidation assertion. Only the
|
|
// fetch triggered by the second `channelDetailsProvider` read should be
|
|
// attributed to invalidation.
|
|
final detailsFiltersAfterRefresh = session.historyFilters
|
|
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
|
|
.length;
|
|
|
|
// Reading the details provider again must trigger a fresh fetch — proving
|
|
// the prior cache was invalidated by the archive transition. Without
|
|
// invalidation, Riverpod would return the cached pre-archive details and
|
|
// no new `kinds:[39000], #d:[id]` filter would be sent.
|
|
await container.read(channelDetailsProvider(_channelA).future);
|
|
final detailsFetchesFromInvalidation =
|
|
session.historyFilters
|
|
.where((f) => f.kinds.contains(39000) && f.tags['#d'] != null)
|
|
.length -
|
|
detailsFiltersAfterRefresh;
|
|
expect(detailsFetchesFromInvalidation, greaterThan(0));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'keeps cached channels and live subscriptions during reconnect',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
final initial = await container.read(channelsProvider.future);
|
|
expect(initial.single.name, 'general');
|
|
await _waitUntil(() => session.subscribeFilters.length == 1);
|
|
expect(session.subscribeFilters, hasLength(1));
|
|
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
final reconnecting = await container.read(channelsProvider.future);
|
|
|
|
expect(reconnecting.single.name, 'general');
|
|
expect(session.subscribeFilters, hasLength(1));
|
|
expect(session.unsubscribeCount, 0);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'refreshes cached channels after a disconnected community switch',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(
|
|
(await container.read(channelsProvider.future)).single.name,
|
|
'general',
|
|
);
|
|
|
|
session.setStatus(SessionStatus.disconnected);
|
|
session.memberships = [_membership(_channelB, myPk)];
|
|
session.metadata = [_meta(id: _channelB, name: 'random')];
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://new-community.example');
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(container.read(channelsProvider).value?.single.name, 'general');
|
|
|
|
session.setStatus(SessionStatus.connected);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(container.read(channelsProvider).value?.single.name, 'random');
|
|
},
|
|
);
|
|
|
|
test('recovers an initial fetch failure after reconnecting', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
membershipFailures: 1,
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await expectLater(container.read(channelsProvider.future), throwsException);
|
|
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
session.setStatus(SessionStatus.connected);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final recovered = await container.read(channelsProvider.future);
|
|
expect(recovered.single.name, 'general');
|
|
});
|
|
|
|
test(
|
|
'preserves a successfully loaded empty list while disconnected',
|
|
() async {
|
|
final session = _FakeRelaySession(memberships: [], metadata: []);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
final fetchCount = session.historyFilters.length;
|
|
|
|
session.setStatus(SessionStatus.reconnecting);
|
|
expect(await container.read(channelsProvider.future), isEmpty);
|
|
expect(session.historyFilters, hasLength(fetchCount));
|
|
},
|
|
);
|
|
|
|
test('initial fetch issues membership + metadata queries', () async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [_membership(_channelA, myPk)],
|
|
metadata: [_meta(id: _channelA, name: 'general')],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
expect(session.membershipQueryFilters, isNotEmpty);
|
|
expect(session.membershipQueryFilters.first.kinds, [39002]);
|
|
expect(session.membershipQueryFilters.first.tags['#p'], [myPk]);
|
|
expect(
|
|
session.historyFilters.any(
|
|
(filter) =>
|
|
filter.kinds.contains(39000) &&
|
|
filter.tags['#d']?.contains(_channelA) == true,
|
|
),
|
|
isTrue,
|
|
);
|
|
|
|
// And one live subscription on the resulting channel.
|
|
await _waitUntil(() => session.subscribeFilters.length == 1);
|
|
expect(session.subscribeFilters, hasLength(1));
|
|
});
|
|
}
|
|
|
|
const _channelA = '11111111-1111-4111-8111-111111111111';
|
|
const _channelB = '22222222-2222-4222-8222-222222222222';
|
|
const _channelD = '44444444-4444-4444-8444-444444444444';
|
|
const _otherPk = 'someone-else';
|
|
|
|
String _generatedChannelId(int index) =>
|
|
'${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000';
|
|
|
|
/// Build a kind:39002 membership event tagged with the channel id and member.
|
|
NostrEvent _membership(
|
|
String channelId,
|
|
String pubkey, {
|
|
String? additionalPubkey,
|
|
String? ownerPubkey,
|
|
}) => NostrEvent(
|
|
id: 'mem-$channelId',
|
|
pubkey: 'creator',
|
|
createdAt: 1,
|
|
kind: 39002,
|
|
tags: [
|
|
['d', channelId],
|
|
if (ownerPubkey != null) ['p', ownerPubkey, '', 'owner'],
|
|
if (ownerPubkey == null || ownerPubkey != pubkey) ['p', pubkey],
|
|
if (additionalPubkey != null) ['p', additionalPubkey],
|
|
],
|
|
content: '',
|
|
sig: 'sig',
|
|
);
|
|
|
|
NostrEvent _hiddenDms(List<String> channelIds, {required String pubkey}) =>
|
|
NostrEvent(
|
|
id: 'hidden-${channelIds.join('-')}',
|
|
pubkey: 'relay',
|
|
createdAt: 2,
|
|
kind: EventKind.dmVisibility,
|
|
tags: [
|
|
['d', pubkey],
|
|
['p', pubkey],
|
|
for (final channelId in channelIds) ['h', channelId],
|
|
],
|
|
content: '',
|
|
sig: 'sig',
|
|
);
|
|
|
|
/// Build a kind:39000 channel metadata event.
|
|
NostrEvent _meta({
|
|
required String id,
|
|
required String name,
|
|
String channelType = 'stream',
|
|
String visibility = 'open',
|
|
int createdAt = 1,
|
|
int? ttlSeconds,
|
|
bool archived = false,
|
|
}) => NostrEvent(
|
|
id: 'meta-$id',
|
|
pubkey: 'creator',
|
|
createdAt: createdAt,
|
|
kind: 39000,
|
|
tags: [
|
|
['d', id],
|
|
['name', name],
|
|
['t', channelType],
|
|
[visibility == 'private' ? 'private' : 'public'],
|
|
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
|
|
if (archived) ['archived', 'true'],
|
|
],
|
|
content: '',
|
|
sig: 'sig',
|
|
);
|
|
|
|
ProviderContainer _buildContainer({required _FakeRelaySession session}) {
|
|
return ProviderContainer(
|
|
retry: (_, _) => null,
|
|
overrides: [
|
|
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
|
|
relaySessionProvider.overrideWith(() => session),
|
|
// Route the pubkey through a mutable notifier so tests can switch the
|
|
// signing identity mid-flight the way an account change does at runtime.
|
|
myPubkeyProvider.overrideWith((ref) => ref.watch(_testPubkeyProvider)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Mutable stand-in for the signing identity derived from the active community.
|
|
class _TestPubkeyNotifier extends Notifier<String?> {
|
|
@override
|
|
String? build() => 'me';
|
|
|
|
void set(String? pubkey) => state = pubkey;
|
|
}
|
|
|
|
final _testPubkeyProvider = NotifierProvider<_TestPubkeyNotifier, String?>(
|
|
_TestPubkeyNotifier.new,
|
|
);
|
|
|
|
/// Drains pending microtasks so provider rebuilds and awaited writes land.
|
|
Future<void> _settle() async {
|
|
for (var i = 0; i < 20; i++) {
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
}
|
|
|
|
Future<void> _waitUntil(bool Function() predicate) async {
|
|
for (var i = 0; i < 100; i++) {
|
|
if (predicate()) return;
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
fail('Timed out waiting for asynchronous provider work');
|
|
}
|
|
|
|
/// Fake [RelaySessionNotifier] that returns canned query results and records
|
|
/// subscriptions.
|
|
class _FakeRelaySession extends RelaySessionNotifier {
|
|
_FakeRelaySession({
|
|
required this.memberships,
|
|
this.membershipPages,
|
|
this.repeatLastMembershipPage = false,
|
|
this.maxMembershipPageRequests,
|
|
this.metadata = const [],
|
|
this.metadataPages,
|
|
this.metadataPageBuilder,
|
|
this.repeatLastMetadataPage = false,
|
|
this.maxMetadataPageRequests,
|
|
this.hiddenDmEvents = const [],
|
|
this.huddleStarts = const [],
|
|
this.recentMessages = const [],
|
|
this.membershipFailures = 0,
|
|
});
|
|
|
|
List<NostrEvent> memberships;
|
|
final List<List<NostrEvent>>? membershipPages;
|
|
final bool repeatLastMembershipPage;
|
|
final int? maxMembershipPageRequests;
|
|
List<NostrEvent> metadata;
|
|
final List<List<NostrEvent>>? metadataPages;
|
|
final List<NostrEvent> Function(int pageIndex)? metadataPageBuilder;
|
|
final bool repeatLastMetadataPage;
|
|
final int? maxMetadataPageRequests;
|
|
final List<NostrEvent> hiddenDmEvents;
|
|
final List<NostrEvent> huddleStarts;
|
|
List<NostrEvent> recentMessages;
|
|
int membershipFailures;
|
|
int directoryFailures = 0;
|
|
bool failClaimedMemberCountQuery = false;
|
|
bool failClaimedUnreadCatchUpQuery = false;
|
|
int membershipRequestCount = 0;
|
|
int metadataPageRequestCount = 0;
|
|
|
|
final List<NostrFilter> historyFilters = [];
|
|
final List<List<NostrFilter>> queryBatches = [];
|
|
final List<NostrFilter> directoryQueryFilters = [];
|
|
final List<NostrFilter> membershipQueryFilters = [];
|
|
final List<NostrFilter> subscribeFilters = [];
|
|
final Map<
|
|
int,
|
|
(NostrFilter, void Function(NostrEvent), void Function(String message)?)
|
|
>
|
|
_subscriptions = {};
|
|
int _nextSubscriptionKey = 0;
|
|
Completer<void>? _pausedSubscribe;
|
|
Completer<void>? _subscribeStarted;
|
|
Completer<void>? _pausedDirectory;
|
|
Completer<void>? _directoryStarted;
|
|
Completer<void>? _pausedHiddenDm;
|
|
Completer<void>? _hiddenDmStarted;
|
|
Completer<void>? _claimedHiddenDm;
|
|
Completer<void>? _pausedMemberCount;
|
|
Completer<void>? _memberCountStarted;
|
|
Completer<void>? _claimedMemberCount;
|
|
Completer<void>? _pausedHuddleStarts;
|
|
Completer<void>? _huddleStartsStarted;
|
|
Completer<void>? _claimedHuddleStarts;
|
|
Completer<void>? _pausedUnreadCatchUp;
|
|
Completer<void>? _unreadCatchUpStarted;
|
|
Completer<void>? _claimedUnreadCatchUp;
|
|
int unsubscribeCount = 0;
|
|
int totalSubscribeCount = 0;
|
|
int subscribeFailures = 0;
|
|
int successfulSubscribesBeforeFailure = 0;
|
|
|
|
Set<String> get activeChannels => {
|
|
for (final (filter, _, _) in _subscriptions.values)
|
|
...filter.tags['#h'] ?? const <String>[],
|
|
};
|
|
|
|
int get activeSubscriptionCount => _subscriptions.length;
|
|
|
|
Future<void> get nextSubscribeStarted async {
|
|
final started = _subscribeStarted;
|
|
if (started == null) {
|
|
throw StateError('No paused subscription is pending');
|
|
}
|
|
await started.future;
|
|
}
|
|
|
|
void pauseNextSubscribe() {
|
|
if (_pausedSubscribe != null) {
|
|
throw StateError('A subscription is already paused');
|
|
}
|
|
_pausedSubscribe = Completer<void>();
|
|
_subscribeStarted = Completer<void>();
|
|
}
|
|
|
|
void resumePausedSubscribe() {
|
|
final paused = _pausedSubscribe;
|
|
if (paused == null) throw StateError('No subscription is paused');
|
|
paused.complete();
|
|
}
|
|
|
|
/// Holds the next directory query open so a community or identity switch can
|
|
/// be interleaved between the request and its response.
|
|
void pauseNextDirectoryQuery() {
|
|
if (_pausedDirectory != null) {
|
|
throw StateError('A directory query is already paused');
|
|
}
|
|
_pausedDirectory = Completer<void>();
|
|
_directoryStarted = Completer<void>();
|
|
}
|
|
|
|
/// Completes once the paused directory query has been requested.
|
|
Future<void> get nextDirectoryQueryStarted async {
|
|
final started = _directoryStarted;
|
|
if (started == null) {
|
|
throw StateError('No directory query is pending');
|
|
}
|
|
await started.future;
|
|
}
|
|
|
|
/// Releases the paused directory query so its response lands.
|
|
void resumePausedDirectoryQuery() {
|
|
final paused = _pausedDirectory;
|
|
if (paused == null) throw StateError('No directory query is paused');
|
|
paused.complete();
|
|
}
|
|
|
|
/// Holds the next hidden-DM query open, one shot only.
|
|
///
|
|
/// One shot matters: the refresh that follows the switch issues its own
|
|
/// hidden-DM query, and it must be able to finish while the earlier scope's
|
|
/// query is still parked. That is the window Jed's second probe describes.
|
|
void pauseNextHiddenDmQuery() {
|
|
if (_pausedHiddenDm != null) {
|
|
throw StateError('A hidden-DM query is already paused');
|
|
}
|
|
_pausedHiddenDm = Completer<void>();
|
|
_hiddenDmStarted = Completer<void>();
|
|
}
|
|
|
|
/// Completes once the parked hidden-DM query has been requested.
|
|
Future<void> get nextHiddenDmQueryStarted async {
|
|
final started = _hiddenDmStarted;
|
|
if (started == null) {
|
|
throw StateError('No hidden-DM query is pending');
|
|
}
|
|
await started.future;
|
|
}
|
|
|
|
/// Releases the parked hidden-DM query so its response lands.
|
|
void resumePausedHiddenDmQuery() {
|
|
final paused = _claimedHiddenDm ?? _pausedHiddenDm;
|
|
if (paused == null) throw StateError('No hidden-DM query is paused');
|
|
paused.complete();
|
|
}
|
|
|
|
/// Holds the next Huddle-start query open, one shot only.
|
|
///
|
|
/// Parks the older refresh AFTER both membership fetches have landed, so the
|
|
/// only guard left between the park and the member-snapshot write is the
|
|
/// fence on this leg.
|
|
void pauseNextHuddleStartQuery() {
|
|
if (_pausedHuddleStarts != null) {
|
|
throw StateError('A Huddle-start query is already paused');
|
|
}
|
|
_pausedHuddleStarts = Completer<void>();
|
|
_huddleStartsStarted = Completer<void>();
|
|
}
|
|
|
|
/// Completes once the parked Huddle-start query has been requested.
|
|
Future<void> get nextHuddleStartQueryStarted async {
|
|
final started = _huddleStartsStarted;
|
|
if (started == null) {
|
|
throw StateError('No Huddle-start query is pending');
|
|
}
|
|
await started.future;
|
|
}
|
|
|
|
/// Releases the parked Huddle-start query so its response lands.
|
|
void resumePausedHuddleStartQuery() {
|
|
final paused = _claimedHuddleStarts ?? _pausedHuddleStarts;
|
|
if (paused == null) throw StateError('No Huddle-start query is paused');
|
|
_claimedHuddleStarts = null;
|
|
_pausedHuddleStarts = null;
|
|
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');
|
|
}
|
|
_pausedMemberCount = Completer<void>();
|
|
_memberCountStarted = Completer<void>();
|
|
}
|
|
|
|
/// Completes once the parked member-count query has been requested.
|
|
Future<void> get nextMemberCountQueryStarted async {
|
|
final started = _memberCountStarted;
|
|
if (started == null) {
|
|
throw StateError('No member-count 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');
|
|
paused.complete();
|
|
}
|
|
|
|
/// Holds the next unread catch-up batch open, one shot only.
|
|
///
|
|
/// The catch-up runs detached from the refresh that starts it, so this is the
|
|
/// window where a community or identity switch can land between the request
|
|
/// and the writes its response drives.
|
|
void pauseNextUnreadCatchUpQuery() {
|
|
if (_pausedUnreadCatchUp != null) {
|
|
throw StateError('An unread catch-up query is already paused');
|
|
}
|
|
_pausedUnreadCatchUp = Completer<void>();
|
|
_unreadCatchUpStarted = Completer<void>();
|
|
}
|
|
|
|
/// Completes once the parked unread catch-up batch has been requested.
|
|
Future<void> get nextUnreadCatchUpQueryStarted async {
|
|
final started = _unreadCatchUpStarted;
|
|
if (started == null) {
|
|
throw StateError('No unread catch-up query is pending');
|
|
}
|
|
await started.future;
|
|
}
|
|
|
|
/// Releases the parked unread catch-up batch so its response lands.
|
|
void resumePausedUnreadCatchUpQuery() {
|
|
final paused = _claimedUnreadCatchUp ?? _pausedUnreadCatchUp;
|
|
if (paused == null) {
|
|
throw StateError('No unread catch-up query is paused');
|
|
}
|
|
paused.complete();
|
|
}
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
historyFilters.add(filter);
|
|
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();
|
|
}
|
|
if (filter.kinds.contains(39002) && filter.tags['#p'] != null) {
|
|
membershipRequestCount++;
|
|
if (membershipFailures > 0) {
|
|
membershipFailures--;
|
|
throw Exception('membership fetch failed');
|
|
}
|
|
// Membership query — return all memberships we have for this pubkey.
|
|
final myPk = filter.tags['#p']?.single;
|
|
return memberships
|
|
.where(
|
|
(e) =>
|
|
e.tags.any((t) => t.length >= 2 && t[0] == 'p' && t[1] == myPk),
|
|
)
|
|
.toList();
|
|
}
|
|
if (filter.kinds.contains(EventKind.dmVisibility)) {
|
|
// Claim the parked slot so the switch's own refresh runs unblocked.
|
|
final paused = _pausedHiddenDm;
|
|
if (paused != null) {
|
|
_claimedHiddenDm = paused;
|
|
_pausedHiddenDm = null;
|
|
_hiddenDmStarted!.complete();
|
|
_hiddenDmStarted = null;
|
|
await paused.future;
|
|
}
|
|
return hiddenDmEvents;
|
|
}
|
|
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.
|
|
final paused = _pausedHuddleStarts;
|
|
if (paused != null) {
|
|
_claimedHuddleStarts = paused;
|
|
_pausedHuddleStarts = null;
|
|
_huddleStartsStarted!.complete();
|
|
_huddleStartsStarted = null;
|
|
await paused.future;
|
|
}
|
|
return huddleStarts;
|
|
}
|
|
if (filter.kinds.contains(39000)) {
|
|
final ids = filter.tags['#d']?.toSet();
|
|
if (ids == null) {
|
|
throw StateError('Directory queries must use the HTTP query bridge');
|
|
}
|
|
// Member metadata query — return only matching `d` tags.
|
|
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
@override
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
if (filters case [final filter]
|
|
when filter.kinds.length == 1 &&
|
|
filter.kinds.single == 39002 &&
|
|
filter.tags['#p'] != null) {
|
|
membershipQueryFilters.add(filter);
|
|
if (membershipFailures > 0) {
|
|
membershipFailures--;
|
|
throw Exception('membership fetch failed');
|
|
}
|
|
final requestIndex = membershipRequestCount++;
|
|
final maxRequests = maxMembershipPageRequests;
|
|
if (maxRequests != null && requestIndex >= maxRequests) {
|
|
throw StateError('Unexpected membership page request');
|
|
}
|
|
final pages = membershipPages;
|
|
if (pages != null) {
|
|
if (requestIndex < pages.length) return List.of(pages[requestIndex]);
|
|
if (repeatLastMembershipPage && pages.isNotEmpty) {
|
|
return List.of(pages.last);
|
|
}
|
|
return const [];
|
|
}
|
|
if (filter.until != null) return const [];
|
|
final myPk = filter.tags['#p']?.single;
|
|
return memberships
|
|
.where(
|
|
(event) => event.tags.any(
|
|
(tag) => tag.length >= 2 && tag[0] == 'p' && tag[1] == myPk,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
if (filters case [final filter]
|
|
when filter.kinds.length == 1 &&
|
|
filter.kinds.single == 39000 &&
|
|
!filter.tags.containsKey('#d')) {
|
|
directoryQueryFilters.add(filter);
|
|
// Snapshot the directory contents at request time so a paused response
|
|
// reflects the community that issued it, not whichever community is
|
|
// active when the response is released.
|
|
final directorySnapshot = List.of(metadata);
|
|
final paused = _pausedDirectory;
|
|
if (paused != null) {
|
|
_directoryStarted!.complete();
|
|
await paused.future;
|
|
_pausedDirectory = null;
|
|
_directoryStarted = null;
|
|
}
|
|
if (directoryFailures > 0) {
|
|
directoryFailures--;
|
|
throw Exception('directory fetch failed');
|
|
}
|
|
final requestIndex = metadataPageRequestCount++;
|
|
final maxRequests = maxMetadataPageRequests;
|
|
if (maxRequests != null && requestIndex >= maxRequests) {
|
|
throw StateError('Unexpected directory page request');
|
|
}
|
|
final pageBuilder = metadataPageBuilder;
|
|
if (pageBuilder != null) return List.of(pageBuilder(requestIndex));
|
|
final pages = metadataPages;
|
|
if (pages != null) {
|
|
if (requestIndex < pages.length) return List.of(pages[requestIndex]);
|
|
if (repeatLastMetadataPage && pages.isNotEmpty) {
|
|
return List.of(pages.last);
|
|
}
|
|
return const [];
|
|
}
|
|
return filter.until == null ? directorySnapshot : const [];
|
|
}
|
|
queryBatches.add(filters);
|
|
// The unread catch-up is the only batch that carries `since` on every
|
|
// filter; the latest-message batch leaves it null. Snapshot the messages at
|
|
// request time so a parked response reflects the scope that asked for it.
|
|
final isUnreadCatchUp =
|
|
filters.isNotEmpty && filters.every((filter) => filter.since != null);
|
|
final messageSnapshot = List.of(recentMessages);
|
|
if (isUnreadCatchUp) {
|
|
// Claim the parked slot so the refresh that follows the switch can run
|
|
// its own catch-up unblocked while this one stays parked.
|
|
final paused = _pausedUnreadCatchUp;
|
|
if (paused != null) {
|
|
_claimedUnreadCatchUp = paused;
|
|
_pausedUnreadCatchUp = null;
|
|
_unreadCatchUpStarted!.complete();
|
|
_unreadCatchUpStarted = null;
|
|
await paused.future;
|
|
if (failClaimedUnreadCatchUpQuery) {
|
|
throw Exception('unread catch-up fetch failed');
|
|
}
|
|
}
|
|
}
|
|
return messageSnapshot.where((event) {
|
|
return filters.any((filter) {
|
|
if (!filter.kinds.contains(event.kind)) return false;
|
|
for (final entry in filter.tags.entries) {
|
|
final tagName = entry.key.startsWith('#')
|
|
? entry.key.substring(1)
|
|
: entry.key;
|
|
if (!event.tags.any(
|
|
(tag) =>
|
|
tag.length > 1 &&
|
|
tag[0] == tagName &&
|
|
entry.value.contains(tag[1]),
|
|
)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Future<void Function()> subscribe(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
}) async {
|
|
totalSubscribeCount++;
|
|
subscribeFilters.add(filter);
|
|
final paused = _pausedSubscribe;
|
|
if (paused != null) {
|
|
_subscribeStarted!.complete();
|
|
await paused.future;
|
|
_pausedSubscribe = null;
|
|
_subscribeStarted = null;
|
|
}
|
|
if (subscribeFailures > 0 && successfulSubscribesBeforeFailure == 0) {
|
|
subscribeFailures--;
|
|
subscribeFilters.remove(filter);
|
|
throw StateError('live subscription failed');
|
|
}
|
|
if (successfulSubscribesBeforeFailure > 0) {
|
|
successfulSubscribesBeforeFailure--;
|
|
}
|
|
final subscriptionKey = ++_nextSubscriptionKey;
|
|
_subscriptions[subscriptionKey] = (filter, onEvent, onClosed);
|
|
return () {
|
|
final subscription = _subscriptions.remove(subscriptionKey);
|
|
if (subscription == null) return;
|
|
unsubscribeCount++;
|
|
subscribeFilters.remove(subscription.$1);
|
|
};
|
|
}
|
|
|
|
void closeSubscriptionContaining(String channelId, String message) {
|
|
final entry = _subscriptions.entries.singleWhere(
|
|
(entry) => entry.value.$1.tags['#h']?.contains(channelId) ?? false,
|
|
);
|
|
_subscriptions.remove(entry.key);
|
|
subscribeFilters.remove(entry.value.$1);
|
|
entry.value.$3?.call(message);
|
|
}
|
|
|
|
void setStatus(SessionStatus status) {
|
|
state = SessionState(status: status);
|
|
}
|
|
|
|
/// Emit a live event to all subscribers.
|
|
void emit(NostrEvent event) {
|
|
for (final (_, listener, _) in List.of(_subscriptions.values)) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
|
|
@override
|
|
AppLifecycleState build() => AppLifecycleState.resumed;
|
|
}
|