Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion mobile/lib/features/channels/channels_page/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
),
],
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
if (opened != null && context.mounted) {
await openChannel(opened);
}
case _QuickAction.browseChannels:
await _showBrowseChannelsSheet(context);
}
}

Expand Down
53 changes: 36 additions & 17 deletions mobile/lib/features/channels/channels_page/sections.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
],
),
),
),
);
Expand Down
180 changes: 180 additions & 0 deletions mobile/lib/features/channels/channels_page/sheets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading