From 62f20d2fda552be6fafee7b4de5710aa34afb2b1 Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 17:32:03 -0700 Subject: [PATCH] feat(mobile): browse and join open channels Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- mobile/lib/features/channels/channel.dart | 2 + .../lib/features/channels/channels_page.dart | 2 +- .../features/channels/channels_page/body.dart | 4 +- .../channels/channels_page/quick_actions.dart | 9 +- .../channels_page/quick_actions_launcher.dart | 2 + .../channels/channels_page/sections.dart | 53 ++-- .../channels/channels_page/sheets.dart | 180 +++++++++++++ .../channels/manage_channel_sheet.dart | 6 +- .../features/channels/channels_page_test.dart | 253 +++++++++++++++++- 9 files changed, 483 insertions(+), 28 deletions(-) diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 3104e9033..1dc94351a 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -79,6 +79,8 @@ class Channel { bool get isPrivate => visibility == 'private'; bool get isArchived => archivedAt != null; + bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm; + String displayLabel({String? currentPubkey}) { if (!isDm || participants.isEmpty) { return name; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9..a07947371 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -54,7 +54,7 @@ part 'channels_page/community.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; -enum _QuickAction { createChannel, newDm } +enum _QuickAction { createChannel, newDm, browseChannels } 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 9f0b1dd4a..04e87ee2c 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -196,7 +196,9 @@ class _SliverChannelsList extends HookConsumerWidget { sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) - const _EmptyState() + _EmptyState( + channels: channels.where((channel) => channel.canJoin).toList(), + ) else ...[ // Starred channels (exclusive — pinned above all sections). if (starredStreamChannels.isNotEmpty) diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index 875484270..02f5fbc9b 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 = 160; +const double _kMorphOpenHeight = 216; const double _kMorphOpenRadius = 20; const double _kMorphSlide = 40; const double _kMorphScale = 0.97; @@ -274,6 +274,13 @@ class _QuickActionsMenu extends StatelessWidget { key: const Key('quick-action-new-dm-card'), onTap: () => onSelected(_QuickAction.newDm), ), + const SizedBox(height: Grid.xxs), + _QuickActionItem( + icon: LucideIcons.search, + title: 'Browse channels', + key: const Key('quick-action-browse-channels-card'), + onTap: () => onSelected(_QuickAction.browseChannels), + ), ], ), ); 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 365393208..c92ec5b3a 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -107,6 +107,8 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (opened != null && context.mounted) { await openChannel(opened); } + case _QuickAction.browseChannels: + await _showBrowseChannelsSheet(context); } } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index f9fe5453d..28faf67b0 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -360,29 +360,48 @@ class _ChannelSection extends StatelessWidget { } class _EmptyState extends StatelessWidget { - const _EmptyState(); + final List channels; + + const _EmptyState({required this.channels}); @override Widget build(BuildContext context) { - return SizedBox( - height: MediaQuery.sizeOf(context).height * 0.55, + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * 0.55, + ), child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.messagesSquare, - size: Grid.xl, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.xs), - Text( - 'No conversations yet', - style: context.textTheme.bodyLarge?.copyWith( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.messagesSquare, + size: Grid.xl, color: context.colors.onSurfaceVariant, ), - ), - ], + const SizedBox(height: Grid.xs), + Text( + 'No conversations yet', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (channels.isNotEmpty) ...[ + const SizedBox(height: Grid.xs), + Text( + 'Join an open channel to start a conversation.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + _JoinableChannelList(channels: channels), + ], + ], + ), ), ), ); diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 2738be0aa..25bf7e621 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -2,6 +2,186 @@ part of '../channels_page.dart'; const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60; +Future _showBrowseChannelsSheet(BuildContext context) => + showModalBottomSheet( + context: context, + constraints: _quickActionSheetConstraints(context), + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _BrowseChannelsSheet(), + ); + +class _BrowseChannelsSheet extends StatelessWidget { + const _BrowseChannelsSheet(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, ref, _) { + final channelsAsync = ref.watch(channelsProvider); + final channels = channelsAsync.asData?.value + .where((channel) => channel.canJoin) + .toList(); + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: ListView( + shrinkWrap: true, + children: [ + Text( + 'Browse channels', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: Grid.half), + Text( + 'Join an open channel to add it to your conversations.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + if (channelsAsync.isLoading && channels == null) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: BuzzLoadingIndicator()), + ) + else if (channelsAsync.hasError && channels == null) + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'Could not load open channels.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + else if (channels == null || channels.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'No open channels available to join.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + else + _JoinableChannelList( + channels: channels, + closeAfterJoin: true, + ), + ], + ), + ), + ); + }, + ); + } +} + +class _JoinableChannelList extends StatelessWidget { + final List channels; + final bool closeAfterJoin; + + const _JoinableChannelList({ + required this.channels, + this.closeAfterJoin = false, + }); + + @override + Widget build(BuildContext context) { + final sortedChannels = List.of(channels) + ..sort((left, right) => left.name.compareTo(right.name)); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final channel in sortedChannels) + _JoinableChannelTile( + channel: channel, + closeAfterJoin: closeAfterJoin, + ), + ], + ); + } +} + +class _JoinableChannelTile extends HookConsumerWidget { + final Channel channel; + final bool closeAfterJoin; + + const _JoinableChannelTile({ + required this.channel, + required this.closeAfterJoin, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isJoining = useState(false); + final actionError = useState(null); + + Future join() async { + if (isJoining.value) return; + isJoining.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).joinChannel(channel.id); + if (closeAfterJoin && context.mounted) Navigator.of(context).pop(); + } catch (error) { + actionError.value = error.toString(); + } finally { + isJoining.value = false; + } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: Key('browse-channel-${channel.id}'), + contentPadding: EdgeInsets.zero, + leading: Icon(channelIcon(channel)), + title: Text(channel.name), + subtitle: channel.description.trim().isEmpty + ? null + : Text( + channel.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: FilledButton.tonal( + key: Key('browse-channel-join-${channel.id}'), + onPressed: isJoining.value ? null : () => unawaited(join()), + child: Text(isJoining.value ? 'Joining\u2026' : 'Join'), + ), + ), + if (actionError.value case final error?) + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + key: Key('browse-channel-error-${channel.id}'), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ); + } +} + class _CreateChannelMenuOption { final Key? key; final String label; diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 15ad07b0c..794fdd74d 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -34,11 +34,7 @@ class ManageChannelSheet extends HookConsumerWidget { final mutesState = ref.watch(channelMutesProvider); final isMuted = mutesState.store.channels[channel.id]?.muted == true; - final canJoin = - channel.visibility == 'open' && - !channel.isArchived && - !channel.isMember && - !channel.isDm; + final canJoin = channel.canJoin; final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; final canEditCanvas = channel.isMember && !channel.isArchived; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 991db3b5c..6940b6720 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -723,8 +723,8 @@ void main() { } await tester.pumpAndSettle(); - expect(largestHeight, greaterThan(160)); - expect(tester.getSize(surface).height, closeTo(160, 0.01)); + expect(largestHeight, greaterThan(216)); + expect(tester.getSize(surface).height, closeTo(216, 0.01)); final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width; final surfaceRect = tester.getRect(surface); expect(surfaceRect.left, closeTo(20, 0.01)); @@ -737,15 +737,23 @@ 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.width, createRect.width); + expect(browseRect.width, createRect.width); expect(dmRect.width, closeTo(menuRect.width - 16, 0.01)); final cardScheme = Theme.of(tester.element(createCard)).colorScheme; @@ -759,8 +767,12 @@ 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.borderRadius as BorderRadius).topLeft.x, closeTo(12, 0.01), @@ -779,9 +791,98 @@ void main() { tester.widget(find.text('New direct message')).style?.fontSize, 16, ); + expect( + tester.widget(find.text('Browse channels')).style?.fontSize, + 16, + ); expect(find.text('Message one or more people'), findsNothing); }); + testWidgets('browse action lists only channels eligible to join', ( + tester, + ) async { + final channels = [ + ...testChannels, + Channel( + id: 'open-to-join', + name: 'announcements', + channelType: 'stream', + visibility: 'open', + description: 'Community announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 8, + ), + Channel( + id: 'private-channel', + name: 'private-planning', + channelType: 'stream', + visibility: 'private', + description: 'Private planning', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 4, + ), + Channel( + id: 'archived-channel', + name: 'old-announcements', + channelType: 'stream', + visibility: 'open', + description: 'Archived announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 3, + archivedAt: DateTime(2025, 1, 2), + ), + Channel( + id: 'unjoined-dm', + name: 'Hidden DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 2, + ), + ]; + + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + ], + ), + ); + 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.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-open-to-join')), + findsOneWidget, + ); + expect( + find.byKey(const Key('browse-channel-join-open-to-join')), + findsOneWidget, + ); + expect(find.byKey(const Key('browse-channel-1')), findsNothing); + expect( + find.byKey(const Key('browse-channel-private-channel')), + findsNothing, + ); + expect( + find.byKey(const Key('browse-channel-archived-channel')), + findsNothing, + ); + expect(find.byKey(const Key('browse-channel-unjoined-dm')), findsNothing); + }); + testWidgets('create channel sheet lists type and visibility radio options', ( tester, ) async { @@ -1173,6 +1274,127 @@ void main() { expect(find.text('archived-stream'), findsNothing); }); + testWidgets('empty state lets stuck users join an open channel', ( + tester, + ) async { + final discoveredChannel = Channel( + id: 'recovery-channel', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Get help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 7, + ); + final channelsNotifier = _FakeNotifier([discoveredChannel]); + final joinedChannelIds = []; + + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => channelsNotifier), + channelActionsProvider.overrideWith( + (ref) => _FakeChannelActions( + ref, + onJoinChannel: (channelId) async { + joinedChannelIds.add(channelId); + channelsNotifier.setChannels([ + discoveredChannel.copyWith(isMember: true), + ]); + }, + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + expect( + find.byKey(const Key('browse-channel-recovery-channel')), + findsOneWidget, + ); + expect( + find.byKey(const Key('browse-channel-join-recovery-channel')), + findsOneWidget, + ); + expect(find.text('community-help'), findsOneWidget); + + await tester.tap( + find.byKey(const Key('browse-channel-join-recovery-channel')), + ); + await tester.pumpAndSettle(); + + expect(joinedChannelIds, ['recovery-channel']); + expect(find.text('No conversations yet'), findsNothing); + expect(find.text('community-help'), findsOneWidget); + }); + + testWidgets('empty state lists every joinable channel without overflow', ( + tester, + ) async { + final discoveredChannels = List.generate( + 6, + (index) => Channel( + id: 'recovery-channel-$index', + name: 'community-channel-$index', + channelType: 'stream', + visibility: 'open', + description: 'Community channel $index', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: index + 1, + ), + ); + + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier(discoveredChannels), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + for (final channel in discoveredChannels) { + expect(find.byKey(Key('browse-channel-${channel.id}')), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('empty state omits browse CTA without joinable channels', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([ + Channel( + id: 'private-only', + name: 'private-only', + channelType: 'stream', + visibility: 'private', + description: 'Private channel', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 2, + ), + ]), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + expect(find.byKey(const Key('browse-channel-private-only')), findsNothing); + }); + testWidgets('shows empty state when no channels', (tester) async { await tester.pumpWidget( buildTestable( @@ -1414,7 +1636,7 @@ Widget _buildSettingsPage(BuildContext context) => const Scaffold(body: Text('Injected settings')); class _FakeNotifier extends ChannelsNotifier { - final List _channels; + List _channels; final Map> _observedEventsByChannel; _FakeNotifier( @@ -1428,6 +1650,11 @@ class _FakeNotifier extends ChannelsNotifier { @override Future> build() async => _channels; + void setChannels(List channels) { + _channels = channels; + state = AsyncData(channels); + } + @override Map get latestObservedByChannel => { for (final entry in _observedEventsByChannel.entries) @@ -1442,6 +1669,26 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _FakeChannelActions extends ChannelActions { + final Future Function(String channelId)? onJoinChannel; + + _FakeChannelActions(Ref ref, {this.onJoinChannel}) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'aabb', + ); + + @override + Future joinChannel(String channelId) async { + await onJoinChannel?.call(channelId); + } +} + class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { _FakeChannelSectionsNotifier(this._store);