From 0be2c4070ff13864a3eb37793371d37c9abbcbb8 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 13:45:58 -0500 Subject: [PATCH 1/6] mobile: let admins set who joins a channel and default channels Channel details get a General access card on open channels. Channel owners and admins choose whether new people join as Member or Guest; community owners and admins can make an open, unarchived stream a default channel. Each change is one partial kind:9002 confirmed from a fresh kind:39000 readback; an accepted change that does not read back is reported as unconfirmed, never as saved. Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../channels/channel_actions_sheet.dart | 2 + .../channels/channel_admission_card.dart | 172 ++++++++++ .../channels/channel_details_page.dart | 5 + .../channels/channel_management_actions.dart | 46 +++ .../channels/channel_management_provider.dart | 65 ++++ .../community_membership_provider.dart | 6 +- mobile/lib/shared/relay/nostr_models.dart | 13 + .../channel_admission_journey_test.dart | 309 ++++++++++++++++++ .../channels/channel_detail_page_test.dart | 5 +- 9 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/features/channels/channel_admission_card.dart create mode 100644 mobile/test/features/channels/channel_admission_journey_test.dart 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..b559cbe0b80 --- /dev/null +++ b/mobile/lib/features/channels/channel_admission_card.dart @@ -0,0 +1,172 @@ +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. +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 managesDefault = + channel.isStream && + !channel.isArchived && + managesCommunity(ref.watch(currentCommunityRoleProvider).value); + final admissionAsync = ref.watch(channelAdmissionProvider(channel.id)); + final saving = useState(false); + if (!canEditJoinRole && !managesDefault) return const SizedBox.shrink(); + + final admission = admissionAsync.asData?.value; + final ready = admission != null && !admissionAsync.isLoading; + final canToggleDefault = + managesDefault && ready && admission.canBeDefault && !saving.value; + + Future save(Future Function(ChannelActions) change) async { + if (saving.value) return; + saving.value = true; + try { + await change(ref.read(channelActionsProvider)); + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showFeedbackSnackBar( + SnackBar( + content: Text( + error is ChannelChangeUnconfirmed + ? 'Buzz couldn’t confirm the change yet. Check again ' + 'before trying again.' + : 'Couldn’t change channel access. Try again.', + ), + ), + kind: ToastKind.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 (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 && !saving.value + ? const _ChannelDetailsChevron() + : const BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading access settings', + ), + onTap: ready && !saving.value ? 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..871682fe8f1 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -264,6 +264,52 @@ 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, + ], (admission) => admission.joinRole == 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(), + ], (admission) => admission.autoJoin == enabled); + + /// Sends one partial kind:9002 change, then confirms it from fresh relay + /// metadata. An accepted change that does not read back is reported as + /// [ChannelChangeUnconfirmed], never as success, and is not resent. + Future _setAdmission( + String channelId, + List tag, + bool Function(ChannelAdmission admission) applied, + ) async { + _ensureCommunityValid(); + await _signedEventRelay.submit( + kind: 9002, + content: '', + tags: [ + ['h', channelId], + tag, + ], + ); + _ensureCommunityValid(); + try { + final admission = await _fetchChannelAdmission(_session, channelId); + if (!applied(admission)) throw const ChannelChangeUnconfirmed(); + } on ChannelChangeUnconfirmed { + rethrow; + } catch (_) { + throw const ChannelChangeUnconfirmed(); + } finally { + _ref.invalidate(channelAdmissionProvider(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..275fb601152 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -549,6 +549,71 @@ 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, + ); + }); + +/// The relay accepted 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/test/features/channels/channel_admission_journey_test.dart b/mobile/test/features/channels/channel_admission_journey_test.dart new file mode 100644 index 00000000000..e27c5a9ed5b --- /dev/null +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -0,0 +1,309 @@ +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/community/community_membership_provider.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: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 applies accepted kind:9002 +/// admission changes to it, unless told to accept without applying. +class _AdmissionRelay extends RelaySessionNotifier { + _AdmissionRelay({required this.channelType, required this.visibility}); + + final String channelType; + final String visibility; + String joinRole = 'member'; + bool autoJoin = false; + bool applies = true; + final changes = >>[]; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (event.kind == 9002) { + changes.add(event.tags); + 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'; + } + } + } + return event; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!filter.kinds.contains(39000)) return const []; + 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 (autoJoin) ['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 () {}; + } +} + +class _Sections extends ChannelSectionsNotifier { + @override + ChannelSectionsState build() => const ChannelSectionsState( + isReady: true, + store: ChannelSectionStore(), + version: 1, + ); +} + +Widget _app({ + required _AdmissionRelay relay, + required Channel channel, + required String channelRole, + required CommunityMemberRole communityRole, +}) { + final keys = nostr.Keys.generate(); + return ProviderScope( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => relay), + currentPubkeyProvider.overrideWith((ref) => keys.public), + myPubkeyProvider.overrideWithValue(keys.public), + currentCommunityRoleProvider.overrideWithValue( + AsyncValue.data(communityRole), + ), + 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: SignedEventRelay(session: relay, nsec: keys.nsec), + currentPubkey: keys.public, + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: ChannelDetailsPage( + channel: channel, + currentPubkey: keys.public, + onMemberTap: (_, _) {}, + ), + ), + ); +} + +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')); + +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(); +} + +void main() { + testWidgets('admin sets who joins a channel and makes it a default', ( + tester, + ) async { + final relay = _AdmissionRelay(channelType: 'stream', visibility: 'open'); + await tester.pumpWidget( + _app( + relay: relay, + channel: _channel(), + channelRole: 'admin', + communityRole: CommunityMemberRole.admin, + ), + ); + 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 tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); + expect(relay.changes.last, [ + ['h', _channelId], + ['auto_join', 'true'], + ]); + expect(_defaultSwitch(tester).value, isTrue); + expect( + tester.getSemantics(_defaultSetting), + isSemantics(hasToggledState: true, isToggled: 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.descendant(of: _joinRoleRow, matching: find.text('Guest')), + findsOneWidget, + ); + + // An accepted change the relay's metadata does not show is never + // reported as saved; the controls show what the relay holds. + 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(find.textContaining('couldn’t confirm the change'), findsOneWidget); + expect(_defaultSwitch(tester).value, isTrue); + expect(relay.changes, hasLength(3)); + }); + + 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', CommunityMemberRole.member, true, false), + // Community admin, channel member: default channel only. + ('open', 'stream', 'member', CommunityMemberRole.owner, false, true), + // A forum cannot be a default channel. + ('open', 'forum', 'member', CommunityMemberRole.admin, false, false), + // A private channel has no general access. + ( + 'private', + 'stream', + 'admin', + CommunityMemberRole.owner, + false, + false, + ), + // Neither role manages access. + ( + 'open', + 'stream', + 'member', + CommunityMemberRole.member, + false, + false, + ), + ]) { + final relay = _AdmissionRelay(channelType: type, visibility: visibility); + await tester.pumpWidget( + _app( + relay: relay, + channel: _channel(visibility: visibility, type: type), + channelRole: channelRole, + communityRole: communityRole, + ), + ); + 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(); From e00dc271c7f6607030fa69587f307fa3fc58fc1b Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 14:22:23 -0500 Subject: [PATCH 2/6] mobile: hold uncertain channel access changes until confirmed A default-channel or join-role change the relay may have applied without showing it, including one whose acknowledgement was lost, is now held per channel outside the details page. It blocks further changes, survives closing and reopening the page, and resolves only through a read-only status check. Confirming a default-channel change requires an open, unarchived stream, since archived and private metadata keep the stored flag without advertising it. A relay rejection stays a definite failure. A failed community role read now explains why the default channel setting is missing and retries in place. Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../channels/channel_admission_card.dart | 91 +++++-- .../channels/channel_management_actions.dart | 76 ++++-- .../channels/channel_management_provider.dart | 39 ++- mobile/lib/shared/relay/relay_session.dart | 2 +- .../lib/shared/relay/relay_session_types.dart | 10 + .../channel_admission_journey_test.dart | 228 ++++++++++++++---- 6 files changed, 351 insertions(+), 95 deletions(-) diff --git a/mobile/lib/features/channels/channel_admission_card.dart b/mobile/lib/features/channels/channel_admission_card.dart index b559cbe0b80..a8c36611bfd 100644 --- a/mobile/lib/features/channels/channel_admission_card.dart +++ b/mobile/lib/features/channels/channel_admission_card.dart @@ -2,7 +2,9 @@ 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. +/// 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. class _ChannelAdmissionCard extends HookConsumerWidget { const _ChannelAdmissionCard({ required this.channel, @@ -18,18 +20,45 @@ class _ChannelAdmissionCard extends HookConsumerWidget { 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 = - channel.isStream && - !channel.isArchived && - managesCommunity(ref.watch(currentCommunityRoleProvider).value); + 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) return const SizedBox.shrink(); + if (!canEditJoinRole && !managesDefault && !roleUnknown) { + return const SizedBox.shrink(); + } final admission = admissionAsync.asData?.value; - final ready = admission != null && !admissionAsync.isLoading; - final canToggleDefault = - managesDefault && ready && admission.canBeDefault && !saving.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; @@ -37,19 +66,7 @@ class _ChannelAdmissionCard extends HookConsumerWidget { try { await change(ref.read(channelActionsProvider)); } catch (error) { - if (context.mounted) { - ScaffoldMessenger.of(context).showFeedbackSnackBar( - SnackBar( - content: Text( - error is ChannelChangeUnconfirmed - ? 'Buzz couldn’t confirm the change yet. Check again ' - 'before trying again.' - : 'Couldn’t change channel access. Try again.', - ), - ), - kind: ToastKind.error, - ); - } + showError(error); } finally { if (context.mounted) saving.value = false; } @@ -108,6 +125,32 @@ class _ChannelAdmissionCard extends HookConsumerWidget { 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 (admissionAsync.hasError && admission == null) AppListRow( key: const ValueKey('channel-access-retry'), @@ -127,13 +170,15 @@ class _ChannelAdmissionCard extends HookConsumerWidget { 'member' => 'Member', _ => null, }, - trailing: ready && !saving.value + trailing: ready ? const _ChannelDetailsChevron() + : uncertain != null + ? null : const BuzzLoadingIndicator( size: 20, semanticLabel: 'Loading access settings', ), - onTap: ready && !saving.value ? chooseJoinRole : null, + onTap: ready ? chooseJoinRole : null, ), if (managesDefault) MergeSemantics( diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 871682fe8f1..41151224b05 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -267,40 +267,68 @@ class ChannelActions { /// 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, - ], (admission) => admission.joinRole == 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(), - ], (admission) => admission.autoJoin == enabled); + _setAdmission(channelId, ['auto_join', enabled.toString()]); /// Sends one partial kind:9002 change, then confirms it from fresh relay - /// metadata. An accepted change that does not read back is reported as - /// [ChannelChangeUnconfirmed], never as success, and is not resent. - Future _setAdmission( - String channelId, - List tag, - bool Function(ChannelAdmission admission) applied, - ) async { - _ensureCommunityValid(); - await _signedEventRelay.submit( - kind: 9002, - content: '', - tags: [ - ['h', channelId], - tag, - ], + /// metadata. A change the relay may have applied without showing it, or + /// whose acknowledgement was lost, is held as uncertain and reported as + /// [ChannelChangeUnconfirmed]; nothing more 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 sent = 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: (_) { + sent = true; + uncertain.hold(tag); + }, + ); + } on RelayEventRejected { + uncertain.clear(); + rethrow; + } catch (_) { + if (!sent) 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. + Future checkAdmissionChange(String channelId) async { + final uncertain = _ref.read( + uncertainAdmissionChangeProvider(channelId).notifier, + ); + final tag = _ref.read(uncertainAdmissionChangeProvider(channelId)); + if (tag == null) return; + try { + _ensureCommunityValid(); final admission = await _fetchChannelAdmission(_session, channelId); - if (!applied(admission)) throw const ChannelChangeUnconfirmed(); + if (!admissionShows(admission, tag)) { + throw const ChannelChangeUnconfirmed(); + } + uncertain.clear(); } on ChannelChangeUnconfirmed { rethrow; } catch (_) { diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 275fb601152..d58832efdec 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -609,7 +609,44 @@ final channelAdmissionProvider = FutureProvider.autoDispose ); }); -/// The relay accepted a change that its metadata does not show yet. +/// 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. +/// +/// 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 + List? build() { + ref.watch(relayConfigProvider); + return null; + } + + void hold(List tag) => state = tag; + + void clear() => state = null; +} + +final uncertainAdmissionChangeProvider = + NotifierProvider.family?, String>( + UncertainAdmissionChange.new, + ); + +/// The relay may have applied a change that its metadata does not show yet. class ChannelChangeUnconfirmed implements Exception { const ChannelChangeUnconfirmed(); } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 495b62a5fa8..c3771916e09 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -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..f79ad52adb1 100644 --- a/mobile/lib/shared/relay/relay_session_types.dart +++ b/mobile/lib/shared/relay/relay_session_types.dart @@ -21,5 +21,15 @@ 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'; +} + /// 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 index e27c5a9ed5b..2e851578c0b 100644 --- a/mobile/test/features/channels/channel_admission_journey_test.dart +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -1,12 +1,14 @@ +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/community/community_membership_provider.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'; @@ -27,16 +29,28 @@ Channel _channel({String visibility = 'open', String type = 'stream'}) => isMember: true, ); -/// Relay that serves one channel's kind:39000 and applies accepted kind:9002 -/// admission changes to it, unless told to accept without applying. +/// 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}); + _AdmissionRelay({ + required this.channelType, + required this.visibility, + required this.member, + required this.communityRole, + }); final String channelType; - final String visibility; + 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 rosterFails = false; final changes = >>[]; @override @@ -49,22 +63,49 @@ class _AdmissionRelay extends RelaySessionNotifier { }) async { if (event.kind == 9002) { changes.add(event.tags); + 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', @@ -77,7 +118,9 @@ class _AdmissionRelay extends RelaySessionNotifier { ['t', channelType], [visibility == 'open' ? 'public' : 'private'], ['join_role', joinRole], - if (autoJoin) ['auto_join'], + if (archived) ['archived', 'true'], + // Only an eligible channel advertises a stored default flag. + if (autoJoin && eligible && !archived) ['auto_join'], ], content: '', sig: 'sig', @@ -113,22 +156,38 @@ class _Sections extends ChannelSectionsNotifier { ); } +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, - required CommunityMemberRole communityRole, + ValueNotifier? open, }) { - final keys = nostr.Keys.generate(); + 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), - currentCommunityRoleProvider.overrideWithValue( - AsyncValue.data(communityRole), - ), channelMembersProvider(channel.id).overrideWith( (ref) async => [ ChannelMember( @@ -153,11 +212,13 @@ Widget _app({ ], child: MaterialApp( theme: AppTheme.light(), - home: ChannelDetailsPage( - channel: channel, - currentPubkey: keys.public, - onMemberTap: (_, _) {}, - ), + home: open == null + ? page + : ValueListenableBuilder( + valueListenable: open, + builder: (_, isOpen, _) => + isOpen ? page : const Scaffold(body: SizedBox()), + ), ), ); } @@ -165,6 +226,8 @@ Widget _app({ 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 _roleRetry = find.byKey(const ValueKey('channel-community-role-retry')); Switch _defaultSwitch(WidgetTester tester) => tester.widget( find.descendant(of: _defaultSetting, matching: find.byType(Switch)), @@ -179,18 +242,20 @@ Future _scrollTo(WidgetTester tester, Finder finder) async { await tester.pumpAndSettle(); } +Future _checkStatus(WidgetTester tester) async { + await tester.tap(_checkRow); + await tester.pumpAndSettle(); +} + void main() { testWidgets('admin sets who joins a channel and makes it a default', ( tester, ) async { - final relay = _AdmissionRelay(channelType: 'stream', visibility: 'open'); + final relay = _relay(communityRole: 'admin'); + final open = ValueNotifier(true); + addTearDown(open.dispose); await tester.pumpWidget( - _app( - relay: relay, - channel: _channel(), - channelRole: 'admin', - communityRole: CommunityMemberRole.admin, - ), + _app(relay: relay, channel: _channel(), channelRole: 'admin', open: open), ); await tester.pumpAndSettle(); await _scrollTo(tester, _defaultSetting); @@ -218,7 +283,11 @@ void main() { 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'))); @@ -227,13 +296,31 @@ void main() { ['h', _channelId], ['join_role', 'guest'], ]); + expect(find.textContaining('couldn’t confirm the change'), findsOneWidget); + expect(_checkRow, findsOneWidget); + expect(tester.widget(_joinRoleRow).onTap, isNull); + await tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); + // 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; the controls show what the relay holds. + // reported as saved, and survives closing and reopening the page. relay.applies = false; await tester.tap( find.descendant(of: _defaultSetting, matching: find.byType(Switch)), @@ -243,46 +330,95 @@ void main() { ['h', _channelId], ['auto_join', 'false'], ]); - expect(find.textContaining('couldn’t confirm the change'), findsOneWidget); 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 tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); 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 tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); + expect(relay.changes, hasLength(4)); + expect( + find.text('Couldn’t change channel access. Try again.'), + findsOneWidget, + ); + expect(_checkRow, findsNothing); + 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', CommunityMemberRole.member, true, false), + ('open', 'stream', 'admin', 'member', true, false), // Community admin, channel member: default channel only. - ('open', 'stream', 'member', CommunityMemberRole.owner, false, true), + ('open', 'stream', 'member', 'owner', false, true), // A forum cannot be a default channel. - ('open', 'forum', 'member', CommunityMemberRole.admin, false, false), + ('open', 'forum', 'member', 'admin', false, false), // A private channel has no general access. - ( - 'private', - 'stream', - 'admin', - CommunityMemberRole.owner, - false, - false, - ), + ('private', 'stream', 'admin', 'owner', false, false), // Neither role manages access. - ( - 'open', - 'stream', - 'member', - CommunityMemberRole.member, - false, - false, - ), + ('open', 'stream', 'member', 'member', false, false), ]) { - final relay = _AdmissionRelay(channelType: type, visibility: visibility); + final relay = _relay( + type: type, + visibility: visibility, + communityRole: communityRole, + ); await tester.pumpWidget( _app( relay: relay, channel: _channel(visibility: visibility, type: type), channelRole: channelRole, - communityRole: communityRole, ), ); await tester.pumpAndSettle(); From 01d5346960f8f7a77517bc738881b181524bc17e Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 14:29:18 -0500 Subject: [PATCH 3/6] mobile: treat a change made while disconnected as definite The session's publish now throws a typed RelayNotConnected (still a StateError with the same message) when it has no connection and sends nothing. A channel access change that fails this way clears its uncertain hold instead of blocking further changes forever. Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../channels/channel_management_actions.dart | 10 ++++++--- mobile/lib/shared/relay/relay_session.dart | 2 +- .../lib/shared/relay/relay_session_types.dart | 6 +++++ .../channel_admission_journey_test.dart | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 41151224b05..a8bc54d04bd 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -287,7 +287,7 @@ class ChannelActions { throw const ChannelChangeUnconfirmed(); } _ensureCommunityValid(); - var sent = false; + var signed = false; try { await _signedEventRelay.submit( kind: 9002, @@ -298,15 +298,19 @@ class ChannelActions { ], // From here the relay may apply the change even if its answer is lost. onSigned: (_) { - sent = true; + signed = true; uncertain.hold(tag); }, ); } on RelayEventRejected { uncertain.clear(); rethrow; + } on RelayNotConnected { + // Nothing was sent, so nothing can have been applied. + uncertain.clear(); + rethrow; } catch (_) { - if (!sent) rethrow; + if (!signed) rethrow; _ref.invalidate(channelAdmissionProvider(channelId)); throw const ChannelChangeUnconfirmed(); } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index c3771916e09..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(); diff --git a/mobile/lib/shared/relay/relay_session_types.dart b/mobile/lib/shared/relay/relay_session_types.dart index f79ad52adb1..206abddab40 100644 --- a/mobile/lib/shared/relay/relay_session_types.dart +++ b/mobile/lib/shared/relay/relay_session_types.dart @@ -31,5 +31,11 @@ class RelayEventRejected implements Exception { 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 index 2e851578c0b..05bbcfb3acc 100644 --- a/mobile/test/features/channels/channel_admission_journey_test.dart +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -50,6 +50,7 @@ class _AdmissionRelay extends RelaySessionNotifier { bool applies = true; bool losesAck = false; bool rejects = false; + bool disconnected = false; bool rosterFails = false; final changes = >>[]; @@ -62,6 +63,8 @@ class _AdmissionRelay extends RelaySessionNotifier { 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); if (rejects) throw const RelayEventRejected('restricted: not allowed'); if (applies) { @@ -374,6 +377,25 @@ void main() { ); expect(_checkRow, findsNothing); expect(_defaultSwitch(tester).onChanged, isNotNull); + + // So is a change made while disconnected, and it works after reconnect. + relay + ..rejects = false + ..disconnected = true; + await tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); + expect(relay.changes, hasLength(4)); + expect( + find.text('Couldn’t change channel access. Try again.'), + findsOneWidget, + ); + expect(_checkRow, findsNothing); + relay.disconnected = false; + await tester.tap(find.text('Default channel')); + await tester.pumpAndSettle(); + expect(relay.changes, hasLength(5)); + expect(_checkRow, findsNothing); + expect(_defaultSwitch(tester).value, isTrue); }); testWidgets('a failed community role read explains and retries in place', ( From 6fe7ace69b0fef6f6fb7d6eb1240775772c3a093 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 14:33:08 -0500 Subject: [PATCH 4/6] mobile: let the reconnect journey step apply its retry Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../test/features/channels/channel_admission_journey_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/test/features/channels/channel_admission_journey_test.dart b/mobile/test/features/channels/channel_admission_journey_test.dart index 05bbcfb3acc..8ae0973bf50 100644 --- a/mobile/test/features/channels/channel_admission_journey_test.dart +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -381,6 +381,7 @@ void main() { // So is a change made while disconnected, and it works after reconnect. relay ..rejects = false + ..applies = true ..disconnected = true; await tester.tap(find.text('Default channel')); await tester.pumpAndSettle(); From 3e8b55244c775de139a70a317699552077cdedbb Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 14:44:04 -0500 Subject: [PATCH 5/6] mobile: let a held access change the relay never saw be sent again A change whose event was lost before reaching the relay stayed held forever, since no read could ever show it. Once a read shows the held change unapplied, the card offers Send again, which republishes the exact signed event. The relay stores each kind:9002 by id in the same commit that applies it and ignores a duplicate, so this applies the change at most once. Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../channels/channel_admission_card.dart | 15 +++++- .../channels/channel_management_actions.dart | 43 ++++++++++++--- .../channels/channel_management_provider.dart | 45 +++++++++++++--- .../channel_admission_journey_test.dart | 52 ++++++++++++++----- 4 files changed, 127 insertions(+), 28 deletions(-) diff --git a/mobile/lib/features/channels/channel_admission_card.dart b/mobile/lib/features/channels/channel_admission_card.dart index a8c36611bfd..19fa02d6b5e 100644 --- a/mobile/lib/features/channels/channel_admission_card.dart +++ b/mobile/lib/features/channels/channel_admission_card.dart @@ -4,7 +4,8 @@ part of 'channel_actions_sheet.dart'; /// 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. +/// 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, @@ -151,6 +152,18 @@ class _ChannelAdmissionCard extends HookConsumerWidget { (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'), diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index a8bc54d04bd..1e9086663ec 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -277,7 +277,7 @@ class ChannelActions { /// 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]; nothing more is published for the channel + /// [ChannelChangeUnconfirmed]; no other change is published for the channel /// until [checkAdmissionChange] confirms it. Future _setAdmission(String channelId, List tag) async { final uncertain = _ref.read( @@ -297,9 +297,9 @@ class ChannelActions { tag, ], // From here the relay may apply the change even if its answer is lost. - onSigned: (_) { + onSigned: (event) { signed = true; - uncertain.hold(tag); + uncertain.hold(tag, event); }, ); } on RelayEventRejected { @@ -319,17 +319,19 @@ class ChannelActions { /// Reads [channelId]'s metadata, without publishing, to resolve an /// uncertain admission change. Throws [ChannelChangeUnconfirmed] while the - /// relay still does not show it. + /// 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 tag = _ref.read(uncertainAdmissionChangeProvider(channelId)); - if (tag == null) return; + final held = _ref.read(uncertainAdmissionChangeProvider(channelId)); + if (held == null) return; try { _ensureCommunityValid(); final admission = await _fetchChannelAdmission(_session, channelId); - if (!admissionShows(admission, tag)) { + if (!admissionShows(admission, held.tag)) { + uncertain.markUnseen(true); throw const ChannelChangeUnconfirmed(); } uncertain.clear(); @@ -342,6 +344,33 @@ class ChannelActions { } } + /// 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 d58832efdec..ba3e8242f18 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -622,29 +622,62 @@ bool admissionShows(ChannelAdmission admission, List tag) => }; /// 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?> { +class UncertainAdmissionChange extends Notifier { UncertainAdmissionChange(this.channelId); final String channelId; @override - List? build() { + HeldAdmissionChange? build() { ref.watch(relayConfigProvider); return null; } - void hold(List tag) => state = tag; + 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?, String>( - UncertainAdmissionChange.new, - ); + 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 { diff --git a/mobile/test/features/channels/channel_admission_journey_test.dart b/mobile/test/features/channels/channel_admission_journey_test.dart index 8ae0973bf50..c8ee36fe9ce 100644 --- a/mobile/test/features/channels/channel_admission_journey_test.dart +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -51,8 +51,10 @@ class _AdmissionRelay extends RelaySessionNotifier { bool losesAck = false; bool rejects = false; bool disconnected = false; + bool drops = false; bool rosterFails = false; final changes = >>[]; + final ids = []; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -66,6 +68,9 @@ class _AdmissionRelay extends RelaySessionNotifier { // 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) { @@ -230,6 +235,7 @@ 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( @@ -245,11 +251,17 @@ Future _scrollTo(WidgetTester tester, Finder finder) async { await tester.pumpAndSettle(); } -Future _checkStatus(WidgetTester tester) async { - await tester.tap(_checkRow); +/// 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, @@ -275,8 +287,7 @@ void main() { ); // Tapping the row, not only the switch, makes the channel a default. - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + await _tapCentred(tester, find.text('Default channel')); expect(relay.changes.last, [ ['h', _channelId], ['auto_join', 'true'], @@ -302,8 +313,7 @@ void main() { expect(find.textContaining('couldn’t confirm the change'), findsOneWidget); expect(_checkRow, findsOneWidget); expect(tester.widget(_joinRoleRow).onTap, isNull); - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + await _tapCentred(tester, find.text('Default channel')); // The actions refuse to publish too, whoever calls them. final actions = ProviderScope.containerOf( tester.element(_accessCard), @@ -341,8 +351,7 @@ void main() { await _scrollTo(tester, _defaultSetting); expect(_checkRow, findsOneWidget); expect(_defaultSwitch(tester).onChanged, isNull); - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + await _tapCentred(tester, find.text('Default channel')); expect(relay.changes, hasLength(3)); // Archived and private metadata keep the stored flag without showing @@ -368,8 +377,7 @@ void main() { // A rejection is definite: nothing is held, and the user can try again. relay.rejects = true; - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + await _tapCentred(tester, find.text('Default channel')); expect(relay.changes, hasLength(4)); expect( find.text('Couldn’t change channel access. Try again.'), @@ -383,8 +391,7 @@ void main() { ..rejects = false ..applies = true ..disconnected = true; - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + await _tapCentred(tester, find.text('Default channel')); expect(relay.changes, hasLength(4)); expect( find.text('Couldn’t change channel access. Try again.'), @@ -392,11 +399,28 @@ void main() { ); expect(_checkRow, findsNothing); relay.disconnected = false; - await tester.tap(find.text('Default channel')); - await tester.pumpAndSettle(); + 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); + await _tapCentred(tester, _resendRow); + expect(relay.changes, hasLength(7)); + expect(relay.ids.last, relay.ids[5]); + 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', ( From 6b65c3a9e23d1180cb184c8414c792ed3e5f40b9 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 4 Nov 2026 14:48:15 -0500 Subject: [PATCH 6/6] mobile: prove Send again resends the signed event without re-signing Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../channel_admission_journey_test.dart | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/mobile/test/features/channels/channel_admission_journey_test.dart b/mobile/test/features/channels/channel_admission_journey_test.dart index c8ee36fe9ce..002fd22e5e6 100644 --- a/mobile/test/features/channels/channel_admission_journey_test.dart +++ b/mobile/test/features/channels/channel_admission_journey_test.dart @@ -56,6 +56,9 @@ class _AdmissionRelay extends RelaySessionNotifier { 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); @@ -155,6 +158,31 @@ class _AdmissionRelay extends RelaySessionNotifier { } } +/// 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( @@ -213,7 +241,7 @@ Widget _app({ (ref) => ChannelActions( ref: ref, session: relay, - signedEventRelay: SignedEventRelay(session: relay, nsec: keys.nsec), + signedEventRelay: _CountingSigner(relay, keys.nsec), currentPubkey: keys.public, ), ), @@ -414,9 +442,13 @@ void main() { 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);