9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
731 lines
24 KiB
Dart
731 lines
24 KiB
Dart
import 'dart:async';
|
|
|
|
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';
|
|
|
|
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
|
|
///
|
|
/// The provider performs a two-step WS query:
|
|
/// 1. kind:39002 memberships tagged `#p:<my-pubkey>`
|
|
/// 2. kind:39000 metadata for those channel ids
|
|
/// then layers per-channel live subscriptions on the `#h` tag.
|
|
///
|
|
/// 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(
|
|
'subscribes per-channel with #h tags (only joined, non-archived)',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
_membership(_channelD, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
// channelD metadata missing -> won't appear in channel list
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
|
|
// One subscription per joined, non-archived channel.
|
|
expect(session.subscribeFilters, hasLength(2));
|
|
expect(
|
|
session.subscribeFilters.map((f) => f.tags['#h']?.single).toSet(),
|
|
{_channelA, _channelB},
|
|
);
|
|
for (final filter in session.subscribeFilters) {
|
|
expect(filter.kinds, EventKind.channelEventKinds);
|
|
expect(filter.limit, 0);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'refreshing an unchanged channel set issues zero new live REQs',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
final initialSubscribeCount = session.totalSubscribeCount;
|
|
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
expect(session.totalSubscribeCount, initialSubscribeCount);
|
|
expect(session.unsubscribeCount, 0);
|
|
expect(session.subscribeFilters, hasLength(2));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'live subscription diff only removes and adds changed channels',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
session.memberships = [
|
|
_membership(_channelB, myPk),
|
|
_membership(_channelD, myPk),
|
|
];
|
|
session.metadata = [
|
|
_meta(id: _channelB, name: 'random'),
|
|
_meta(id: _channelD, name: 'support'),
|
|
];
|
|
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
expect(session.totalSubscribeCount, 3);
|
|
expect(session.unsubscribeCount, 1);
|
|
expect(
|
|
session.subscribeFilters
|
|
.map((filter) => filter.tags['#h']!.single)
|
|
.toSet(),
|
|
{_channelB, _channelD},
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'empty channel refresh removes every retained live subscription',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
session.memberships = [];
|
|
session.metadata = [];
|
|
|
|
await container.read(channelsProvider.notifier).refresh();
|
|
|
|
expect(session.activeChannels, isEmpty);
|
|
expect(session.activeSubscriptionCount, 0);
|
|
expect(session.unsubscribeCount, 2);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'overlapping refreshes retain one live subscription per desired channel',
|
|
() async {
|
|
final session = _FakeRelaySession(
|
|
memberships: [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
],
|
|
metadata: [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
],
|
|
);
|
|
final container = _buildContainer(session: session);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(channelsProvider.future);
|
|
session.pauseNextSubscribe();
|
|
session.memberships = [
|
|
_membership(_channelA, myPk),
|
|
_membership(_channelB, myPk),
|
|
_membership(_channelD, myPk),
|
|
];
|
|
session.metadata = [
|
|
_meta(id: _channelA, name: 'general'),
|
|
_meta(id: _channelB, name: 'random'),
|
|
_meta(id: _channelD, name: 'support'),
|
|
];
|
|
|
|
final firstRefresh = container.read(channelsProvider.notifier).refresh();
|
|
await session.nextSubscribeStarted;
|
|
final secondRefresh = container.read(channelsProvider.notifier).refresh();
|
|
session.resumePausedSubscribe();
|
|
await Future.wait([firstRefresh, secondRefresh]);
|
|
|
|
expect(session.activeChannels, {_channelA, _channelB, _channelD});
|
|
expect(session.activeSubscriptionCount, 3);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'community switch replaces retained live subscriptions on the new relay',
|
|
() 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.activeChannels, {_channelA});
|
|
|
|
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);
|
|
session.setStatus(SessionStatus.connected);
|
|
await container.read(channelsProvider.future);
|
|
await _waitUntil(
|
|
() =>
|
|
session.activeChannels.length == 1 &&
|
|
session.activeChannels.contains(_channelB),
|
|
);
|
|
|
|
expect(session.activeChannels, {_channelB});
|
|
expect(session.activeSubscriptionCount, 1);
|
|
expect(session.unsubscribeCount, 1);
|
|
},
|
|
);
|
|
|
|
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('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('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');
|
|
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);
|
|
|
|
// Two history fetches for channel loading, plus one per non-DM channel
|
|
// for high-priority event backfill.
|
|
expect(session.historyFilters.length, greaterThanOrEqualTo(2));
|
|
expect(session.historyFilters[0].kinds, [39002]);
|
|
expect(session.historyFilters[0].tags['#p'], [myPk]);
|
|
expect(session.historyFilters[1].kinds, [39000]);
|
|
expect(session.historyFilters[1].tags['#d'], [_channelA]);
|
|
|
|
// And one live subscription on the resulting channel.
|
|
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';
|
|
|
|
/// Build a kind:39002 membership event tagged with the channel id and member.
|
|
NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
|
|
id: 'mem-$channelId',
|
|
pubkey: 'creator',
|
|
createdAt: 1,
|
|
kind: 39002,
|
|
tags: [
|
|
['d', channelId],
|
|
['p', pubkey],
|
|
],
|
|
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',
|
|
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],
|
|
['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),
|
|
myPubkeyProvider.overrideWithValue('me'),
|
|
],
|
|
);
|
|
}
|
|
|
|
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 events from [fetchHistory]
|
|
/// and records subscribe calls.
|
|
class _FakeRelaySession extends RelaySessionNotifier {
|
|
_FakeRelaySession({
|
|
required this.memberships,
|
|
required this.metadata,
|
|
this.hiddenDmEvents = const [],
|
|
this.membershipFailures = 0,
|
|
});
|
|
|
|
List<NostrEvent> memberships;
|
|
List<NostrEvent> metadata;
|
|
final List<NostrEvent> hiddenDmEvents;
|
|
int membershipFailures;
|
|
|
|
final List<NostrFilter> historyFilters = [];
|
|
final List<NostrFilter> subscribeFilters = [];
|
|
final Map<int, (NostrFilter, void Function(NostrEvent))> _subscriptions = {};
|
|
int _nextSubscriptionKey = 0;
|
|
Completer<void>? _pausedSubscribe;
|
|
Completer<void>? _subscribeStarted;
|
|
int unsubscribeCount = 0;
|
|
int totalSubscribeCount = 0;
|
|
|
|
Set<String> get activeChannels => {
|
|
for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single,
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
@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['#p'] != null) {
|
|
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)) {
|
|
return hiddenDmEvents;
|
|
}
|
|
if (filter.kinds.contains(39000)) {
|
|
// Metadata query — return all metadata events whose `d` tag matches.
|
|
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();
|
|
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
@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;
|
|
}
|
|
final subscriptionKey = ++_nextSubscriptionKey;
|
|
_subscriptions[subscriptionKey] = (filter, onEvent);
|
|
return () {
|
|
final subscription = _subscriptions.remove(subscriptionKey);
|
|
if (subscription == null) return;
|
|
unsubscribeCount++;
|
|
subscribeFilters.remove(subscription.$1);
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|