feat(mobile): browse and join open channels

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-08-01 17:32:03 -07:00
co-authored by Tom Brow
parent acdb264e65
commit 62f20d2fda
9 changed files with 483 additions and 28 deletions
@@ -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;
@@ -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;
@@ -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)
@@ -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),
),
],
),
);
@@ -107,6 +107,8 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
if (opened != null && context.mounted) {
await openChannel(opened);
}
case _QuickAction.browseChannels:
await _showBrowseChannelsSheet(context);
}
}
@@ -360,29 +360,48 @@ class _ChannelSection extends StatelessWidget {
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
final List<Channel> 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),
],
],
),
),
),
);
@@ -2,6 +2,186 @@ part of '../channels_page.dart';
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
Future<void> _showBrowseChannelsSheet(BuildContext context) =>
showModalBottomSheet<void>(
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<Channel> channels;
final bool closeAfterJoin;
const _JoinableChannelList({
required this.channels,
this.closeAfterJoin = false,
});
@override
Widget build(BuildContext context) {
final sortedChannels = List<Channel>.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<String?>(null);
Future<void> 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<T> {
final Key? key;
final String label;
@@ -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;
@@ -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<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
final browseMaterial = tester.widget<Material>(
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<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(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 = <String>[];
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<Channel> _channels;
List<Channel> _channels;
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
_FakeNotifier(
@@ -1428,6 +1650,11 @@ class _FakeNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => _channels;
void setChannels(List<Channel> channels) {
_channels = channels;
state = AsyncData(channels);
}
@override
Map<String, int> 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<void> 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<void> joinChannel(String channelId) async {
await onJoinChannel?.call(channelId);
}
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
_FakeChannelSectionsNotifier(this._store);