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
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
43 changes: 43 additions & 0 deletions mobile/lib/features/channels/channel_directory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
part of 'channels_provider.dart';

const _channelDirectoryPageSize = 500;
const _maxChannelDirectoryPages = 100;

Future<List<NostrEvent>> _fetchChannelDirectoryMetas(
RelaySessionNotifier session,
) async {
final directoryMetas = <NostrEvent>[];
final seenDirectoryChannelIds = <String>{};
int? directoryUntil;
String? directoryBeforeId;
for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) {
final page = await session.queryRelay([
NostrFilter(
kinds: const [39000],
limit: _channelDirectoryPageSize,
until: directoryUntil,
extensions: {'before_id': ?directoryBeforeId},
),
]);
directoryMetas.addAll(page);

var madeProgress = false;
for (final event in page) {
final channelId = event.getTagValue('d');
if (channelId != null && seenDirectoryChannelIds.add(channelId)) {
madeProgress = true;
}
}
if (!madeProgress || page.length < _channelDirectoryPageSize) break;

final last = page.last;
directoryUntil = last.createdAt;
directoryBeforeId = last.id;
if (pageIndex == _maxChannelDirectoryPages - 1) {
throw StateError(
'Channel directory exceeded $_maxChannelDirectoryPages pages',
);
}
}
return directoryMetas;
}
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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();
channels?.sort(
(left, right) =>
left.name.toLowerCase().compareTo(right.name.toLowerCase()),
);

return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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 SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(Grid.sm),
child: Center(child: BuzzLoadingIndicator()),
),
)
else if (channelsAsync.hasError && channels == null)
SliverToBoxAdapter(
child: 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)
SliverToBoxAdapter(
child: 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
SliverList.builder(
itemCount: channels.length,
itemBuilder: (context, index) => _JoinableChannelTile(
channel: channels[index],
closeAfterJoin: true,
),
),
],
),
),
);
}
}

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
Loading
Loading