Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
130 changes: 130 additions & 0 deletions mobile/lib/features/channels/channel_directory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
part of 'channels_provider.dart';

const _channelDirectoryPageSize = 500;
const _maxChannelDirectoryPages = 100;

/// Describes whether the open-channel directory is ready to browse.
enum ChannelDirectoryLoadStatus {
/// No directory request has completed for the active identity and relay.
idle,

/// A directory request is currently in flight.
loading,

/// The directory request completed, including when it returned no channels.
loaded,

/// The most recent directory request could not complete.
error,
}

/// Directory loading state scoped to one relay and signing identity.
class ChannelDirectoryLoadState {
/// Relay-and-identity scope that produced [status].
final String? scope;

/// Current loading status for [scope].
final ChannelDirectoryLoadStatus status;

/// Creates directory loading state.
const ChannelDirectoryLoadState({required this.scope, required this.status});

/// Initial state before any directory request has started.
const ChannelDirectoryLoadState.idle()
: scope = null,
status = ChannelDirectoryLoadStatus.idle;
}

/// Returns the stable directory scope for a relay and signing identity.
String channelDirectoryScope(String relayBaseUrl, String? pubkey) =>
'$relayBaseUrl:${pubkey?.toLowerCase() ?? ''}';

/// Owns the independently observable channel-directory loading state.
class ChannelDirectoryLoadNotifier extends Notifier<ChannelDirectoryLoadState> {
@override
ChannelDirectoryLoadState build() => const ChannelDirectoryLoadState.idle();

/// Marks the directory as loading.
void markLoading(String scope) => state = ChannelDirectoryLoadState(
scope: scope,
status: ChannelDirectoryLoadStatus.loading,
);

/// Marks the directory as successfully loaded.
void markLoaded(String scope) => state = ChannelDirectoryLoadState(
scope: scope,
status: ChannelDirectoryLoadStatus.loaded,
);

/// Marks the directory request as unsuccessful.
void markError(String scope) => state = ChannelDirectoryLoadState(
scope: scope,
status: ChannelDirectoryLoadStatus.error,
);
}

/// Loading state for open-channel discovery, separate from membership loading.
final channelDirectoryLoadStatusProvider =
NotifierProvider<ChannelDirectoryLoadNotifier, ChannelDirectoryLoadState>(
ChannelDirectoryLoadNotifier.new,
);

Future<List<NostrEvent>> _fetchChannelMemberships(
RelaySessionNotifier session,
String pubkey,
) => _fetchPaginatedChannelEvents(
session,
kind: 39002,
tags: {
'#p': [pubkey],
},
operation: 'Channel memberships',
);

Future<List<NostrEvent>> _fetchChannelDirectoryMetas(
RelaySessionNotifier session,
) => _fetchPaginatedChannelEvents(
session,
kind: 39000,
operation: 'Channel directory',
);

Future<List<NostrEvent>> _fetchPaginatedChannelEvents(
RelaySessionNotifier session, {
required int kind,
required String operation,
Map<String, List<String>> tags = const {},
}) async {
final events = <NostrEvent>[];
final seenEventIds = <String>{};
int? until;
String? beforeId;
for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) {
final page = await session.queryRelay([
NostrFilter(
kinds: [kind],
tags: tags,
limit: _channelDirectoryPageSize,
until: until,
extensions: {'before_id': ?beforeId},
),
]);
if (page.isEmpty) break;
var madeProgress = false;
for (final event in page) {
if (seenEventIds.add(event.id)) {
events.add(event);
madeProgress = true;
}
}
if (!madeProgress) break;

final last = page.last;
until = last.createdAt;
beforeId = last.id;
if (pageIndex == _maxChannelDirectoryPages - 1) {
throw StateError('$operation exceeded $_maxChannelDirectoryPages pages');
}
}
return events;
}
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,191 @@
part of '../channels_page.dart';

class _BrowseChannelsSheet extends HookConsumerWidget {
const _BrowseChannelsSheet();

@override
Widget build(BuildContext context, WidgetRef ref) {
final channelsAsync = ref.watch(channelsProvider);
final directoryState = ref.watch(channelDirectoryLoadStatusProvider);
final activeDirectoryScope = channelDirectoryScope(
ref.watch(relayConfigProvider).baseUrl,
ref.watch(myPubkeyProvider),
);
final directoryStatus = directoryState.scope == activeDirectoryScope
? directoryState.status
: ChannelDirectoryLoadStatus.idle;
final channels = channelsAsync.asData?.value
.where((channel) => channel.canJoin)
.toList();
channels?.sort(
(left, right) =>
left.name.toLowerCase().compareTo(right.name.toLowerCase()),
);

useEffect(() {
unawaited(
Future<void>.microtask(
ref.read(channelsProvider.notifier).ensureDirectoryLoaded,
),
);
return null;
}, const []);

final directoryIsLoading =
directoryStatus == ChannelDirectoryLoadStatus.idle ||
directoryStatus == ChannelDirectoryLoadStatus.loading;
final directoryHasError =
directoryStatus == ChannelDirectoryLoadStatus.error ||
channelsAsync.hasError;

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 (directoryIsLoading && (channels == null || channels.isEmpty))
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(Grid.sm),
child: Center(child: BuzzLoadingIndicator()),
),
)
else if (directoryHasError &&
(channels == null || channels.isEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.sm),
child: Column(
children: [
Text(
'Couldn’t load open channels.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xxs),
TextButton(
key: const Key('browse-channels-retry'),
onPressed: () => unawaited(
ref.read(channelsProvider.notifier).retryDirectory(),
),
child: const Text('Try again'),
),
],
),
),
)
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