Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class Channel {
bool get isForum => channelType == 'forum';
bool get isDm => channelType == 'dm';
bool get isPrivate => visibility == 'private';
bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm;

/// Whether [selfRole] may add *another* identity here, mirroring the relay's
/// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never,
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import '../../shared/read_state/read_state_time.dart';
import 'unread_badge/observed_unread_event.dart';

part 'channels_page/body.dart';
part 'channels_page/browse_channels_sheet.dart';
part 'channels_page/sections.dart';
part 'channels_page/channel_tile.dart';
part 'channels_page/sheets.dart';
Expand All @@ -62,7 +63,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 @@ -235,7 +235,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
@@ -0,0 +1,160 @@
part of '../channels_page.dart';

class _BrowseChannelsSheet extends ConsumerWidget {
const _BrowseChannelsSheet();

@override
Widget build(BuildContext context, WidgetRef 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(
'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.toLowerCase().compareTo(right.name.toLowerCase()),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final channel in sortedChannels)
_JoinableChannelTile(
channel: channel,
closeAfterJoin: closeAfterJoin,
),
Comment thread
brow marked this conversation as resolved.
Outdated
],
);
}
}

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…' : '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,
),
),
),
],
);
}
}
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.compass,
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 @@ -109,6 +109,15 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
if (opened != null && context.mounted) {
await openChannel(opened);
}
case _QuickAction.browseChannels:
await showBuzzModalBottomSheet<void>(
context: context,
title: 'Browse channels',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _BrowseChannelsSheet(),
);
}
}

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 @@ -456,29 +456,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
Loading
Loading