feat(mobile): browse and join open channels (#6243)

Mobile previously exposed no way to browse or join channels.

Users can now browse and join eligible open channels from the Home
quick-actions menu. The public directory loads on demand when Browse
channels opens, while the existing kind 9021 join path refreshes
membership after success.

| Browse channels | Join channel |
| --- | --- |
| <img width="320" alt="Browse channels"
src="https://github.com/user-attachments/assets/f12c46c8-8bf4-487d-8a45-7bec63b16028"
/> | <img width="320" alt="Join channel"
src="https://github.com/user-attachments/assets/d6306ca7-9ab8-4f5a-8131-7f9b2a76d653"
/> |

### How is it tested?

Manually tested (see screenshots) and added tests:

-
[`channels_provider_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_provider_test.dart)
covers access filtering, independently paginated membership and
directory queries, relay-capped pages, repeated-page termination, hard
page caps, on-demand directory loading, load failures, retry, and
cached-channel retention.
-
[`channels_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_page_test.dart)
covers browse eligibility, loading and retry states, quick-action
layout, and scrolling and joining from a 500-channel directory.
-
[`search_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/search/search_page_test.dart)
covers discoverable open-channel results without presenting unknown
membership counts as zero.

Local validation:

- `just mobile-check`
- `just mobile-test` (1,560 tests)
- full pre-push gate

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-08-24 09:48:02 -07:00
committed by GitHub
parent 0e69b3fd7c
commit 26f4c3ed30
11 changed files with 3246 additions and 247 deletions

View File

@@ -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,

View File

@@ -0,0 +1,487 @@
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();
/// Whether [scope] currently owns an in-flight directory request.
bool isLoading(String scope) =>
state.scope == scope &&
state.status == ChannelDirectoryLoadStatus.loading;
/// Marks the directory as loading.
void markLoading(String scope) => state = ChannelDirectoryLoadState(
scope: scope,
status: ChannelDirectoryLoadStatus.loading,
);
/// Marks the directory as eligible for a fresh request.
void markIdle(String scope) => state = ChannelDirectoryLoadState(
scope: scope,
status: ChannelDirectoryLoadStatus.idle,
);
/// 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',
);
/// Thrown when a channel-list request is retired before it settles.
///
/// Callers must treat this as "write nothing": a newer request or scope now
/// owns the installed list and its related cache, subscription, and load state.
class _StaleChannelRefresh implements Exception {
const _StaleChannelRefresh();
@override
String toString() =>
'Channel refresh retired by a newer request or scope change';
}
/// Carries one channel-list refresh's request ownership across every await.
///
/// This token is captured once at the start of every ordinary, directory, and
/// reconnect refresh. It is re-checked after each relay await, so an older
/// request cannot regain ownership by reaching subscription setup last.
class _ChannelRefreshFence {
/// Relay-and-identity scope that started the refresh.
final String scope;
final _ChannelRefreshCoordinator _coordinator;
final int _generation;
_ChannelRefreshFence(this._coordinator, this.scope, this._generation);
/// Whether the refresh still owns the active scope and generation.
bool get isCurrent =>
_generation == _coordinator.generation &&
scope == _coordinator.currentScope();
/// Throws [_StaleChannelRefresh] once this refresh has been retired.
///
/// Call after every await and immediately before every write to metadata,
/// cache, load status, subscriptions or provider state.
void ensureCurrent() {
if (!isCurrent) throw const _StaleChannelRefresh();
}
}
/// Whether a detached unread catch-up has been superseded, so it writes nothing.
///
/// The refresh fence is acquired before the first relay await, which makes this
/// request-ordered rather than subscription-completion-ordered. The subscription
/// generation remains a second lifecycle check for disconnect and disposal.
///
/// A retired catch-up returns rather than throwing [_StaleChannelRefresh]:
/// nothing awaits it, so a throw would only surface as an unhandled error.
///
/// An extension in this part file rather than a method on the notifier because
/// `channels_provider.dart` sits against the repository-wide 1000-line file
/// ceiling enforced by `just file-size-check`.
extension _CatchUpFencing on ChannelsNotifier {
bool _isCatchUpRetired(
_ChannelRefreshFence fence,
int subscriptionGeneration,
) => !fence.isCurrent || subscriptionGeneration != _subscriptionVersion;
}
/// Awaits [future], then rejects the result if the refresh was retired.
///
/// One helper keeps every await site on the fenced path identical, so a new
/// await cannot be added without deciding whether it needs the fence.
///
/// The error path is fenced too. Without it a retired refresh that fails would
/// surface an ordinary exception, and `retryDirectory` would treat it as a
/// failure of the current scope: it would mark the wrong scope's status and
/// reinstall the channel list it captured before the switch.
Future<T> _fenced<T>(_ChannelRefreshFence fence, Future<T> future) async {
final T value;
try {
value = await future;
} catch (_) {
if (!fence.isCurrent) throw const _StaleChannelRefresh();
rethrow;
}
fence.ensureCurrent();
return value;
}
/// Resolves display labels for the other participants in every DM meta.
///
/// The relay stores DM channels with the literal name "DM", and the pure-Nostr
/// architecture puts name resolution in the client. So collect the non-self
/// participant pubkeys across all DM metas and batch-fetch their kind:0
/// profiles in one round-trip. Returns lowercase pubkey to label.
///
/// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1000-line file ceiling enforced by `just file-size-check`.
Future<Map<String, String>> _resolveDmDisplayNames(
RelaySessionNotifier session,
_ChannelRefreshFence fence,
Iterable<NostrEvent> dedupedMetas,
String myPk,
) async {
final dmParticipants = <String>{};
final myPkLower = myPk.toLowerCase();
for (final event in dedupedMetas) {
final data = ChannelData.fromEvent(event);
if (data.channelType != 'dm') continue;
for (final pk in data.participantPubkeys) {
final lower = pk.toLowerCase();
if (lower != myPkLower) dmParticipants.add(lower);
}
}
if (dmParticipants.isEmpty) return const {};
final profileEvents = await _fenced(
fence,
session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())),
);
final displayNames = <String, String>{};
for (final event in profileEvents) {
if (event.kind != 0) continue;
final profile = ProfileData.fromEvent(event);
final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: profile.nip05?.trim().isNotEmpty == true
? profile.nip05!.trim()
: shortPubkey(profile.pubkey);
displayNames[profile.pubkey.toLowerCase()] = label;
}
return displayNames;
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
) async {
try {
final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk));
if (events.isEmpty) return const {};
NostrEvent latest = events.first;
for (final event in events.skip(1)) {
if (event.createdAt > latest.createdAt) latest = event;
}
return {
for (final tag in latest.tags)
if (tag.length >= 2 && tag[0] == 'h') tag[1],
};
} catch (_) {
return const {};
}
}
Future<List<NostrEvent>> _fetchHuddleStarts(
RelaySessionNotifier session,
List<String> parentChannelIds,
) async {
if (parentChannelIds.isEmpty) return const [];
try {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return await session.fetchHistory(
NostrFilter(
kinds: const [EventKind.huddleStarted],
tags: {'#h': parentChannelIds},
since: now - const Duration(hours: 2).inSeconds,
limit: 500,
),
);
} catch (error) {
debugPrint(
'[ChannelsNotifier] Huddle backing-channel query failed: $error',
);
return const [];
}
}
/// Counts distinct `p`-tagged members per channel from kind:39002 events.
///
/// Lives in this part file to keep `channels_provider.dart` under the
/// repository-wide 1000-line ceiling enforced by `just file-size-check`.
Map<String, int> _memberCountsByChannelId(Iterable<NostrEvent> memberEvents) {
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final channelId = event.getTagValue('d');
if (channelId == null) continue;
final pTags = <String>{};
for (final tag in event.tags) {
if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) {
pTags.add(tag[1].toLowerCase());
}
}
memberCounts[channelId] = pTags.length;
}
return memberCounts;
}
/// Loads the open-channel directory fenced to the scope that requested it.
///
/// A community or identity switch changes the scope, and a newer request bumps
/// the generation. Either one retires an in-flight request, so a delayed
/// response can never populate the current community's state. This is the
/// tenant boundary described in VISION.md: isolation is the boundary, not a
/// filter, so a retired response is discarded rather than merged.
///
/// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1000-line file ceiling enforced by `just file-size-check`.
class _ChannelRefreshCoordinator {
/// Resolves the relay-and-identity scope that is active right now.
final String Function() currentScope;
/// Owns the externally observable directory load status.
final ChannelDirectoryLoadNotifier Function() loadStatus;
int _generation = 0;
/// Generation of the most recently issued or retired request.
int get generation => _generation;
_ChannelRefreshCoordinator({
required this.currentScope,
required this.loadStatus,
});
/// Binds the fence to a notifier's [Ref] so the provider needs one line.
///
/// Both closures read rather than watch: the fence asks what the scope is
/// right now, and must not make the notifier depend on it.
factory _ChannelRefreshCoordinator.forRef(Ref ref) =>
_ChannelRefreshCoordinator(
currentScope: () => channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
),
loadStatus: () => ref.read(channelDirectoryLoadStatusProvider.notifier),
);
/// Retires any in-flight request without starting a new one.
///
/// Called when the relay or identity changes so a response already on the
/// wire cannot be written into the new scope.
void retireInFlight() => _generation++;
/// Starts a fenced refresh without issuing the directory query.
///
/// Used by callers that must carry the scope across later awaits even when
/// they do not refresh discovery, so a membership-only refresh cannot install
/// an old scope's list either.
_ChannelRefreshFence beginRefresh({required bool fetchesDirectory}) {
final scope = currentScope();
final generation = ++_generation;
if (!fetchesDirectory) {
final status = loadStatus();
if (status.isLoading(scope)) {
// This refresh takes ownership of the shared generation, so the
// in-flight directory response will be discarded. The mounted Browse
// sheet cannot restart an idle request on its own: it only starts a
// load when it mounts and deliberately renders idle as its initial
// spinner. Settle the displaced load to a retryable terminal state so
// the sheet never waits forever for a response that may no longer
// write results.
status.markError(scope);
}
}
return _ChannelRefreshFence(this, scope, generation);
}
/// Fetches the directory under [fence], or throws if the fence is retired.
///
/// Returns null when the request failed inside the current scope, which means
/// "retain the cached discovery". The fence is re-checked after the await and
/// before every write, on both the success and the failure path.
Future<List<NostrEvent>?> loadDirectory(
RelaySessionNotifier session,
_ChannelRefreshFence fence,
) async {
loadStatus().markLoading(fence.scope);
final List<NostrEvent> metas;
try {
metas = await _fetchChannelDirectoryMetas(session);
} catch (error, stackTrace) {
fence.ensureCurrent();
loadStatus().markError(fence.scope);
debugPrint(
'[ChannelsNotifier] channel directory refresh failed; retaining '
'cached discovery: $error\n$stackTrace',
);
return null;
}
fence.ensureCurrent();
loadStatus().markLoaded(fence.scope);
return metas;
}
}
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;
}
/// Thread-interest and unread helpers shared by [ChannelsNotifier].
///
/// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1000-line file ceiling enforced by `just file-size-check`.
String? _observedUnreadRootId(NostrEvent event) =>
_isBroadcastReply(event) ? null : event.threadReference.rootId;
bool _isBroadcastReply(NostrEvent event) => event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
Set<String> _readRootIdSet(String? raw) {
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return {};
return {
for (final value in decoded)
if (value is String) value,
};
} catch (_) {
return {};
}
}
String _encodeRootIdSet(Set<String> values) => jsonEncode(values.toList());
/// Records one observed unread event for a channel's badge state.
///
/// An extension in this part file rather than a method on the notifier because
/// `channels_provider.dart` sits against the repository-wide 1000-line file
/// ceiling enforced by `just file-size-check`. Private members stay reachable:
/// a part shares its parent's library.
extension _ObservedUnreadRecording on ChannelsNotifier {
void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) {
final isThreadedReply =
event.threadReference.parentId != null && !_isBroadcastReply(event);
final isHighPriority =
channel.isDm || isHighPriorityEvent(event.tags, myPk);
recordObservedUnreadEvent(
_observedUnreadEventsByChannel,
channel.id,
makeObservedUnreadEvent(
id: event.id,
createdAt: event.createdAt,
rootId: _observedUnreadRootId(event),
highPriority: isHighPriority,
channelType: channel.channelType,
isThreadedReply: isThreadedReply,
),
_unreadCatchUpLimit,
);
final current = _latestObservedByChannel[channel.id] ?? 0;
if (event.createdAt > current) {
_latestObservedByChannel[channel.id] = event.createdAt;
}
}
}

View File

@@ -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;

View File

@@ -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,
),
),
),
],
);
}
}

View File

@@ -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),
),
],
),
);

View File

@@ -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(),
);
}
}

View File

@@ -19,6 +19,8 @@ import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/observed_unread_event.dart';
import 'unread_badge/should_notify_for_event.dart';
part 'channel_directory.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
const _unreadCatchUpLimit = 1000;
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
@@ -26,11 +28,11 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
/// Loads the user's channel list from the relay over WebSocket.
///
/// Two-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.
/// Membership loading resolves kind:39002 events tagged `#p:<my-pubkey>`,
/// then fetches kind:39000 metadata for those channel ids.
///
/// The paginated kind:39000 directory is fetched separately when Browse
/// channels opens, so discovery never delays the main Conversations screen.
/// Live updates are layered on top via per-channel subscriptions on the
/// `#h` tag for any of the visible channel event kinds — incoming events
/// bump `lastMessageAt` for that channel.
@@ -39,7 +41,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final Map<String, void Function()> _unsubscribersByChannel = {};
Future<void> _liveSubscriptionQueue = Future.value();
List<Channel> _desiredLiveChannels = const [];
Set<String> _desiredLiveChannelIds = const {};
int _subscriptionVersion = 0;
String? _subscriptionRelayBaseUrl;
@@ -54,6 +55,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
List<NostrEvent> _directoryMetas = const [];
/// Fences directory responses to the relay and identity that requested them.
late final _ChannelRefreshCoordinator _refreshCoordinator =
_ChannelRefreshCoordinator.forRef(ref);
/// The member snapshot already returned while loading the channel list.
///
@@ -81,6 +87,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
_directoryMetas = const [];
// Retire any in-flight directory request: its response describes the
// previous relay or identity and must not reach this scope's state.
_refreshCoordinator.retireInFlight();
}
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
@@ -125,10 +135,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
bool fetchDirectory = false,
}) async {
final channels = await _fetchChannels(
subscribeLive: subscribeLive,
fetchLastMessage: fetchLastMessage,
fetchDirectory: fetchDirectory,
);
_hasLoaded = true;
return channels;
@@ -137,6 +149,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<List<Channel>> _fetchChannels({
bool subscribeLive = false,
bool fetchLastMessage = true,
bool fetchDirectory = false,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
@@ -144,49 +157,48 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final session = ref.read(relaySessionProvider.notifier);
// Step 1: find the channels I'm a member of via kind:39002.
final memberships = <NostrEvent>[];
{
int? until;
const pageSize = 500;
while (true) {
final page = await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {
'#p': [myPk],
},
limit: pageSize,
until: until,
),
);
memberships.addAll(page);
if (page.length < pageSize) break;
until = page.map((e) => e.createdAt).reduce(min) - 1;
}
}
final channelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
.toSet()
.toList();
_cacheMemberSnapshots(memberships, replaceAll: true);
if (channelIds.isEmpty) {
if (subscribeLive) await _subscribeLive(const []);
return const [];
}
// Step 2: pull channel metadata in one batched filter.
final metas = await session.fetchHistory(
NostrFilters.channelMetadata(channelIds),
// Acquire request ownership before the first relay await. Every channel-list
// path uses this fence so completion order cannot let an older ordinary,
// directory, or reconnect refresh replace a newer membership list.
final fence = _refreshCoordinator.beginRefresh(
fetchesDirectory: fetchDirectory,
);
// 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.
// Step 1: find the channels I'm a member of via kind:39002.
final memberships = await _fenced(
fence,
_fetchChannelMemberships(session, myPk),
);
final memberChannelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
.toSet();
_cacheMemberSnapshots(memberships, replaceAll: true);
// 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 _fenced(
fence,
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.
if (fetchDirectory) {
final metas = await _refreshCoordinator.loadDirectory(session, fence);
if (metas != null) _directoryMetas = metas;
}
// 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;
@@ -197,62 +209,56 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
final dedupedMetas = latestMetaPerId.values;
// Resolve DM participant display names. Relay stores DM channels with
// literal name="DM"; pure-Nostr architecture pushes name resolution to
// the client, so collect non-self participant pubkeys across all DM
// metas and batch-fetch their kind:0 profiles in one round-trip.
final dmParticipants = <String>{};
final myPkLower = myPk.toLowerCase();
for (final event in dedupedMetas) {
final data = ChannelData.fromEvent(event);
if (data.channelType != 'dm') continue;
for (final pk in data.participantPubkeys) {
final lower = pk.toLowerCase();
if (lower != myPkLower) dmParticipants.add(lower);
}
}
// Resolve DM participant display names. Extracted into the part file so
// `channels_provider.dart` stays under the 1000-line ceiling enforced by
// `just file-size-check`.
final displayNames = await _resolveDmDisplayNames(
session,
fence,
dedupedMetas,
myPk,
);
final displayNames = <String, String>{};
if (dmParticipants.isNotEmpty) {
final profileEvents = await session.fetchHistory(
NostrFilters.profilesBatch(dmParticipants.toList()),
);
for (final event in profileEvents) {
if (event.kind != 0) continue;
final profile = ProfileData.fromEvent(event);
final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: profile.nip05?.trim().isNotEmpty == true
? profile.nip05!.trim()
: shortPubkey(profile.pubkey);
displayNames[profile.pubkey.toLowerCase()] = label;
}
}
final hiddenDmIds = await _fetchHiddenDmIds(session, myPk);
final hiddenDmIds = await _fenced(fence, _fetchHiddenDmIds(session, myPk));
// Fetch the authoritative membership snapshots before filtering Huddle
// backing channels. The relay-signed kind:39000 metadata identifies the
// relay, not the channel creator; the owner role in kind:39002 is the
// canonical creator identity used to reject forged Huddle links.
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 _fenced(
fence,
session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': memberCountChannelIds},
limit: memberCountChannelIds.length,
),
),
);
final huddleStarts = memberCountChannelIds.isEmpty
? const <NostrEvent>[]
: await _fenced(
fence,
_fetchHuddleStarts(session, memberCountChannelIds),
);
final huddleBackingIds = huddleBackingChannelIds(
await _fetchHuddleStarts(session, channelIds),
huddleStarts,
memberEvents,
);
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;
if (huddleBackingIds.contains(channel.id) &&
channel.isStream &&
@@ -269,18 +275,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Use the membership snapshots already fetched above for both Huddle
// linkage validation and member-count hydration.
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
if (chId == null) continue;
final pTags = <String>{};
for (final tag in event.tags) {
if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) {
pTags.add(tag[1].toLowerCase());
}
}
memberCounts[chId] = pTags.length;
}
final memberCounts = _memberCountsByChannelId(memberEvents);
for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id];
if (count != null) {
@@ -301,7 +296,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final events = await _fetchLastMessageEvents(session, activeChannels);
final events = await _fenced(
fence,
_fetchLastMessageEvents(session, activeChannels),
);
final lastMessageMap = <String, int>{};
final mutedChannelIds = _mutedChannelIds();
for (final event in events) {
@@ -356,6 +354,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Scoped narrowly to the archived flip — broader metadata staleness
// (renames, topic changes, etc.) is a separate, pre-existing concern that
// already affects this provider for other reasons.
// Re-check before the first write that other providers can observe. Every
// await above is fenced, but the switch can also land in the synchronous
// gap, so the guard sits immediately before the write rather than only
// after the await.
fence.ensureCurrent();
final prevById = <String, Channel>{
for (final c in state.value ?? const <Channel>[]) c.id: c,
};
@@ -367,8 +371,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
if (subscribeLive) {
await _subscribeLive(channels);
// Subscriptions are shared relay state, so a retired refresh must not
// install them even though its channel list is already built.
fence.ensureCurrent();
await _fenced(fence, _subscribeLive(channels, fence));
}
// Guard the provider-state write in `retryDirectory` and `build`: the
// caller assigns whatever this returns, so the last check belongs here.
fence.ensureCurrent();
return channels;
}
@@ -470,51 +480,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return events;
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
) async {
try {
final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk));
if (events.isEmpty) return const {};
NostrEvent latest = events.first;
for (final event in events.skip(1)) {
if (event.createdAt > latest.createdAt) {
latest = event;
}
}
return {
for (final tag in latest.tags)
if (tag.length >= 2 && tag[0] == 'h') tag[1],
};
} catch (_) {
return const {};
}
}
Future<List<NostrEvent>> _fetchHuddleStarts(
RelaySessionNotifier session,
List<String> parentChannelIds,
) async {
if (parentChannelIds.isEmpty) return const [];
try {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return await session.fetchHistory(
NostrFilter(
kinds: const [EventKind.huddleStarted],
tags: {'#h': parentChannelIds},
since: now - const Duration(hours: 2).inSeconds,
limit: 500,
),
);
} catch (error) {
debugPrint(
'[ChannelsNotifier] Huddle backing-channel query failed: $error',
);
return const [];
}
}
/// Build a [Channel] from a kind:39000 metadata event.
///
/// [displayNames] maps lowercase participant pubkey → resolved label and is
@@ -565,26 +530,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
/// Subscribe per-channel to live events (requires `#h` tag for relay
/// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect
/// newly created channels we don't yet have subscriptions for.
Future<void> _subscribeLive(List<Channel> channels) {
/// channel-scoped fan-out). Also starts a 60s WS backstop poll to reconcile
/// membership changes without repeatedly downloading the global directory.
Future<void> _subscribeLive(
List<Channel> channels,
_ChannelRefreshFence fence,
) {
final channelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final relayBaseUrl = ref.read(relayConfigProvider).baseUrl;
_desiredLiveChannels = channels;
_desiredLiveChannelIds = channelIds;
final subscriptionVersion = ++_subscriptionVersion;
final sync = _liveSubscriptionQueue.then(
(_) =>
_syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels),
(_) => _syncLiveSubscriptions(
relayBaseUrl,
subscriptionVersion,
channels,
fence,
),
);
_liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) {
debugPrint(
'[ChannelsNotifier] live subscription sync failed: $error\n$stack',
);
if (error is! _StaleChannelRefresh) {
debugPrint(
'[ChannelsNotifier] live subscription sync failed: $error\n$stack',
);
}
});
return sync;
}
@@ -593,17 +566,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
String relayBaseUrl,
int subscriptionVersion,
List<Channel> channels,
_ChannelRefreshFence fence,
) async {
fence.ensureCurrent();
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
return;
}
if (subscriptionVersion != _subscriptionVersion) {
await _syncLiveSubscriptions(
ref.read(relayConfigProvider).baseUrl,
_subscriptionVersion,
_desiredLiveChannels,
);
return;
}
@@ -642,7 +612,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
),
_handleLiveEvent,
);
if (ref.read(relaySessionProvider).status != SessionStatus.connected ||
if (!fence.isCurrent) {
unsubscribe();
throw const _StaleChannelRefresh();
}
if (subscriptionVersion != _subscriptionVersion ||
ref.read(relaySessionProvider).status != SessionStatus.connected ||
!_desiredLiveChannelIds.contains(channelId) ||
ref.read(relayConfigProvider).baseUrl != relayBaseUrl ||
_subscriptionRelayBaseUrl != relayBaseUrl) {
@@ -655,6 +630,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
continue;
}
_unsubscribersByChannel[channelId] = unsubscribe;
} on _StaleChannelRefresh {
rethrow;
} catch (error) {
debugPrint(
'[ChannelsNotifier] live subscription failed for $channelId: $error',
@@ -676,7 +653,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return;
}
unawaited(_catchUpUnreadEvents(channels));
fence.ensureCurrent();
unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion));
_backstopTimer?.cancel();
_backstopTimer = Timer.periodic(
@@ -685,7 +663,24 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
);
}
Future<void> _catchUpUnreadEvents(List<Channel> channels) async {
/// Backfills unread badges for the channels this refresh just installed.
///
/// Runs detached from the refresh that starts it, so the lifecycle token
/// captured below is what keeps a response that outlived its refresh from
/// writing unread state into whatever the user is looking at now. Every
/// refresh path starts a catch-up, including the initial load, the ordinary
/// membership refresh a join performs and the reconnect backstop, so the
/// token is unconditional rather than tied to discovery. A retired refresh
/// returns instead of throwing: nothing awaits this future, so a thrown
/// [_StaleChannelRefresh] would only surface as an unhandled error.
///
/// The request fence and subscription generation are passed from the refresh
/// that installed [channels], preserving request ownership after detachment.
Future<void> _catchUpUnreadEvents(
List<Channel> channels,
_ChannelRefreshFence fence,
int subscriptionGeneration,
) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
@@ -727,6 +722,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
filters,
operation: 'unread catch-up',
);
// The relay round-trip above is the window Jed's probes park in: a newer
// refresh, a community switch or an identity switch here means every
// write below belongs to a channel list the user has left.
if (_isCatchUpRetired(fence, subscriptionGeneration)) return;
for (final event in events) {
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
@@ -734,6 +733,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
var recorded = false;
for (final event in events) {
final channelId = event.channelId;
if (channelId == null) continue;
@@ -754,12 +754,19 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
continue;
}
_recordUnreadEvent(channel, event, myPk);
recorded = true;
}
// Republish only when this catch-up actually changed unread state. A
// batch that recorded nothing has nothing to show, and a failed or
// superseded batch must not repaint another refresh's list: the retired
// check above already returned in that case, and no await separates it
// from here, so a second check would be dead code.
if (recorded) {
state = state.whenData((channels) => List<Channel>.of(channels));
}
} catch (error) {
debugPrint('[ChannelsNotifier] unread catch-up failed: $error');
}
state = state.whenData((channels) => List<Channel>.of(channels));
}
void _handleLiveEvent(NostrEvent event) {
@@ -858,31 +865,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) {
final isThreadedReply =
event.threadReference.parentId != null && !_isBroadcastReply(event);
final isHighPriority =
channel.isDm || isHighPriorityEvent(event.tags, myPk);
recordObservedUnreadEvent(
_observedUnreadEventsByChannel,
channel.id,
makeObservedUnreadEvent(
id: event.id,
createdAt: event.createdAt,
rootId: _observedUnreadRootId(event),
highPriority: isHighPriority,
channelType: channel.channelType,
isThreadedReply: isThreadedReply,
),
_unreadCatchUpLimit,
);
final current = _latestObservedByChannel[channel.id] ?? 0;
if (event.createdAt > current) {
_latestObservedByChannel[channel.id] = event.createdAt;
}
}
void clearObservedUnreadForChannel(String channelId) {
_latestObservedByChannel.remove(channelId);
_observedUnreadEventsByChannel.remove(channelId);
@@ -908,6 +890,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channels = await _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
fetchLastMessage: false,
fetchDirectory: false,
);
for (var i = 0; i < channels.length; i++) {
final prev = prevLastMessage[channels[i].id];
@@ -916,6 +899,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
state = AsyncData(channels);
} on _StaleChannelRefresh {
return;
} catch (error) {
debugPrint('[ChannelsNotifier] backstop refresh failed: $error');
}
@@ -929,12 +914,69 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// cached channel list with [] or an error. Wait for `build()` to re-run
// when the session transitions to connected.
if (sessionState.status != SessionStatus.connected) return;
state = await AsyncValue.guard(() => _fetch(subscribeLive: true));
try {
final channels = await _fetch(subscribeLive: true);
state = AsyncData(channels);
} on _StaleChannelRefresh {
return;
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
}
}
/// Loads the directory when Browse channels opens after startup or an error.
Future<void> ensureDirectoryLoaded() async {
final directoryState = ref.read(channelDirectoryLoadStatusProvider);
final scope = channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
if (directoryState.scope == scope &&
(directoryState.status == ChannelDirectoryLoadStatus.loading ||
directoryState.status == ChannelDirectoryLoadStatus.loaded)) {
return;
}
await retryDirectory();
}
/// Retries channel discovery while retaining the current channel list.
Future<void> retryDirectory() async {
final previousChannels = state.value;
final directoryStatus = ref.read(
channelDirectoryLoadStatusProvider.notifier,
);
final scope = channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
final directoryState = ref.read(channelDirectoryLoadStatusProvider);
if (directoryState.scope == scope &&
directoryState.status == ChannelDirectoryLoadStatus.loading) {
return;
}
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
directoryStatus.markError(scope);
return;
}
try {
state = AsyncData(
await _fetch(subscribeLive: true, fetchDirectory: true),
);
} on _StaleChannelRefresh {
// A community or identity switch retired this request. Its response
// describes a scope the user has left, so write neither the channel list
// nor the load status; the new scope owns both now.
return;
} catch (error, stackTrace) {
directoryStatus.markError(scope);
state = previousChannels == null
? AsyncError(error, stackTrace)
: AsyncData(previousChannels);
}
}
void _clearLiveSubscriptions() {
_subscriptionVersion++;
_desiredLiveChannels = const [];
_desiredLiveChannelIds = const {};
for (final unsubscribe in _unsubscribersByChannel.values) {
unsubscribe();
@@ -949,26 +991,3 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
ChannelsNotifier.new,
);
String? _observedUnreadRootId(NostrEvent event) =>
_isBroadcastReply(event) ? null : event.threadReference.rootId;
bool _isBroadcastReply(NostrEvent event) => event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
Set<String> _readRootIdSet(String? raw) {
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return {};
return {
for (final value in decoded)
if (value is String) value,
};
} catch (_) {
return {};
}
}
String _encodeRootIdSet(Set<String> values) => jsonEncode(values.toList());

View File

@@ -644,12 +644,10 @@ class _RecentSearches extends StatelessWidget {
class _ChannelsSection extends StatelessWidget {
final List<Channel> channels;
final VoidCallback onResultSelected;
const _ChannelsSection({
required this.channels,
required this.onResultSelected,
});
@override
Widget build(BuildContext context) {
return Column(
@@ -670,12 +668,14 @@ class _ChannelsSection extends StatelessWidget {
key: ValueKey('search-channel-title-${channel.id}'),
style: contentListTitleTextStyle,
),
subtitle: Text(
'${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}',
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
subtitle: channel.isMember
? Text(
'${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}',
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
)
: null,
trailing: !channel.isMember && !channel.isDm
? Container(
padding: const EdgeInsets.symmetric(

View File

@@ -24,6 +24,7 @@ import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
@@ -1278,8 +1279,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));
@@ -1292,15 +1293,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;
@@ -1314,8 +1323,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),
@@ -1334,9 +1347,297 @@ 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('browse action retries an initial directory request problem', (
tester,
) async {
final joinable = Channel(
id: 'retry-discovery',
name: 'community-help',
channelType: 'stream',
visibility: 'open',
description: 'Help from the community',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 0,
);
late _RetryingDirectoryNotifier notifier;
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(
() => notifier = _RetryingDirectoryNotifier(
initialChannels: testChannels,
retriedChannels: [...testChannels, joinable],
),
),
],
),
);
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('Couldn’t load open channels.'), findsOneWidget);
expect(find.text('No open channels available to join.'), findsNothing);
expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget);
await tester.tap(find.byKey(const Key('browse-channels-retry')));
await tester.pumpAndSettle();
expect(notifier.retryCount, 1);
expect(find.text('Couldn’t load open channels.'), findsNothing);
expect(
find.byKey(const Key('browse-channel-retry-discovery')),
findsOneWidget,
);
});
testWidgets('browse action exposes retry when refresh supersedes loading', (
tester,
) async {
final joinable = Channel(
id: 'superseded-directory',
name: 'community-help',
channelType: 'stream',
visibility: 'open',
description: 'Help from the community',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 0,
);
late _SupersededDirectoryNotifier notifier;
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(
() => notifier = _SupersededDirectoryNotifier(
initialChannels: testChannels,
retriedChannels: [...testChannels, joinable],
),
),
],
),
);
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.byType(BuzzLoadingIndicator), findsOneWidget);
notifier.supersedeLoadingDirectory();
await tester.pumpAndSettle();
expect(find.byType(BuzzLoadingIndicator), findsNothing);
expect(find.text('Couldn’t load open channels.'), findsOneWidget);
expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget);
await tester.tap(find.byKey(const Key('browse-channels-retry')));
await tester.pumpAndSettle();
expect(notifier.retryCount, 1);
expect(
find.byKey(const Key('browse-channel-superseded-directory')),
findsOneWidget,
);
});
testWidgets('browse action scrolls and joins an offscreen channel', (
tester,
) async {
final channels = List.generate(
500,
(index) => Channel(
id: 'directory-$index',
name: 'channel-${index.toString().padLeft(3, '0')}',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 0,
),
);
late _RecordingChannelActions actions;
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
channelActionsProvider.overrideWith(
(ref) => actions = _RecordingChannelActions(ref),
),
],
),
);
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-directory-0')),
findsAtLeast(1),
);
expect(find.byKey(const Key('browse-channel-directory-499')), findsNothing);
final sheet = find.byType(BottomSheet).last;
final scrollable = find
.descendant(of: sheet, matching: find.byType(Scrollable))
.last;
expect(
tester.state<ScrollableState>(scrollable).position.maxScrollExtent,
greaterThan(0),
);
await tester.scrollUntilVisible(
find.byKey(const Key('browse-channel-directory-499')),
500,
scrollable: scrollable,
maxScrolls: 100,
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('browse-channel-directory-499')),
findsOneWidget,
);
expect(find.byKey(const Key('browse-channel-directory-0')), findsNothing);
await tester.tap(
find.byKey(const Key('browse-channel-join-directory-499')),
);
await tester.pumpAndSettle();
expect(actions.joinedChannelIds, ['directory-499']);
expect(find.byType(BottomSheet), findsNothing);
});
testWidgets('create channel sheet lists type and visibility radio options', (
tester,
) async {
@@ -1728,15 +2029,37 @@ void main() {
expect(find.text('archived-stream'), findsNothing);
});
testWidgets('shows empty state when no channels', (tester) async {
testWidgets('empty state does not preview unjoined channels', (tester) async {
final discoveredChannel = Channel(
id: 'discovered-channel',
name: 'community-help',
channelType: 'stream',
visibility: 'open',
description: 'Get help from the community',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 7,
);
await tester.pumpWidget(
buildTestable(
overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))],
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier([discoveredChannel]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('No conversations yet'), findsOneWidget);
expect(
find.text('Join an open channel to start a conversation.'),
findsNothing,
);
expect(
find.byKey(const Key('browse-channel-discovered-channel')),
findsNothing,
);
});
testWidgets('shows error view with retry button', (tester) async {
@@ -1997,6 +2320,13 @@ class _FakeNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => _channels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
@override
Map<String, int> get latestObservedByChannel => {
for (final entry in _observedEventsByChannel.entries)
@@ -2011,6 +2341,105 @@ class _FakeNotifier extends ChannelsNotifier {
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _RetryingDirectoryNotifier extends ChannelsNotifier {
_RetryingDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
}
@override
Future<void> retryDirectory() async {
retryCount++;
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await Future<void>.delayed(Duration.zero);
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
class _SupersededDirectoryNotifier extends ChannelsNotifier {
_SupersededDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
final _directoryCompletion = Completer<void>();
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await _directoryCompletion.future;
}
/// Mirrors an ordinary refresh invalidating the active directory request.
void supersedeLoadingDirectory() {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
_directoryCompletion.complete();
}
@override
Future<void> retryDirectory() async {
retryCount++;
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
String _activeDirectoryScope(Ref ref) => channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
class _RecordingChannelActions extends ChannelActions {
_RecordingChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
final List<String> joinedChannelIds = [];
@override
Future<void> joinChannel(String channelId) async {
joinedChannelIds.add(channelId);
}
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
_FakeChannelSectionsNotifier(this._store);

File diff suppressed because it is too large Load Diff

View File

@@ -930,6 +930,43 @@ void main() {
expect(content.agentMentionPubkeys, contains(agentPubkey));
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
});
testWidgets('does not label an unjoined channel as having zero members', (
tester,
) async {
final state = SearchState(
query: 'community',
channelResults: [
Channel(
id: 'community-help',
name: 'community-help',
channelType: 'stream',
visibility: 'open',
description: 'Help from the community',
createdBy: 'test',
createdAt: DateTime(2025),
memberCount: 0,
),
],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget);
expect(find.text('0 members'), findsNothing);
});
}
class _FakeSearchNotifier extends SearchNotifier {