mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
487
mobile/lib/features/channels/channel_directory.dart
Normal file
487
mobile/lib/features/channels/channel_directory.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user