diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 30ba2ec835..765d9ff347 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -1022,6 +1022,28 @@ class ChannelsNotifier extends AsyncNotifier> { } } + /// 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 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 ensureDirectoryLoaded() async { final directoryState = ref.read(channelDirectoryLoadStatusProvider); diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 177163ebbd..cc7f7cd24e 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -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 { bool _preparingInvite = false; bool _handlingOnboarding = false; + bool _resolvingLink = false; + bool _lookupInterrupted = false; + ProviderSubscription? _session; @override void initState() { @@ -55,6 +59,18 @@ class _DeepLinkDispatcherState extends ConsumerState { }); } + @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 { } 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 { // listener re-attempts once data arrives. if (channels == null) return; - final channel = channels - .where((c) => c.id == channelId) - .cast() - .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(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 _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) { diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 72c9abda5e..c915222ab8 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -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(); + 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( + 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(); + 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(); + final session = _MetadataRelaySession([ + _channelMeta('unjoined-channel', 'agents', 'open'), + ], gate: gate); + final opened = []; + + 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(); + 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 metas; + final bool fail; + + /// When set, each lookup waits for it, so tests can act mid-lookup. + final Completer? gate; + final SessionStatus initialStatus; + final filters = []; + final _running = >>[]; + + @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> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) { + filters.add(filter); + final lookup = Completer>(); + _running.add(lookup); + Future 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 []; + 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);