mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): browse and join open channels
Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Codex <noreply@openai.com> Ai-assisted: true
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.compass,
|
||||
title: 'Browse channels',
|
||||
key: const Key('quick-action-browse-channels-card'),
|
||||
onTap: () => onSelected(_QuickAction.browseChannels),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -20,15 +20,19 @@ import 'unread_badge/should_notify_for_event.dart';
|
||||
|
||||
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
|
||||
const _unreadCatchUpLimit = 1000;
|
||||
const _channelDirectoryPageSize = 500;
|
||||
const _maxChannelDirectoryPages = 100;
|
||||
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
|
||||
const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
|
||||
|
||||
/// Loads the user's channel list from the relay over WebSocket.
|
||||
///
|
||||
/// Two-step query:
|
||||
/// Three-step query:
|
||||
/// 1. Fetch kind:39002 membership events tagged `#p:<my-pubkey>` to find
|
||||
/// the channel ids I'm a member of.
|
||||
/// 2. Fetch the corresponding kind:39000 channel metadata events.
|
||||
/// 3. Fetch the paginated kind:39000 directory so open channels that the
|
||||
/// user has not joined remain discoverable.
|
||||
///
|
||||
/// Live updates are layered on top via per-channel subscriptions on the
|
||||
/// `#h` tag for any of the visible channel event kinds — incoming events
|
||||
@@ -164,28 +168,66 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
until = page.map((e) => e.createdAt).reduce(min) - 1;
|
||||
}
|
||||
}
|
||||
final channelIds = memberships
|
||||
final memberChannelIds = memberships
|
||||
.map((e) => e.getTagValue('d'))
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
.toList();
|
||||
.toSet();
|
||||
_cacheMemberSnapshots(memberships, replaceAll: true);
|
||||
if (channelIds.isEmpty) {
|
||||
if (subscribeLive) await _subscribeLive(const []);
|
||||
return const [];
|
||||
|
||||
// Step 2: pull metadata for joined channels. A user with no memberships
|
||||
// must still continue to directory discovery below.
|
||||
final memberMetas = memberChannelIds.isEmpty
|
||||
? const <NostrEvent>[]
|
||||
: await session.fetchHistory(
|
||||
NostrFilters.channelMetadata(memberChannelIds.toList()),
|
||||
);
|
||||
|
||||
// Step 3: fetch the open-channel directory. The relay filters this global
|
||||
// kind:39000 query by the caller's access, but the client still rejects
|
||||
// private channels and DMs below so discovery fails closed if that contract
|
||||
// ever regresses. The composite cursor preserves tied-timestamp rows.
|
||||
final directoryMetas = <NostrEvent>[];
|
||||
final seenDirectoryChannelIds = <String>{};
|
||||
int? directoryUntil;
|
||||
String? directoryBeforeId;
|
||||
for (
|
||||
var pageIndex = 0;
|
||||
pageIndex < _maxChannelDirectoryPages;
|
||||
pageIndex++
|
||||
) {
|
||||
final page = await session.fetchHistory(
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: pull channel metadata in one batched filter.
|
||||
final metas = await session.fetchHistory(
|
||||
NostrFilters.channelMetadata(channelIds),
|
||||
);
|
||||
|
||||
// Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable,
|
||||
// so logically there's exactly one current event per id, but stale revisions
|
||||
// from before the relay's d_tag backfill can linger. Keep the highest
|
||||
// `created_at` per id so the latest channel_type / name wins.
|
||||
// Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable,
|
||||
// but stale revisions from before the relay's d_tag backfill can linger.
|
||||
final latestMetaPerId = <String, NostrEvent>{};
|
||||
for (final event in metas) {
|
||||
for (final event in [...memberMetas, ...directoryMetas]) {
|
||||
if (event.kind != 39000) continue;
|
||||
final id = event.getTagValue('d');
|
||||
if (id == null) continue;
|
||||
@@ -232,11 +274,15 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
|
||||
final channels = <Channel>[];
|
||||
for (final event in dedupedMetas) {
|
||||
final id = event.getTagValue('d');
|
||||
if (id == null) continue;
|
||||
final isMember = memberChannelIds.contains(id);
|
||||
final channel = _channelFromMeta(
|
||||
event,
|
||||
isMember: true,
|
||||
isMember: isMember,
|
||||
displayNames: displayNames,
|
||||
);
|
||||
if (!isMember && (channel.isPrivate || channel.isDm)) continue;
|
||||
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
|
||||
// Ephemeral (TTL) channels are surfaced in the list with an
|
||||
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
|
||||
@@ -246,13 +292,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
|
||||
// Batch-fetch member counts via kind:39002 membership events.
|
||||
final memberEvents = await session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [39002],
|
||||
tags: {'#d': channelIds},
|
||||
limit: channelIds.length,
|
||||
),
|
||||
);
|
||||
final memberCountChannelIds = memberChannelIds.toList();
|
||||
final memberEvents = memberCountChannelIds.isEmpty
|
||||
? const <NostrEvent>[]
|
||||
: await session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [39002],
|
||||
tags: {'#d': memberCountChannelIds},
|
||||
limit: memberCountChannelIds.length,
|
||||
),
|
||||
);
|
||||
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
|
||||
final memberCounts = <String, int>{};
|
||||
for (final event in memberEvents) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1241,8 +1241,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));
|
||||
@@ -1255,15 +1255,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;
|
||||
@@ -1277,8 +1285,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),
|
||||
@@ -1297,9 +1309,117 @@ 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-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('browse action explains when no channels are discoverable', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
disableAnimations: true,
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
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.text('No open channels available to join.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('create channel sheet lists type and visibility radio options', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -1702,6 +1822,58 @@ void main() {
|
||||
expect(find.text('No conversations yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty state lets users join a discovered 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,
|
||||
);
|
||||
|
||||
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('shows error view with retry button', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
@@ -1972,6 +2144,28 @@ class _FakeNotifier extends ChannelsNotifier {
|
||||
@override
|
||||
Map<String, Map<String, ObservedUnreadEvent>>
|
||||
get observedUnreadEventsByChannel => _observedEventsByChannel;
|
||||
|
||||
void setChannels(List<Channel> channels) {
|
||||
state = AsyncData(channels);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeChannelActions extends ChannelActions {
|
||||
final Future<void> Function(String channelId) onJoinChannel;
|
||||
|
||||
_FakeChannelActions(Ref ref, {required 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) => onJoinChannel(channelId);
|
||||
}
|
||||
|
||||
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
|
||||
|
||||
@@ -9,9 +9,10 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
/// Tests for [ChannelsNotifier] in the pure-Nostr world.
|
||||
///
|
||||
/// The provider performs a two-step WS query:
|
||||
/// The provider performs a three-step WS query:
|
||||
/// 1. kind:39002 memberships tagged `#p:<my-pubkey>`
|
||||
/// 2. kind:39000 metadata for those channel ids
|
||||
/// 3. paginated kind:39000 metadata for discoverable open channels
|
||||
/// then layers per-channel live subscriptions on the `#h` tag.
|
||||
///
|
||||
/// Tests stub out the relay session by overriding [relaySessionProvider] with
|
||||
@@ -21,6 +22,150 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
void main() {
|
||||
const myPk = 'me';
|
||||
|
||||
test(
|
||||
'discovers open channels for a user with zero channel memberships',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
metadata: [
|
||||
_meta(id: _channelA, name: 'general'),
|
||||
_meta(id: _channelB, name: 'staff', visibility: 'private'),
|
||||
_meta(id: _channelD, name: 'DM', channelType: 'dm'),
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
|
||||
expect(channels, hasLength(1));
|
||||
expect(channels.single.id, _channelA);
|
||||
expect(channels.single.isMember, isFalse);
|
||||
expect(session.subscribeFilters, isEmpty);
|
||||
expect(
|
||||
session.historyFilters.any(
|
||||
(filter) =>
|
||||
filter.kinds.length == 1 &&
|
||||
filter.kinds.single == 39000 &&
|
||||
!filter.tags.containsKey('#d'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('paginates channel discovery with a composite cursor', () async {
|
||||
final firstPage = List.generate(
|
||||
500,
|
||||
(index) => _meta(
|
||||
id: '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000',
|
||||
name: 'channel-$index',
|
||||
createdAt: 10,
|
||||
),
|
||||
);
|
||||
final finalChannel = _meta(
|
||||
id: '99999999-9999-4999-8999-999999999999',
|
||||
name: 'last-page',
|
||||
createdAt: 9,
|
||||
);
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
metadataPages: [
|
||||
firstPage,
|
||||
[finalChannel],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
|
||||
expect(channels, hasLength(501));
|
||||
final directoryFilters = session.historyFilters
|
||||
.where(
|
||||
(filter) =>
|
||||
filter.kinds.length == 1 &&
|
||||
filter.kinds.single == 39000 &&
|
||||
!filter.tags.containsKey('#d'),
|
||||
)
|
||||
.toList();
|
||||
expect(directoryFilters, hasLength(2));
|
||||
expect(directoryFilters.first.until, isNull);
|
||||
expect(directoryFilters.first.extensions, isEmpty);
|
||||
expect(directoryFilters.last.until, firstPage.last.createdAt);
|
||||
expect(directoryFilters.last.extensions['before_id'], firstPage.last.id);
|
||||
});
|
||||
|
||||
test('stops channel discovery when the relay repeats a full page', () async {
|
||||
final repeatedPage = List.generate(
|
||||
500,
|
||||
(index) => _meta(
|
||||
id: 'repeated-channel-$index',
|
||||
name: 'repeated-$index',
|
||||
createdAt: 10,
|
||||
),
|
||||
);
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
metadataPages: [repeatedPage],
|
||||
repeatLastMetadataPage: true,
|
||||
maxMetadataPageRequests: 2,
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
|
||||
expect(channels, hasLength(500));
|
||||
expect(session.metadataPageRequestCount, 2);
|
||||
});
|
||||
|
||||
test('fails loudly when channel discovery exceeds its page cap', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
metadataPageBuilder: (pageIndex) => List.generate(
|
||||
500,
|
||||
(eventIndex) => _meta(
|
||||
id: 'channel-$pageIndex-$eventIndex',
|
||||
name: 'channel-$pageIndex-$eventIndex',
|
||||
createdAt: 1000 - pageIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await expectLater(
|
||||
container.read(channelsProvider.future),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('Channel directory exceeded'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('deduplicates joined channels from directory discovery', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [
|
||||
_meta(id: _channelA, name: 'general'),
|
||||
_meta(id: _channelB, name: 'random'),
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
|
||||
expect(channels.map((channel) => channel.id), [_channelA, _channelB]);
|
||||
expect(channels.first.isMember, isTrue);
|
||||
expect(channels.last.isMember, isFalse);
|
||||
expect(session.subscribeFilters, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'seeds members from the channel-list snapshot during reconnect',
|
||||
() async {
|
||||
@@ -699,6 +844,7 @@ NostrEvent _meta({
|
||||
required String id,
|
||||
required String name,
|
||||
String channelType = 'stream',
|
||||
String visibility = 'open',
|
||||
int createdAt = 1,
|
||||
int? ttlSeconds,
|
||||
bool archived = false,
|
||||
@@ -711,7 +857,7 @@ NostrEvent _meta({
|
||||
['d', id],
|
||||
['name', name],
|
||||
['t', channelType],
|
||||
['public'],
|
||||
[visibility == 'private' ? 'private' : 'public'],
|
||||
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
|
||||
if (archived) ['archived', 'true'],
|
||||
],
|
||||
@@ -743,7 +889,11 @@ Future<void> _waitUntil(bool Function() predicate) async {
|
||||
class _FakeRelaySession extends RelaySessionNotifier {
|
||||
_FakeRelaySession({
|
||||
required this.memberships,
|
||||
required this.metadata,
|
||||
this.metadata = const [],
|
||||
this.metadataPages,
|
||||
this.metadataPageBuilder,
|
||||
this.repeatLastMetadataPage = false,
|
||||
this.maxMetadataPageRequests,
|
||||
this.hiddenDmEvents = const [],
|
||||
this.recentMessages = const [],
|
||||
this.membershipFailures = 0,
|
||||
@@ -751,9 +901,14 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
|
||||
List<NostrEvent> memberships;
|
||||
List<NostrEvent> metadata;
|
||||
final List<List<NostrEvent>>? metadataPages;
|
||||
final List<NostrEvent> Function(int pageIndex)? metadataPageBuilder;
|
||||
final bool repeatLastMetadataPage;
|
||||
final int? maxMetadataPageRequests;
|
||||
final List<NostrEvent> hiddenDmEvents;
|
||||
final List<NostrEvent> recentMessages;
|
||||
int membershipFailures;
|
||||
int metadataPageRequestCount = 0;
|
||||
|
||||
final List<NostrFilter> historyFilters = [];
|
||||
final List<List<NostrFilter>> queryBatches = [];
|
||||
@@ -820,8 +975,26 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
return hiddenDmEvents;
|
||||
}
|
||||
if (filter.kinds.contains(39000)) {
|
||||
// Metadata query — return all metadata events whose `d` tag matches.
|
||||
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();
|
||||
final ids = filter.tags['#d']?.toSet();
|
||||
if (ids == null) {
|
||||
final requestIndex = metadataPageRequestCount++;
|
||||
final maxRequests = maxMetadataPageRequests;
|
||||
if (maxRequests != null && requestIndex >= maxRequests) {
|
||||
throw StateError('Unexpected directory page request');
|
||||
}
|
||||
final pageBuilder = metadataPageBuilder;
|
||||
if (pageBuilder != null) return List.of(pageBuilder(requestIndex));
|
||||
final pages = metadataPages;
|
||||
if (pages != null) {
|
||||
if (requestIndex < pages.length) return List.of(pages[requestIndex]);
|
||||
if (repeatLastMetadataPage && pages.isNotEmpty) {
|
||||
return List.of(pages.last);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
return List.of(metadata);
|
||||
}
|
||||
// Member metadata query — return only matching `d` tags.
|
||||
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
||||
}
|
||||
return const [];
|
||||
|
||||
Reference in New Issue
Block a user