diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 3f8cb24548a..a5b98c18099 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/community/community_membership_provider.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import 'dm_channel_labels.dart'; import '../../shared/mentions/agent_identity_provider.dart'; @@ -37,6 +38,7 @@ import '../../shared/profile/user_cache_provider.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; +part 'channel_admission_card.dart'; part 'channel_details_page.dart'; /// Opens the mobile channel actions sheet and returns whether its parent page diff --git a/mobile/lib/features/channels/channel_admission_card.dart b/mobile/lib/features/channels/channel_admission_card.dart new file mode 100644 index 00000000000..19fa02d6b5e --- /dev/null +++ b/mobile/lib/features/channels/channel_admission_card.dart @@ -0,0 +1,230 @@ +part of 'channel_actions_sheet.dart'; + +/// Who joins an open channel and how: the role newcomers get, and whether the +/// relay joins every new community member to it. Every value comes from fresh +/// relay metadata, and a change shows only after the relay confirms it. A +/// change the relay may have applied blocks further changes until a read-only +/// check confirms it, or until a read shows it unapplied and the same signed +/// change is sent again. +class _ChannelAdmissionCard extends HookConsumerWidget { + const _ChannelAdmissionCard({ + required this.channel, + required this.canEditJoinRole, + }); + + final Channel channel; + + /// Channel owners and admins choose the role newcomers get. + final bool canEditJoinRole; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Only community owners and admins choose default channels, and only an + // open, unarchived stream can be one. + final mayBeDefault = channel.isStream && !channel.isArchived; + final communityRole = ref.watch(currentCommunityRoleProvider); + final managesDefault = + mayBeDefault && managesCommunity(communityRole.value); + // Without the role the setting cannot be offered, so say why and retry. + final roleUnknown = + mayBeDefault && !managesDefault && communityRole.hasError; + final admissionAsync = ref.watch(channelAdmissionProvider(channel.id)); + final uncertain = ref.watch(uncertainAdmissionChangeProvider(channel.id)); + final saving = useState(false); + if (!canEditJoinRole && !managesDefault && !roleUnknown) { + return const SizedBox.shrink(); + } + + final admission = admissionAsync.asData?.value; + final ready = + admission != null && + !admissionAsync.isLoading && + uncertain == null && + !saving.value; + final canToggleDefault = managesDefault && ready && admission.canBeDefault; + + void showError(Object error) { + if (!context.mounted) return; + // Replace, not queue, so the message always matches the latest result. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showFeedbackSnackBar( + SnackBar( + content: Text( + error is ChannelChangeUnconfirmed + ? 'Buzz couldn’t confirm the change yet. Check its status ' + 'before trying again.' + : 'Couldn’t change channel access. Try again.', + ), + ), + kind: ToastKind.error, + ); + } + + Future save(Future Function(ChannelActions) change) async { + if (saving.value) return; + saving.value = true; + try { + await change(ref.read(channelActionsProvider)); + } catch (error) { + showError(error); + } finally { + if (context.mounted) saving.value = false; + } + } + + Future chooseJoinRole() async { + final current = admission?.joinRole; + final role = await showBuzzModalBottomSheet( + context: context, + title: 'New people join as', + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: SheetActionSection( + children: [ + for (final (value, title, subtitle) in const [ + ('member', 'Member', 'Can read and post'), + ('guest', 'Guest', 'Can read but not post'), + ]) + ListTile( + key: ValueKey('channel-join-role-$value'), + title: Text(title), + subtitle: Text(subtitle), + selected: current == value, + trailing: current == value + ? Icon( + BuzzIcons.check, + color: sheetContext.colors.primary, + ) + : null, + onTap: () => Navigator.of(sheetContext).pop(value), + ), + ], + ), + ), + ), + ); + if (role == null || role == current) return; + await save((actions) => actions.setChannelJoinRole(channel.id, role)); + } + + void setDefault(bool enabled) => unawaited( + save( + (actions) => actions.setDefaultChannel(channel.id, enabled: enabled), + ), + ); + + return AppListCard( + key: const ValueKey('channel-details-access-card'), + label: 'General access', + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (roleUnknown) + AppListRow( + key: const ValueKey('channel-community-role-retry'), + icon: BuzzIcons.triangleAlert, + title: 'Default channel unavailable', + subtitle: 'Couldn’t check your community role. Tap to try again.', + onTap: () => ref.invalidate(communityMembershipProvider), + ), + if (uncertain != null) + AppListRow( + key: const ValueKey('channel-access-check'), + icon: BuzzIcons.triangleAlert, + title: 'Change not confirmed', + subtitle: 'It may have saved. Tap to check its status.', + trailing: saving.value + ? const BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Checking save status', + ) + : null, + onTap: saving.value + ? null + : () => save( + (actions) => actions.checkAdmissionChange(channel.id), + ), + ), + if (uncertain != null && uncertain.unseen) + AppListRow( + key: const ValueKey('channel-access-resend'), + icon: BuzzIcons.refreshCw, + title: 'Send again', + subtitle: 'The relay doesn’t show the change yet.', + onTap: saving.value + ? null + : () => save( + (actions) => actions.resendAdmissionChange(channel.id), + ), + ), + if (admissionAsync.hasError && admission == null) + AppListRow( + key: const ValueKey('channel-access-retry'), + icon: BuzzIcons.triangleAlert, + title: 'Access settings unavailable', + subtitle: 'Tap to try again.', + onTap: () => ref.invalidate(channelAdmissionProvider(channel.id)), + ) + else ...[ + if (canEditJoinRole) + AppListRow( + key: const ValueKey('channel-join-role-row'), + icon: BuzzIcons.users, + title: 'New people join as', + value: switch (admission?.joinRole) { + 'guest' => 'Guest', + 'member' => 'Member', + _ => null, + }, + trailing: ready + ? const _ChannelDetailsChevron() + : uncertain != null + ? null + : const BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading access settings', + ), + onTap: ready ? chooseJoinRole : null, + ), + if (managesDefault) + MergeSemantics( + key: const ValueKey('channel-default-setting'), + child: Stack( + alignment: Alignment.centerRight, + children: [ + AppListRow( + key: const ValueKey('channel-default-row'), + icon: BuzzIcons.userPlus, + title: 'Default channel', + subtitle: 'New community members join automatically', + // Reserve room for the switch without growing the row. + trailing: const SizedBox(width: 60), + onTap: canToggleDefault + ? () => setDefault(!admission.autoJoin) + : null, + ), + Positioned.fill( + right: Grid.xs, + child: Align( + alignment: Alignment.centerRight, + child: Switch.adaptive( + value: admission?.autoJoin ?? false, + onChanged: canToggleDefault ? setDefault : null, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 3b870181232..5ddf9ad3765 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -469,6 +469,11 @@ class ChannelDetailsPage extends HookConsumerWidget { ], ], ), + if (!resolvedChannel.isDm && resolvedChannel.visibility == 'open') + _ChannelAdmissionCard( + channel: resolvedChannel, + canEditJoinRole: canEdit, + ), AppListCard( key: const ValueKey('channel-details-channel-card'), verticalPadding: _channelDetailsSectionPadding, diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index f57a651a000..1e9086663ec 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -264,6 +264,113 @@ class ChannelActions { await _refreshChannelState(channelId); } + /// Sets the role people get when they join [channelId]: `member` lets them + /// participate, `guest` lets them only view. Existing members keep theirs. + Future setChannelJoinRole(String channelId, String role) => + _setAdmission(channelId, ['join_role', role]); + + /// Makes [channelId] a default channel, which the relay joins every new + /// community member to. Only community owners and admins may change it. + Future setDefaultChannel(String channelId, {required bool enabled}) => + _setAdmission(channelId, ['auto_join', enabled.toString()]); + + /// Sends one partial kind:9002 change, then confirms it from fresh relay + /// metadata. A change the relay may have applied without showing it, or + /// whose acknowledgement was lost, is held as uncertain and reported as + /// [ChannelChangeUnconfirmed]; no other change is published for the channel + /// until [checkAdmissionChange] confirms it. + Future _setAdmission(String channelId, List tag) async { + final uncertain = _ref.read( + uncertainAdmissionChangeProvider(channelId).notifier, + ); + if (_ref.read(uncertainAdmissionChangeProvider(channelId)) != null) { + throw const ChannelChangeUnconfirmed(); + } + _ensureCommunityValid(); + var signed = false; + try { + await _signedEventRelay.submit( + kind: 9002, + content: '', + tags: [ + ['h', channelId], + tag, + ], + // From here the relay may apply the change even if its answer is lost. + onSigned: (event) { + signed = true; + uncertain.hold(tag, event); + }, + ); + } on RelayEventRejected { + uncertain.clear(); + rethrow; + } on RelayNotConnected { + // Nothing was sent, so nothing can have been applied. + uncertain.clear(); + rethrow; + } catch (_) { + if (!signed) rethrow; + _ref.invalidate(channelAdmissionProvider(channelId)); + throw const ChannelChangeUnconfirmed(); + } + await checkAdmissionChange(channelId); + } + + /// Reads [channelId]'s metadata, without publishing, to resolve an + /// uncertain admission change. Throws [ChannelChangeUnconfirmed] while the + /// relay still does not show it; once a read shows it unapplied, the change + /// may be sent again with [resendAdmissionChange]. + Future checkAdmissionChange(String channelId) async { + final uncertain = _ref.read( + uncertainAdmissionChangeProvider(channelId).notifier, + ); + final held = _ref.read(uncertainAdmissionChangeProvider(channelId)); + if (held == null) return; + try { + _ensureCommunityValid(); + final admission = await _fetchChannelAdmission(_session, channelId); + if (!admissionShows(admission, held.tag)) { + uncertain.markUnseen(true); + throw const ChannelChangeUnconfirmed(); + } + uncertain.clear(); + } on ChannelChangeUnconfirmed { + rethrow; + } catch (_) { + throw const ChannelChangeUnconfirmed(); + } finally { + _ref.invalidate(channelAdmissionProvider(channelId)); + } + } + + /// Sends an uncertain change the relay does not show again, as the exact + /// event first signed, then confirms it. A relay that already stored that + /// event ignores it, so this can never apply the change twice. + Future resendAdmissionChange(String channelId) async { + final uncertain = _ref.read( + uncertainAdmissionChangeProvider(channelId).notifier, + ); + final held = _ref.read(uncertainAdmissionChangeProvider(channelId)); + if (held == null || !held.unseen) return; + _ensureCommunityValid(); + uncertain.markUnseen(false); + try { + await _session.publish(held.event); + } on RelayEventRejected { + uncertain.clear(); + rethrow; + } on RelayNotConnected { + // Nothing new was sent; the last read still stands. + uncertain.markUnseen(true); + rethrow; + } catch (_) { + _ref.invalidate(channelAdmissionProvider(channelId)); + throw const ChannelChangeUnconfirmed(); + } + await checkAdmissionChange(channelId); + } + /// Archives the channel and refreshes its cached state. Future archiveChannel(String channelId) => _setChannelArchived(channelId, archived: true); diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index f8a2f0cd0e8..ba3e8242f18 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -549,6 +549,141 @@ final channelDetailsProvider = FutureProvider.family(( return channelDetailsFromEvent(events.first); }); +/// Who a channel admits, from its relay-signed kind:39000 metadata. +@immutable +class ChannelAdmission { + const ChannelAdmission({ + required this.joinRole, + required this.autoJoin, + required this.canBeDefault, + }); + + /// Role people get when they join or are joined: `member` or `guest`. + final String joinRole; + + /// Whether the relay joins new community members to this channel. + final bool autoJoin; + + /// Only an open, unarchived stream channel can be a default channel. + final bool canBeDefault; +} + +/// Builds [ChannelAdmission] from kind:39000 metadata. +@visibleForTesting +ChannelAdmission channelAdmissionFromEvent(NostrEvent event) { + final data = ChannelData.fromEvent(event); + return ChannelAdmission( + joinRole: data.joinRole, + autoJoin: data.isAutoJoin, + canBeDefault: + data.channelType == 'stream' && + data.visibility == 'open' && + !data.isArchived, + ); +} + +Future _fetchChannelAdmission( + RelaySessionNotifier session, + String channelId, +) async { + final events = await session.fetchHistory( + NostrFilter( + kinds: [39000], + tags: { + '#d': [channelId], + }, + limit: 1, + ), + ); + if (events.isEmpty) throw Exception('Channel not found: $channelId'); + return channelAdmissionFromEvent(events.first); +} + +/// Fresh admission settings for one channel; read on demand, never cached. +final channelAdmissionProvider = FutureProvider.autoDispose + .family((ref, channelId) { + ref.watch(relayConfigProvider); + return _fetchChannelAdmission( + ref.watch(relaySessionProvider.notifier), + channelId, + ); + }); + +/// Whether [admission] shows the kind:9002 admission [tag] as applied. +/// +/// Archived and private channels keep a stored default-channel flag without +/// advertising it, so only an eligible channel's metadata proves either value. +bool admissionShows(ChannelAdmission admission, List tag) => + switch (tag) { + ['join_role', final role] => admission.joinRole == role, + ['auto_join', final value] => + admission.canBeDefault && admission.autoJoin == (value == 'true'), + _ => false, + }; + +/// An admission change the relay may have applied but has not shown yet. +@immutable +class HeldAdmissionChange { + const HeldAdmissionChange({ + required this.tag, + required this.event, + this.unseen = false, + }); + + /// The kind:9002 admission tag the change sets. + final List tag; + + /// The exact signed command. The relay ignores an event id it already + /// stored, so sending this again applies the change at most once. + final NostrEvent event; + + /// A read since the change was last sent did not show it. + final bool unseen; +} + +/// Holds [HeldAdmissionChange] for one channel. +/// +/// It outlives the channel details page so an uncertain change cannot be +/// published again by closing and reopening it; switching communities clears it. +class UncertainAdmissionChange extends Notifier { + UncertainAdmissionChange(this.channelId); + + final String channelId; + + @override + HeldAdmissionChange? build() { + ref.watch(relayConfigProvider); + return null; + } + + void hold(List tag, NostrEvent event) => + state = HeldAdmissionChange(tag: tag, event: event); + + void markUnseen(bool unseen) { + final held = state; + if (held == null) return; + state = HeldAdmissionChange( + tag: held.tag, + event: held.event, + unseen: unseen, + ); + } + + void clear() => state = null; +} + +final uncertainAdmissionChangeProvider = + NotifierProvider.family< + UncertainAdmissionChange, + HeldAdmissionChange?, + String + >(UncertainAdmissionChange.new); + +/// The relay may have applied a change that its metadata does not show yet. +class ChannelChangeUnconfirmed implements Exception { + const ChannelChangeUnconfirmed(); +} + /// Channel members from kind:39002 NIP-29 members event. final channelMembersProvider = FutureProvider.autoDispose .family, String>((ref, channelId) async { diff --git a/mobile/lib/shared/community/community_membership_provider.dart b/mobile/lib/shared/community/community_membership_provider.dart index 4e1eb6c8a04..fe3feb98698 100644 --- a/mobile/lib/shared/community/community_membership_provider.dart +++ b/mobile/lib/shared/community/community_membership_provider.dart @@ -122,6 +122,10 @@ final currentCommunityRoleProvider = Provider>( }, ); +/// Whether [role] is a community owner or admin. +bool managesCommunity(CommunityMemberRole? role) => + role == CommunityMemberRole.owner || role == CommunityMemberRole.admin; + /// Whether [role] is allowed to create community invitations. bool canManageCommunityInvites(CommunityMemberRole? role) => - role == CommunityMemberRole.owner || role == CommunityMemberRole.admin; + managesCommunity(role); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 82452faf45c..f3f7f9c04e3 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -305,6 +305,14 @@ class ChannelData { /// listings and does not hold its name in the listed-name rule. final bool isUnlisted; + /// Role a person who joins (or is joined to) the channel receives: + /// `member` when absent, otherwise `guest` (unknown values fail closed). + final String joinRole; + + /// The relay's bare `["auto_join"]` flag, published only while the channel + /// is a default channel that new community members are joined to. + final bool isAutoJoin; + const ChannelData({ required this.id, required this.name, @@ -319,6 +327,8 @@ class ChannelData { this.ttlDeadline, this.isArchived = false, this.isUnlisted = false, + this.joinRole = 'member', + this.isAutoJoin = false, }); factory ChannelData.fromEvent(NostrEvent event) { @@ -356,6 +366,7 @@ class ChannelData { // archived — anything else (missing tag, "false", unexpected value) means // active. final isArchived = event.getTagValue('archived') == 'true'; + final joinRole = event.getTagValue('join_role'); return ChannelData( id: id, name: name, @@ -374,6 +385,8 @@ class ChannelData { ttlDeadline: ttlDeadline, isArchived: isArchived, isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'), + joinRole: joinRole == null || joinRole == 'member' ? 'member' : 'guest', + isAutoJoin: event.tags.any((t) => t.isNotEmpty && t[0] == 'auto_join'), ); } } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 495b62a5fa8..454d7b76381 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -420,7 +420,7 @@ class RelaySessionNotifier extends Notifier { final generation = _connectionGeneration; if (_rateLimitGate.isActive) await _rateLimitGate.wait(); if (!_isActiveConnection(generation) || !_socketConnected) { - throw StateError('Relay session is not connected'); + throw RelayNotConnected(); } final completer = Completer(); @@ -1025,7 +1025,7 @@ class RelaySessionNotifier extends Notifier { } if (!pending.completer.isCompleted) { pending.completer.completeError( - Exception(message.isNotEmpty ? message : 'Event rejected'), + RelayEventRejected(message.isNotEmpty ? message : 'Event rejected'), ); } } diff --git a/mobile/lib/shared/relay/relay_session_types.dart b/mobile/lib/shared/relay/relay_session_types.dart index fe3ac8c5821..206abddab40 100644 --- a/mobile/lib/shared/relay/relay_session_types.dart +++ b/mobile/lib/shared/relay/relay_session_types.dart @@ -21,5 +21,21 @@ class SessionState { const SessionState({required this.status, this.reconnectAttempt = 0}); } +/// The relay answered an EVENT with `OK false`: it definitely did not apply it. +class RelayEventRejected implements Exception { + const RelayEventRejected(this.message); + + final String message; + + @override + String toString() => 'Exception: $message'; +} + +/// [RelaySessionNotifier.publish] had no connection to send on, so it sent +/// nothing. +class RelayNotConnected extends StateError { + RelayNotConnected() : super('Relay session is not connected'); +} + /// Recovery lifecycle for a live relay subscription. enum RelaySubscriptionStatus { ready, retrying } diff --git a/mobile/test/features/channels/channel_admission_journey_test.dart b/mobile/test/features/channels/channel_admission_journey_test.dart new file mode 100644 index 00000000000..002fd22e5e6 --- /dev/null +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -0,0 +1,524 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_actions_sheet.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _channelId = 'channel-id'; + +Channel _channel({String visibility = 'open', String type = 'stream'}) => + Channel( + id: _channelId, + name: 'general', + channelType: type, + visibility: visibility, + description: '', + createdBy: 'owner', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ); + +/// Relay that serves one channel's kind:39000 and the community roster, and +/// applies accepted kind:9002 admission changes, unless told to accept without +/// applying, lose the acknowledgement, or reject. +class _AdmissionRelay extends RelaySessionNotifier { + _AdmissionRelay({ + required this.channelType, + required this.visibility, + required this.member, + required this.communityRole, + }); + + final String channelType; + String visibility; + final String member; + final String communityRole; + String joinRole = 'member'; + bool autoJoin = false; + bool archived = false; + bool applies = true; + bool losesAck = false; + bool rejects = false; + bool disconnected = false; + bool drops = false; + bool rosterFails = false; + final changes = >>[]; + final ids = []; + + /// Events the app signed; a resend must not sign again. + int signs = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (event.kind == 9002) { + // Like the real session, a disconnected one sends nothing. + if (disconnected) throw RelayNotConnected(); + changes.add(event.tags); + ids.add(event.id); + // Lost on the way: the relay never sees it. + if (drops) throw TimeoutException('not acknowledged'); + if (rejects) throw const RelayEventRejected('restricted: not allowed'); + if (applies) { + for (final tag in event.tags) { + if (tag[0] == 'join_role') joinRole = tag[1]; + if (tag[0] == 'auto_join') autoJoin = tag[1] == 'true'; + } + } + if (losesAck) throw TimeoutException('not acknowledged'); + } + return event; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!filters.any((filter) => filter.kinds.contains(13534))) { + return const []; + } + if (rosterFails) throw Exception('roster unavailable'); + return [ + NostrEvent( + id: 'roster', + pubkey: 'relay', + createdAt: 1, + kind: 13534, + tags: [ + ['member', member, communityRole], + ], + content: '', + sig: 'sig', + ), + ]; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!filter.kinds.contains(39000)) return const []; + final eligible = channelType == 'stream' && visibility == 'open'; + return [ + NostrEvent( + id: 'metadata', + pubkey: 'relay', + createdAt: 1, + kind: 39000, + tags: [ + ['d', _channelId], + ['name', 'general'], + ['t', channelType], + [visibility == 'open' ? 'public' : 'private'], + ['join_role', joinRole], + if (archived) ['archived', 'true'], + // Only an eligible channel advertises a stored default flag. + if (autoJoin && eligible && !archived) ['auto_join'], + ], + content: '', + sig: 'sig', + ), + ]; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async => () {}; + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + onStatusChanged(RelaySubscriptionStatus.ready); + return () {}; + } +} + +/// The real signer, counting how many events the app signs. +class _CountingSigner extends SignedEventRelay { + _CountingSigner(this.relay, String nsec) : super(session: relay, nsec: nsec); + + final _AdmissionRelay relay; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + FutureOr Function(NostrEvent event)? onSigned, + }) { + relay.signs++; + return super.submit( + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + onSigned: onSigned, + ); + } +} + +class _Sections extends ChannelSectionsNotifier { + @override + ChannelSectionsState build() => const ChannelSectionsState( + isReady: true, + store: ChannelSectionStore(), + version: 1, + ); +} + +final _member = nostr.Keys.generate(); + +_AdmissionRelay _relay({ + String type = 'stream', + String visibility = 'open', + String communityRole = 'member', +}) => _AdmissionRelay( + channelType: type, + visibility: visibility, + member: _member.public, + communityRole: communityRole, +); + +/// The details page, mounted while [open] is true, under one app-wide scope. +Widget _app({ + required _AdmissionRelay relay, + required Channel channel, + required String channelRole, + ValueNotifier? open, +}) { + final keys = _member; + final page = ChannelDetailsPage( + channel: channel, + currentPubkey: keys.public, + onMemberTap: (_, _) {}, + ); + return ProviderScope( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => relay), + currentPubkeyProvider.overrideWith((ref) => keys.public), + myPubkeyProvider.overrideWithValue(keys.public), + channelMembersProvider(channel.id).overrideWith( + (ref) async => [ + ChannelMember( + pubkey: keys.public, + role: channelRole, + joinedAt: DateTime(2025), + ), + ], + ), + agentOwnersProvider.overrideWithValue( + const AsyncValue.data({}), + ), + channelSectionsProvider.overrideWith(_Sections.new), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: relay, + signedEventRelay: _CountingSigner(relay, keys.nsec), + currentPubkey: keys.public, + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: open == null + ? page + : ValueListenableBuilder( + valueListenable: open, + builder: (_, isOpen, _) => + isOpen ? page : const Scaffold(body: SizedBox()), + ), + ), + ); +} + +final _accessCard = find.byKey(const ValueKey('channel-details-access-card')); +final _defaultSetting = find.byKey(const ValueKey('channel-default-setting')); +final _joinRoleRow = find.byKey(const ValueKey('channel-join-role-row')); +final _checkRow = find.byKey(const ValueKey('channel-access-check')); +final _resendRow = find.byKey(const ValueKey('channel-access-resend')); +final _roleRetry = find.byKey(const ValueKey('channel-community-role-retry')); + +Switch _defaultSwitch(WidgetTester tester) => tester.widget( + find.descendant(of: _defaultSetting, matching: find.byType(Switch)), +); + +Future _scrollTo(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); +} + +/// Taps [finder] after centring it, clear of the frosted app bar. +Future _tapCentred(WidgetTester tester, Finder finder) async { + unawaited(Scrollable.ensureVisible(tester.element(finder), alignment: 0.5)); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +Future _checkStatus(WidgetTester tester) => + _tapCentred(tester, _checkRow); + +void main() { + testWidgets('admin sets who joins a channel and makes it a default', ( + tester, + ) async { + final relay = _relay(communityRole: 'admin'); + final open = ValueNotifier(true); + addTearDown(open.dispose); + await tester.pumpWidget( + _app(relay: relay, channel: _channel(), channelRole: 'admin', open: open), + ); + await tester.pumpAndSettle(); + await _scrollTo(tester, _defaultSetting); + + expect(find.text('General access'), findsOneWidget); + expect( + find.descendant(of: _joinRoleRow, matching: find.text('Member')), + findsOneWidget, + ); + expect(_defaultSwitch(tester).value, isFalse); + expect( + tester.getSemantics(_defaultSetting), + isSemantics(hasToggledState: true, isToggled: false), + ); + + // Tapping the row, not only the switch, makes the channel a default. + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes.last, [ + ['h', _channelId], + ['auto_join', 'true'], + ]); + expect(_defaultSwitch(tester).value, isTrue); + expect( + tester.getSemantics(_defaultSetting), + isSemantics(hasToggledState: true, isToggled: true), + ); + expect(_checkRow, findsNothing); + + // A lost acknowledgement may still have saved the change: it is held as + // uncertain, blocks every further change, and a read confirms it. + relay.losesAck = true; + await tester.tap(_joinRoleRow); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('channel-join-role-guest'))); + await tester.pumpAndSettle(); + expect(relay.changes.last, [ + ['h', _channelId], + ['join_role', 'guest'], + ]); + expect(find.textContaining('couldn’t confirm the change'), findsOneWidget); + expect(_checkRow, findsOneWidget); + expect(tester.widget(_joinRoleRow).onTap, isNull); + await _tapCentred(tester, find.text('Default channel')); + // The actions refuse to publish too, whoever calls them. + final actions = ProviderScope.containerOf( + tester.element(_accessCard), + ).read(channelActionsProvider); + await expectLater( + actions.setDefaultChannel(_channelId, enabled: false), + throwsA(isA()), + ); + expect(relay.changes, hasLength(2)); + relay.losesAck = false; + await _checkStatus(tester); + expect(_checkRow, findsNothing); + expect( + find.descendant(of: _joinRoleRow, matching: find.text('Guest')), + findsOneWidget, + ); + expect(relay.changes, hasLength(2)); + + // An accepted change the relay's metadata does not show is never + // reported as saved, and survives closing and reopening the page. + relay.applies = false; + await tester.tap( + find.descendant(of: _defaultSetting, matching: find.byType(Switch)), + ); + await tester.pumpAndSettle(); + expect(relay.changes.last, [ + ['h', _channelId], + ['auto_join', 'false'], + ]); + expect(_defaultSwitch(tester).value, isTrue); + open.value = false; + await tester.pumpAndSettle(); + open.value = true; + await tester.pumpAndSettle(); + await _scrollTo(tester, _defaultSetting); + expect(_checkRow, findsOneWidget); + expect(_defaultSwitch(tester).onChanged, isNull); + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes, hasLength(3)); + + // Archived and private metadata keep the stored flag without showing + // it, and open metadata still shows it on: none of them confirm "off". + relay.archived = true; + await _checkStatus(tester); + expect(_checkRow, findsOneWidget); + relay + ..archived = false + ..visibility = 'private'; + await _checkStatus(tester); + expect(_checkRow, findsOneWidget); + relay.visibility = 'open'; + await _checkStatus(tester); + expect(_checkRow, findsOneWidget); + + // Only eligible metadata with the flag cleared resolves it. + relay.autoJoin = false; + await _checkStatus(tester); + expect(_checkRow, findsNothing); + expect(_defaultSwitch(tester).value, isFalse); + expect(relay.changes, hasLength(3)); + + // A rejection is definite: nothing is held, and the user can try again. + relay.rejects = true; + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes, hasLength(4)); + expect( + find.text('Couldn’t change channel access. Try again.'), + findsOneWidget, + ); + expect(_checkRow, findsNothing); + expect(_defaultSwitch(tester).onChanged, isNotNull); + + // So is a change made while disconnected, and it works after reconnect. + relay + ..rejects = false + ..applies = true + ..disconnected = true; + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes, hasLength(4)); + expect( + find.text('Couldn’t change channel access. Try again.'), + findsOneWidget, + ); + expect(_checkRow, findsNothing); + relay.disconnected = false; + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes, hasLength(5)); + expect(_checkRow, findsNothing); + expect(_defaultSwitch(tester).value, isTrue); + + // A change lost on the way stays held until a read shows it unapplied; + // then the same signed event can be sent again, and applies once. + relay.drops = true; + await _tapCentred(tester, find.text('Default channel')); + expect(relay.changes, hasLength(6)); + expect(_checkRow, findsOneWidget); + expect(_resendRow, findsNothing); + relay.drops = false; + await _checkStatus(tester); + expect(_checkRow, findsOneWidget); + final signs = relay.signs; + await _tapCentred(tester, _resendRow); + expect(relay.changes, hasLength(7)); + // The exact event, not a fresh signature: one signed later would carry a + // new id the relay would apply again. + expect(relay.ids.last, relay.ids[5]); + expect(relay.signs, signs); + expect(_checkRow, findsNothing); + expect(_resendRow, findsNothing); + expect(_defaultSwitch(tester).value, isFalse); + expect(_defaultSwitch(tester).onChanged, isNotNull); + }); + + testWidgets('a failed community role read explains and retries in place', ( + tester, + ) async { + final relay = _relay(communityRole: 'admin')..rosterFails = true; + await tester.pumpWidget( + _app(relay: relay, channel: _channel(), channelRole: 'member'), + ); + await tester.pumpAndSettle(); + await _scrollTo(tester, _roleRetry); + expect(_defaultSetting, findsNothing); + + relay.rosterFails = false; + await tester.tap(_roleRetry); + await tester.pumpAndSettle(); + expect(_roleRetry, findsNothing); + expect(_defaultSetting, findsOneWidget); + expect(relay.changes, isEmpty); + }); + + testWidgets('shows only the controls each role may use', (tester) async { + for (final (visibility, type, channelRole, communityRole, joinAs, default_) + in const [ + // Channel admin, community member: join role only. + ('open', 'stream', 'admin', 'member', true, false), + // Community admin, channel member: default channel only. + ('open', 'stream', 'member', 'owner', false, true), + // A forum cannot be a default channel. + ('open', 'forum', 'member', 'admin', false, false), + // A private channel has no general access. + ('private', 'stream', 'admin', 'owner', false, false), + // Neither role manages access. + ('open', 'stream', 'member', 'member', false, false), + ]) { + final relay = _relay( + type: type, + visibility: visibility, + communityRole: communityRole, + ); + await tester.pumpWidget( + _app( + relay: relay, + channel: _channel(visibility: visibility, type: type), + channelRole: channelRole, + ), + ); + await tester.pumpAndSettle(); + final reason = '$visibility $type $channelRole $communityRole'; + expect( + _accessCard, + joinAs || default_ ? findsOneWidget : findsNothing, + reason: reason, + ); + expect( + _joinRoleRow, + joinAs ? findsOneWidget : findsNothing, + reason: reason, + ); + expect( + _defaultSetting, + default_ ? findsOneWidget : findsNothing, + reason: reason, + ); + expect(relay.changes, isEmpty, reason: reason); + await tester.pumpWidget(const SizedBox()); + } + }); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index c679a696818..ceded1d1870 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3832,7 +3832,10 @@ void main() { ); expect(_previewRowAvatarInitial(tester, 'self'), 'Y'); - await tester.ensureVisible(seeAllRow); + // Centre the row: aligned to the top it sits under the frosted bar. + unawaited( + Scrollable.ensureVisible(tester.element(seeAllRow), alignment: 0.5), + ); await tester.pumpAndSettle(); await tester.tap(seeAllRow); await tester.pumpAndSettle();