fix(mobile): open message links to public channels the user has not joined (#8518)

🤖
## Summary
- **Problem:** On mobile, tapping a link to a message in a public
channel did nothing if you had not joined that channel. The app showed a
brief "Channel not found in this workspace" notice and dropped the link,
although you are allowed to read that channel.
- **Now:** The app asks the relay for that one channel. If the channel
is public, it opens as a channel you can read but have not joined, the
same as opening it from Browse channels. Private channels and DMs still
open only when you are a member.

## Details
- Before, the link handler looked for the channel only in the app's
loaded channel list. That list holds the channels you joined. It holds
other public channels only after Browse channels has loaded them.
- When the channel is not in that list, the app now reads that channel's
metadata from the relay (kind:39000, the event that names and describes
a channel). The relay returns it only if you may see it. The app then
refuses private channels and DMs, as Browse already does.
- If the lookup fails, the app shows "Could not open the channel". If
the relay returns no channel, it still shows "Channel not found in this
workspace".
- Links are handled in arrival order. While one link is being looked up,
later links wait, including links to channels you already joined. When
the lookup ends, the app handles the next link. A slow relay can
therefore delay later links by up to the lookup timeout (8 s). The app
shows no loading indicator during the wait.
- A link to a channel already in the list opens at once, even while the
app reconnects to the relay. A link that needs the relay lookup waits
until the relay connection is signed in, then opens without a second
tap. If the connection drops during the lookup, the app keeps the link
and tries again after it reconnects.
- During hosted setup the app turns link navigation off. If a lookup
ends during setup, the link stays queued and opens when setup closes. It
does not open a channel over the setup screen.

### Related issue
None found. #8292 covers a different part of message links.

### Testing
- New dispatcher tests cover a public channel you have not joined, a
private channel you have not joined, and a failed lookup. All three fail
on main and pass here.
- Delayed-lookup tests hold the relay response open. They cover
navigation turning off during the lookup (the link opens only after
navigation turns back on; this test fails without the fix), a queued
link behind a slow lookup, and the dispatcher closing during the lookup.
- Reconnect tests start with cached channels and a reconnecting session.
An unlisted-channel link sends no lookup and stays queued, then opens
after the session connects. A listed-channel link opens at once while
reconnecting. A session drop during the lookup keeps the link and
retries it, both when the session returns after the cancelled lookup
ends and before it ends. The three reconnect cases fail on the previous
head.
- `flutter test test/features/channels test/features/onboarding` (1990
tests) and `flutter analyze` pass on the rebased head.
- End-to-end on an Android emulator (API 36) against a local relay.
Alice made a public channel `#launch-notes` and posted in it. In a
private channel `#team`, she posted a link to that message. The phone
user is a member of `#team` but not of `#launch-notes`. The same install
was upgraded in place from a main build to this branch's build.

| Before (main): tap the link | After: tap the same link | After: link
to a private channel you are not in |
| --- | --- | --- |
| <img width="300" alt="Tapping the message link shows 'Channel not
found in this workspace' and stays in #team"
src="https://github.com/user-attachments/assets/aa4341aa-8d87-4a78-a805-ef71f4bd6cc5"
/> | <img width="300" alt="Tapping the message link opens #launch-notes
with Alice's message and a 'Join this channel from Manage to
participate' footer"
src="https://github.com/user-attachments/assets/267930ab-b723-4d97-bb7f-960fe1497e63"
/> | <img width="300" alt="Tapping a link to a private channel still
shows 'Channel not found in this workspace'"
src="https://github.com/user-attachments/assets/10780121-c3a4-4cf4-a8d1-a6994cac0101"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-27 14:01:12 -04:00
committed by GitHub
parent ff9eb8f4dc
commit a4e83dfb50
3 changed files with 579 additions and 10 deletions

View File

@@ -1022,6 +1022,28 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
/// Reads [channelId]'s kind:39000 metadata so a link can open a channel
/// that is not in the loaded list.
///
/// The loaded list holds member channels and, after Browse, the open
/// directory. A link can name an open channel outside both. The relay
/// filters the read by the caller's access, so call this only on a
/// connected session. Like directory discovery, a private channel or DM
/// opens only from the member list. Returns null when no channel qualifies.
Future<Channel?> fetchOpenChannel(String channelId) async {
final metas = await ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.channelMetadata([channelId]));
final latest = _latestChannelEvents(
metas,
kind: 39000,
).where((event) => event.getTagValue('d') == channelId).firstOrNull;
if (latest == null) return null;
final data = ChannelData.fromEvent(latest);
if (data.visibility == 'private' || data.channelType == 'dm') return null;
return _channelFromMeta(latest, data, isMember: false);
}
/// Loads the directory when Browse channels opens after startup or an error.
Future<void> ensureDirectoryLoaded() async {
final directoryState = ref.read(channelDirectoryLoadStatusProvider);

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/relay/relay.dart';
import '../invites/invite_join_provider.dart';
import '../invites/invite_join_sheet.dart';
import 'channel.dart';
@@ -45,6 +46,9 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget {
class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
bool _preparingInvite = false;
bool _handlingOnboarding = false;
bool _resolvingLink = false;
bool _lookupInterrupted = false;
ProviderSubscription<SessionState>? _session;
@override
void initState() {
@@ -55,6 +59,18 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
});
}
@override
void didUpdateWidget(DeepLinkDispatcher oldWidget) {
super.didUpdateWidget(oldWidget);
// A link parked while dispatch was off waits for this, not a new event.
if (widget.dispatchMessageLinks && !oldWidget.dispatchMessageLinks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
}
@override
Widget build(BuildContext context) {
// Re-evaluate dispatch when either a new link arrives or channels load.
@@ -164,6 +180,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
}
void _dispatchNavigableLink(BuzzDeepLink link) {
if (_resolvingLink || !widget.dispatchMessageLinks) return;
final channelId = switch (link) {
MessageDeepLink(:final channelId) => channelId,
ChannelDeepLink(:final channelId) => channelId,
@@ -174,24 +191,86 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
// listener re-attempts once data arrives.
if (channels == null) return;
final channel = channels
.where((c) => c.id == channelId)
.cast<Channel?>()
.firstOrNull;
if (channel == null) {
// Cached channels stay usable while the session reconnects.
final listed = channels.where((c) => c.id == channelId).firstOrNull;
if (listed != null) {
_pushChannel(listed, link);
ref.read(pendingDeepLinkProvider.notifier).consume();
return;
}
// Keep the link parked; the session listener re-attempts on connect.
if (_watchSession().status != SessionStatus.connected) return;
unawaited(_fetchAndPush(link, channelId));
}
/// An unlisted channel's lookup needs an authenticated session. A drop
/// cancels a running lookup; a connect retries the parked link.
SessionState _watchSession() {
_session ??= ref.listenManual<SessionState>(relaySessionProvider, (
previous,
next,
) {
if (next.status != SessionStatus.connected) {
if (_resolvingLink) _lookupInterrupted = true;
return;
}
if (previous?.status != SessionStatus.connected) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
}
});
return _session!.read();
}
Future<void> _fetchAndPush(BuzzDeepLink link, String channelId) async {
_resolvingLink = true;
_lookupInterrupted = false;
Channel? channel;
Object? failure;
try {
channel = await ref
.read(channelsProvider.notifier)
.fetchOpenChannel(channelId);
} catch (error) {
failure = error;
} finally {
_resolvingLink = false;
}
// Dispatch can turn off during the lookup (hosted setup is showing).
// Keep the link parked; didUpdateWidget re-dispatches when it turns on.
if (!mounted || !widget.dispatchMessageLinks) return;
if (ref.read(pendingDeepLinkProvider) != link) {
// The guard above held back whatever replaced this link meanwhile.
_maybeDispatch(ref.read(pendingDeepLinkProvider));
return;
}
if (failure != null) {
final connected = _watchSession().status == SessionStatus.connected;
if (!connected || _lookupInterrupted) {
// The session dropped and cancelled the lookup. Keep the link: retry
// now if it is already back, else the session listener retries.
if (connected) _maybeDispatch(link);
return;
}
}
if (channel != null) {
_pushChannel(channel, link);
ref.read(pendingDeepLinkProvider.notifier).consume();
return;
}
ref.read(pendingDeepLinkProvider.notifier).consume();
if (failure != null) {
debugPrint('deep-link: channel $channelId lookup failed: $failure');
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Could not open the channel')),
);
} else {
debugPrint(
'deep-link: channel $channelId not found in workspace; dropping link',
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Channel not found in this workspace')),
);
return;
}
if (!context.mounted) return;
_pushChannel(channel, link);
ref.read(pendingDeepLinkProvider.notifier).consume();
}
void _pushChannel(Channel channel, BuzzDeepLink link) {

View File

@@ -7,6 +7,7 @@ import 'package:buzz/features/invites/invite_join_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
@@ -113,11 +114,13 @@ void main() {
const missing = ChannelDeepLink(channelId: 'missing-channel');
const next = ChannelDeepLink(channelId: 'channel-1');
final pending = _QueuedPendingDeepLinkNotifier([missing, next]);
final session = _MetadataRelaySession(const []);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
@@ -142,6 +145,392 @@ void main() {
expect(destination.link, same(next));
});
for (final visibility in ['open', 'private']) {
testWidgets('resolves an unlisted $visibility channel from the relay', (
tester,
) async {
const link = MessageDeepLink(
channelId: 'unjoined-channel',
messageId: 'message-2',
threadRootId: 'message-1',
);
final pending = _RecordingPendingDeepLinkNotifier(link);
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', visibility, createdAt: 1),
_channelMeta('unjoined-channel', 'agents-renamed', visibility),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
expect(session.filters, hasLength(1));
expect(session.filters.single.kinds, [39000]);
expect(session.filters.single.tags['#d'], ['unjoined-channel']);
expect(pending.consumeCalls, 1);
if (visibility == 'private') {
expect(find.byType(_CapturedDestination), findsNothing);
expect(
find.text('Channel not found in this workspace'),
findsOneWidget,
);
return;
}
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'unjoined-channel');
expect(destination.channel.name, 'agents-renamed');
expect(destination.channel.isMember, isFalse);
expect(destination.link, same(link));
});
}
testWidgets('holds a link whose lookup ends while dispatch is off', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'unjoined-channel');
final pending = _RecordingPendingDeepLinkNotifier(link);
final gate = Completer<void>();
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', 'open'),
], gate: gate);
final dispatch = ValueNotifier(true);
addTearDown(dispatch.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: ValueListenableBuilder<bool>(
valueListenable: dispatch,
builder: (_, enabled, _) => DeepLinkDispatcher(
dispatchMessageLinks: enabled,
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(session.filters, hasLength(1));
// Hosted setup appears while the lookup is still waiting on the relay.
dispatch.value = false;
await tester.pump();
gate.complete();
await tester.pumpAndSettle();
expect(find.byType(_CapturedDestination), findsNothing);
expect(pending.consumeCalls, 0);
expect(find.text('Channel not found in this workspace'), findsNothing);
// Setup closes: the parked link opens without a new link or channel event.
dispatch.value = true;
await tester.pumpAndSettle();
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'unjoined-channel');
expect(destination.link, same(link));
expect(pending.consumeCalls, 1);
});
testWidgets('parks an unlisted channel link until the session connects', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'unjoined-channel');
final pending = _RecordingPendingDeepLinkNotifier(link);
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', 'open'),
], initialStatus: SessionStatus.reconnecting);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
// Cached channels are loaded, but no lookup goes to an unauthenticated
// socket and the link stays parked.
expect(session.filters, isEmpty);
expect(pending.consumeCalls, 0);
expect(find.byType(_CapturedDestination), findsNothing);
expect(find.byType(SnackBar), findsNothing);
session.setStatus(SessionStatus.connected);
await tester.pumpAndSettle();
expect(session.filters, hasLength(1));
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'unjoined-channel');
expect(destination.link, same(link));
expect(pending.consumeCalls, 1);
});
testWidgets('opens a listed channel link while the session reconnects', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'channel-1');
final pending = _RecordingPendingDeepLinkNotifier(link);
final session = _MetadataRelaySession(
const [],
initialStatus: SessionStatus.reconnecting,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
expect(session.filters, isEmpty);
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'channel-1');
expect(pending.consumeCalls, 1);
});
for (final backBeforeLookupEnds in [false, true]) {
testWidgets('retries a lookup cancelled by a session drop '
'(back before it ends: $backBeforeLookupEnds)', (tester) async {
const link = ChannelDeepLink(channelId: 'unjoined-channel');
final pending = _RecordingPendingDeepLinkNotifier(link);
final gate = Completer<void>();
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', 'open'),
], gate: gate);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pump();
await tester.pump();
expect(session.filters, hasLength(1));
session.drop();
if (backBeforeLookupEnds) {
session.setStatus(SessionStatus.connected);
} else {
await tester.pumpAndSettle();
expect(pending.consumeCalls, 0);
expect(find.byType(_CapturedDestination), findsNothing);
expect(find.byType(SnackBar), findsNothing);
session.setStatus(SessionStatus.connected);
}
await tester.pump();
gate.complete();
await tester.pumpAndSettle();
expect(session.filters, hasLength(2));
expect(find.byType(SnackBar), findsNothing);
final destination = tester.widget<_CapturedDestination>(
find.byType(_CapturedDestination),
);
expect(destination.channel.id, 'unjoined-channel');
expect(destination.link, same(link));
expect(pending.consumeCalls, 1);
});
}
testWidgets('dispatches a queued link after a slow lookup finishes', (
tester,
) async {
const slow = ChannelDeepLink(channelId: 'unjoined-channel');
const next = ChannelDeepLink(channelId: 'channel-1');
final pending = _QueuedPendingDeepLinkNotifier([slow, next]);
final gate = Completer<void>();
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', 'open'),
], gate: gate);
final opened = <BuzzDeepLink>[];
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) {
opened.add(link);
return _CapturedDestination(channel: channel, link: link);
},
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pump();
await tester.pump();
expect(opened, isEmpty);
expect(pending.consumeCalls, 0);
gate.complete();
await tester.pumpAndSettle();
expect(opened, [slow, next]);
expect(pending.consumeCalls, 2);
});
testWidgets('drops a lookup that ends after the dispatcher unmounts', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'unjoined-channel');
final pending = _RecordingPendingDeepLinkNotifier(link);
final gate = Completer<void>();
final session = _MetadataRelaySession([
_channelMeta('unjoined-channel', 'agents', 'open'),
], gate: gate);
final container = ProviderContainer(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pump();
await tester.pump();
expect(session.filters, hasLength(1));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: Scaffold(body: Text('Signed out'))),
),
);
gate.complete();
await tester.pumpAndSettle();
expect(find.byType(_CapturedDestination), findsNothing);
expect(pending.consumeCalls, 0);
expect(container.read(pendingDeepLinkProvider), same(link));
});
testWidgets('reports a failed relay lookup and drops the link', (
tester,
) async {
const link = ChannelDeepLink(channelId: 'unjoined-channel');
final pending = _RecordingPendingDeepLinkNotifier(link);
final session = _MetadataRelaySession(const [], fail: true);
await tester.pumpWidget(
ProviderScope(
overrides: [
pendingDeepLinkProvider.overrideWith(() => pending),
relaySessionProvider.overrideWith(() => session),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(Future.value([_channel])),
),
],
child: MaterialApp(
home: DeepLinkDispatcher(
destinationBuilder: (channel, link) =>
_CapturedDestination(channel: channel, link: link),
child: const Scaffold(body: SizedBox()),
),
),
),
);
await tester.pumpAndSettle();
expect(pending.consumeCalls, 1);
expect(find.byType(_CapturedDestination), findsNothing);
expect(find.text('Could not open the channel'), findsOneWidget);
});
testWidgets('dispatches a link that is already ready on mount', (
tester,
) async {
@@ -874,6 +1263,85 @@ class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
BuzzDeepLink? build() => link;
}
NostrEvent _channelMeta(
String id,
String name,
String visibility, {
int createdAt = 2,
}) => NostrEvent(
id: 'meta-$id-$createdAt',
pubkey: 'relay',
createdAt: createdAt,
kind: 39000,
tags: [
['d', id],
['name', name],
['t', 'stream'],
[visibility == 'private' ? 'private' : 'public'],
],
content: '',
sig: 'sig',
);
/// Answers kind:39000 lookups with canned metadata and records each filter.
class _MetadataRelaySession extends RelaySessionNotifier {
_MetadataRelaySession(
this.metas, {
this.fail = false,
this.gate,
this.initialStatus = SessionStatus.connected,
});
final List<NostrEvent> metas;
final bool fail;
/// When set, each lookup waits for it, so tests can act mid-lookup.
final Completer<void>? gate;
final SessionStatus initialStatus;
final filters = <NostrFilter>[];
final _running = <Completer<List<NostrEvent>>>[];
@override
SessionState build() => SessionState(status: initialStatus);
void setStatus(SessionStatus status) => state = SessionState(status: status);
/// A socket drop cancels every running lookup, then starts reconnecting.
void drop() {
for (final lookup in _running) {
if (!lookup.isCompleted) lookup.completeError(Exception('Lost'));
}
_running.clear();
setStatus(SessionStatus.reconnecting);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) {
filters.add(filter);
final lookup = Completer<List<NostrEvent>>();
_running.add(lookup);
Future<void> answer() async {
if (gate case final gate?) await gate.future;
if (lookup.isCompleted) return;
if (fail) {
lookup.completeError(StateError('relay unavailable'));
return;
}
final ids = filter.tags['#d'] ?? const <String>[];
lookup.complete([
for (final event in metas)
if (ids.contains(event.getTagValue('d'))) event,
]);
}
unawaited(answer());
return lookup.future;
}
}
class _FakeChannelsNotifier extends ChannelsNotifier {
_FakeChannelsNotifier(this.channels);