diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index b0b6b1f368..a27e81e9c6 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -2,6 +2,7 @@ import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/widgets/compose_morph_icon.dart'; import '../../shared/me/me_sync.dart'; + import 'dart:async'; import 'dart:io'; import 'dart:math' show max, min, pi; @@ -77,7 +78,7 @@ part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; part 'channels_page/header_actions.dart'; -enum _QuickAction { createChannel, newDm, browseChannels } +enum _QuickAction { createChannel, newDm } const double _kChannelSectionInset = Grid.gutter; const double _kChannelLeadingWidth = 22.0; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ce2d3fb8d6..ab67e2d390 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -254,6 +254,25 @@ class _SliverChannelsList extends HookConsumerWidget { ), sliver: SliverList.list( children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const Key('browse-channels-button'), + icon: const Icon(BuzzIcons.compass), + label: const Text('Browse channels'), + onPressed: () => showBuzzModalBottomSheet( + context: context, + title: 'Browse channels', + constraints: _quickActionSheetConstraints(context), + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _BrowseChannelsSheet(), + ), + ), + ), + ), if (visibleChannels.isEmpty) const _EmptyState() else ...[ diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index e11772d188..480173ef30 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions.dart @@ -7,7 +7,7 @@ const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1); const double _kMorphOpenBounce = 0.14; const double _kMorphCloseBounce = 0.06; const double _kMorphClosedSize = 56; -const double _kMorphOpenHeight = 216; +const double _kMorphOpenHeight = 142; const double _kMorphOpenRadius = mobileTabBarHeight / 2; const double _kMorphSlide = 40; const double _kMorphScale = 0.97; @@ -300,23 +300,24 @@ class _QuickActionsMenu extends StatelessWidget { _QuickActionItem( icon: BuzzIcons.hash, title: 'Create channel', + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_kQuickActionCardRadius), + bottom: Radius.circular(8), + ), key: const Key('quick-action-create-channel-card'), onTap: () => onSelected(_QuickAction.createChannel), ), - const SizedBox(height: Grid.xxs), + const SizedBox(height: Grid.xxs - 2), _QuickActionItem( icon: BuzzIcons.messagesSquare, title: 'New direct message', + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + bottom: Radius.circular(_kQuickActionCardRadius), + ), key: const Key('quick-action-new-dm-card'), onTap: () => onSelected(_QuickAction.newDm), ), - const SizedBox(height: Grid.xxs), - _QuickActionItem( - icon: BuzzIcons.compass, - title: 'Browse channels', - key: const Key('quick-action-browse-channels-card'), - onTap: () => onSelected(_QuickAction.browseChannels), - ), ], ), ); @@ -327,12 +328,14 @@ class _QuickActionItem extends StatelessWidget { final IconData icon; final String title; final VoidCallback onTap; + final BorderRadius borderRadius; const _QuickActionItem({ super.key, required this.icon, required this.title, required this.onTap, + required this.borderRadius, }); @override @@ -340,7 +343,10 @@ class _QuickActionItem extends StatelessWidget { final isIos = defaultTargetPlatform == TargetPlatform.iOS; final foreground = isIos ? context.colors.onSurface : Colors.white; final background = isIos - ? context.controlSurface + ? Color.alphaBlend( + Colors.black.withValues(alpha: 0.05), + context.controlSurface, + ) : Color.alphaBlend( Colors.white.withValues(alpha: 0.1), const Color(0xFF262626), @@ -349,9 +355,7 @@ class _QuickActionItem extends StatelessWidget { return Expanded( child: Material( color: background, - shape: RoundedSuperellipseBorder( - borderRadius: BorderRadius.circular(_kQuickActionCardRadius), - ), + shape: RoundedSuperellipseBorder(borderRadius: borderRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index a7c8cc2153..b1cc1d6fbd 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -121,15 +121,6 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (opened != null && context.mounted) { await openChannel(opened); } - case _QuickAction.browseChannels: - await showBuzzModalBottomSheet( - context: context, - title: 'Browse channels', - constraints: _quickActionSheetConstraints(context), - isScrollControlled: true, - showDragHandle: true, - builder: (_) => const _BrowseChannelsSheet(), - ); } } diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 9f0701889d..61fe9c746d 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -59,6 +59,7 @@ import 'package:buzz/shared/widgets/skeleton.dart'; import '../../helpers/thread_ids.dart'; part 'channels_page_test/presence_tests.dart'; +part 'channels_page_test/discovery_journey_tests.dart'; part 'channels_page_test/unread_readiness_tests.dart'; void main() { @@ -157,6 +158,7 @@ void main() { } _communityUnreadLandingTests(buildTestable); + _discoveryJourneyTests(buildTestable); final testChannels = [ Channel( @@ -2949,8 +2951,8 @@ void main() { } await tester.pumpAndSettle(); - expect(largestHeight, greaterThan(216)); - expect(tester.getSize(surface).height, closeTo(216, 0.01)); + expect(largestHeight, greaterThan(142)); + expect(tester.getSize(surface).height, closeTo(142, 0.01)); final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width; final surfaceRect = tester.getRect(surface); expect(surfaceRect.left, closeTo(20, 0.01)); @@ -2963,23 +2965,15 @@ void main() { const Key('quick-action-create-channel-card'), ); final dmCard = find.byKey(const Key('quick-action-new-dm-card')); - final browseCard = find.byKey( - const Key('quick-action-browse-channels-card'), - ); final createRect = tester.getRect(createCard); final dmRect = tester.getRect(dmCard); - final browseRect = tester.getRect(browseCard); expect(createRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - createRect.right, closeTo(8, 0.01)); expect(dmRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - dmRect.right, closeTo(8, 0.01)); - expect(browseRect.left - menuRect.left, closeTo(8, 0.01)); - expect(menuRect.right - browseRect.right, closeTo(8, 0.01)); - expect(dmRect.top - createRect.bottom, closeTo(8, 0.01)); - expect(browseRect.top - dmRect.bottom, closeTo(8, 0.01)); + expect(dmRect.top - createRect.bottom, closeTo(6, 0.01)); expect(dmRect.width, createRect.width); - expect(browseRect.width, createRect.width); expect(dmRect.width, closeTo(menuRect.width - 16, 0.01)); final expectedCardColor = Color.alphaBlend( @@ -2992,25 +2986,21 @@ void main() { final dmMaterial = tester.widget( find.descendant(of: dmCard, matching: find.byType(Material)).first, ); - final browseMaterial = tester.widget( - find.descendant(of: browseCard, matching: find.byType(Material)).first, - ); expect(createMaterial.color, expectedCardColor); expect(dmMaterial.color, expectedCardColor); - expect(browseMaterial.color, expectedCardColor); expect( - ((createMaterial.shape as RoundedSuperellipseBorder).borderRadius - as BorderRadius) - .topLeft - .x, - closeTo(20, 0.01), + (createMaterial.shape as RoundedSuperellipseBorder).borderRadius, + const BorderRadius.vertical( + top: Radius.circular(20), + bottom: Radius.circular(8), + ), ); expect( - ((dmMaterial.shape as RoundedSuperellipseBorder).borderRadius - as BorderRadius) - .topLeft - .x, - closeTo(20, 0.01), + (dmMaterial.shape as RoundedSuperellipseBorder).borderRadius, + const BorderRadius.vertical( + top: Radius.circular(8), + bottom: Radius.circular(20), + ), ); expect(find.text('Start a new stream channel'), findsNothing); @@ -3023,8 +3013,8 @@ void main() { 16, ); expect( - tester.widget(find.text('Browse channels')).style?.fontSize, - 16, + find.byKey(const Key('quick-action-browse-channels-card')), + findsNothing, ); expect(find.text('Message one or more people'), findsNothing); }); @@ -3087,11 +3077,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Create or start conversation')); - await tester.pump(); - await tester.tap( - find.byKey(const Key('quick-action-browse-channels-card')), - ); + await tester.tap(find.byKey(const Key('browse-channels-button'))); await tester.pumpAndSettle(); expect( @@ -3123,11 +3109,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Create or start conversation')); - await tester.pump(); - await tester.tap( - find.byKey(const Key('quick-action-browse-channels-card')), - ); + await tester.tap(find.byKey(const Key('browse-channels-button'))); await tester.pumpAndSettle(); expect(find.text('No open channels available to join.'), findsOneWidget); @@ -3162,11 +3144,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Create or start conversation')); - await tester.pump(); - await tester.tap( - find.byKey(const Key('quick-action-browse-channels-card')), - ); + await tester.tap(find.byKey(const Key('browse-channels-button'))); await tester.pumpAndSettle(); expect(find.text('Couldn’t load open channels.'), findsOneWidget); @@ -3213,11 +3191,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Create or start conversation')); - await tester.pump(); - await tester.tap( - find.byKey(const Key('quick-action-browse-channels-card')), - ); + await tester.tap(find.byKey(const Key('browse-channels-button'))); await tester.pumpAndSettle(); expect(find.byType(BuzzLoadingIndicator), findsOneWidget); @@ -3269,11 +3243,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Create or start conversation')); - await tester.pump(); - await tester.tap( - find.byKey(const Key('quick-action-browse-channels-card')), - ); + await tester.tap(find.byKey(const Key('browse-channels-button'))); await tester.pumpAndSettle(); expect( @@ -4120,105 +4090,11 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } -class _RetryingDirectoryNotifier extends ChannelsNotifier { - _RetryingDirectoryNotifier({ - required this.initialChannels, - required this.retriedChannels, - }); - - final List initialChannels; - final List retriedChannels; - int retryCount = 0; - - @override - Future> build() async => initialChannels; - - @override - Future ensureDirectoryLoaded() async { - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markError(_activeDirectoryScope(ref)); - } - - @override - Future retryDirectory() async { - retryCount++; - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markLoading(_activeDirectoryScope(ref)); - await Future.delayed(Duration.zero); - state = AsyncData(retriedChannels); - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markLoaded(_activeDirectoryScope(ref)); - } -} - -class _SupersededDirectoryNotifier extends ChannelsNotifier { - _SupersededDirectoryNotifier({ - required this.initialChannels, - required this.retriedChannels, - }); - - final List initialChannels; - final List retriedChannels; - final _directoryCompletion = Completer(); - int retryCount = 0; - - @override - Future> build() async => initialChannels; - - @override - Future ensureDirectoryLoaded() async { - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markLoading(_activeDirectoryScope(ref)); - await _directoryCompletion.future; - } - - /// Mirrors an ordinary refresh invalidating the active directory request. - void supersedeLoadingDirectory() { - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markError(_activeDirectoryScope(ref)); - _directoryCompletion.complete(); - } - - @override - Future retryDirectory() async { - retryCount++; - state = AsyncData(retriedChannels); - ref - .read(channelDirectoryLoadStatusProvider.notifier) - .markLoaded(_activeDirectoryScope(ref)); - } -} - String _activeDirectoryScope(Ref ref) => channelDirectoryScope( ref.read(relayConfigProvider).baseUrl, ref.read(myPubkeyProvider), ); -class _RecordingChannelActions extends ChannelActions { - _RecordingChannelActions(Ref ref) - : super( - ref: ref, - session: ref.read(relaySessionProvider.notifier), - signedEventRelay: SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: null, - ), - currentPubkey: 'self', - ); - - final List joinedChannelIds = []; - - @override - Future joinChannel(String channelId) async { - joinedChannelIds.add(channelId); - } -} - class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { _FakeChannelSectionsNotifier(this._store); @@ -4424,3 +4300,97 @@ class _CompletedPairingNotifier extends PairingNotifier { @override PairingState build() => const PairingState(status: PairingStatus.success); } + +class _RetryingDirectoryNotifier extends ChannelsNotifier { + _RetryingDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + } + + @override + Future retryDirectory() async { + retryCount++; + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await Future.delayed(Duration.zero); + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +class _SupersededDirectoryNotifier extends ChannelsNotifier { + _SupersededDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + final _directoryCompletion = Completer(); + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await _directoryCompletion.future; + } + + /// Mirrors an ordinary refresh invalidating the active directory request. + void supersedeLoadingDirectory() { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + _directoryCompletion.complete(); + } + + @override + Future retryDirectory() async { + retryCount++; + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +class _RecordingChannelActions extends ChannelActions { + _RecordingChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); + + final List joinedChannelIds = []; + + @override + Future joinChannel(String channelId) async { + joinedChannelIds.add(channelId); + } +} diff --git a/mobile/test/features/channels/channels_page_test/discovery_journey_tests.dart b/mobile/test/features/channels/channels_page_test/discovery_journey_tests.dart new file mode 100644 index 0000000000..6c3c4cb82a --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/discovery_journey_tests.dart @@ -0,0 +1,192 @@ +part of '../channels_page_test.dart'; + +void _discoveryJourneyTests( + Widget Function({required List overrides}) buildTestable, +) { + testWidgets( + 'zero-membership user retries discovery and joins a conversation', + (tester) async { + final session = _DiscoveryJourneySession(); + await tester.pumpWidget( + buildTestable( + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWith((ref) => 'self'), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: _DiscoveryJourneyRelay(session), + currentPubkey: 'self', + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelsPage)), + ); + expect(container.read(channelsProvider).requireValue, isEmpty); + expect(session.directoryRequests, 0); + + await tester.tap(find.byKey(const Key('browse-channels-button'))); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t load open channels.'), findsOneWidget); + await tester.tap(find.byKey(const Key('browse-channels-retry'))); + await tester.pumpAndSettle(); + expect(find.text('community-help'), findsOneWidget); + expect(find.text('private-planning'), findsNothing); + expect(find.text('old-channel'), findsNothing); + expect(find.text('hidden-dm'), findsNothing); + expect( + container.read(channelsProvider).requireValue.every((c) => !c.isMember), + isTrue, + ); + + await tester.tap( + find.byKey(const Key('browse-channel-join-$_discoveryId')), + ); + await tester.pumpAndSettle(); + expect(session.joined, isTrue); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('community-help'), findsOneWidget); + expect( + container + .read(channelsProvider) + .requireValue + .singleWhere((channel) => channel.id == _discoveryId) + .isMember, + isTrue, + ); + // Discovery remains available after acquiring a membership. + expect(find.byKey(const Key('browse-channels-button')), findsOneWidget); + }, + ); +} + +const _discoveryId = '11111111-1111-4111-8111-111111111111'; + +class _DiscoveryJourneySession extends _ReconnectingRelaySession { + _DiscoveryJourneySession() : super(initialStatus: SessionStatus.connected); + + bool joined = false; + int directoryRequests = 0; + + NostrEvent metadata( + String id, + String name, { + String type = 'stream', + bool private = false, + bool archived = false, + }) => NostrEvent( + id: 'meta-$id', + pubkey: 'creator', + createdAt: 1, + kind: 39000, + tags: [ + ['d', id], + ['name', name], + ['t', type], + [private ? 'private' : 'public'], + if (archived) ['archived', 'true'], + ], + content: '', + sig: 'sig', + ); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + final result = []; + for (final filter in filters) { + if (filter.until != null) continue; + if (filter.kinds.contains(39000) && !filter.tags.containsKey('#d')) { + directoryRequests++; + if (directoryRequests == 1) throw Exception('Directory unavailable'); + result.addAll([ + metadata(_discoveryId, 'community-help'), + metadata( + '22222222-2222-4222-8222-222222222222', + 'private-planning', + private: true, + ), + metadata( + '33333333-3333-4333-8333-333333333333', + 'old-channel', + archived: true, + ), + metadata( + '44444444-4444-4444-8444-444444444444', + 'hidden-dm', + type: 'dm', + ), + ]); + } else { + result.addAll(await fetchHistory(filter)); + } + } + return result; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(39002) && joined) { + return [ + NostrEvent( + id: 'membership', + pubkey: 'creator', + createdAt: 2, + kind: 39002, + tags: [ + ['d', _discoveryId], + ['p', 'self'], + ], + content: '', + sig: 'sig', + ), + ]; + } + if (filter.kinds.contains(39000) && + (filter.tags['#d'] ?? []).contains(_discoveryId)) { + return [metadata(_discoveryId, 'community-help')]; + } + return []; + } +} + +// Fake only the signed network boundary; the widget, ChannelsNotifier and +// ChannelActions.joinChannel/refresh are the production implementations. +class _DiscoveryJourneyRelay extends SignedEventRelay { + _DiscoveryJourneyRelay(this.session) : super(session: session, nsec: null); + final _DiscoveryJourneySession session; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent)? onSigned, + }) async { + expect(kind, 9021); + expect(tags, [ + ['h', _discoveryId], + ]); + session.joined = true; + return NostrEvent( + id: 'join', + pubkey: 'self', + createdAt: 2, + kind: kind, + tags: tags, + content: '', + sig: 'sig', + ); + } +} diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 9f8e098ee9..2cb796b145 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -165,7 +165,10 @@ void main() { expect(tester.getSize(launcherGlass).width, greaterThan(56)); expect(find.text('Create channel').hitTestable(), findsOneWidget); expect(find.text('New direct message').hitTestable(), findsOneWidget); - expect(find.text('Browse channels').hitTestable(), findsOneWidget); + expect( + find.byKey(const Key('quick-action-browse-channels-card')), + findsNothing, + ); await tester.tapAt(const Offset(8, 8)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); @@ -270,7 +273,10 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Create channel'), findsOneWidget); expect(find.text('New direct message'), findsOneWidget); - expect(find.text('Browse channels'), findsOneWidget); + expect( + find.byKey(const Key('quick-action-browse-channels-card')), + findsNothing, + ); }); testWidgets('keeps the Buzz backdrop behind the scalable Home screen', (