From 4a3abc61637c8be8f5a15b249c2e76677b16759e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 4 Nov 2026 10:19:59 -0800 Subject: [PATCH] refactor(mobile): drop client-side starter channel setup The relay now joins new members to channels flagged auto_join on invite claim and admission, so mobile no longer creates or joins the `general` and `welcome-everyone` starter channels itself. Remove MobileInviteJoinRecovery, the starter channel id derivation, the persisted Community.starterSetupIncomplete flag (old saved JSON that still carries the key keeps loading), the retry and recovery UI, and the focus on welcome-everyone after joining. A successful claim now goes straight to the success state, and the hosted flow no longer reruns setup for an incomplete community. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- mobile/lib/app.dart | 292 -------- .../channels/deep_link_dispatcher.dart | 28 +- .../invites/invite_join_provider.dart | 180 +---- .../features/invites/invite_join_sheet.dart | 54 +- mobile/lib/hosted_app_gate.dart | 3 +- mobile/lib/main.dart | 5 - mobile/lib/shared/community/community.dart | 10 - .../hosted/hosted_community_connection.dart | 12 +- .../shared/hosted/hosted_flow_provider.dart | 57 +- mobile/lib/shared/push/push_subscription.dart | 2 +- .../channels/channels_provider_test.dart | 49 +- .../channels/deep_link_dispatcher_test.dart | 235 +------ .../invite_active_community_race_test.dart | 14 +- .../invites/invite_fresh_membership_test.dart | 95 --- .../invites/invite_join_provider_test.dart | 652 +----------------- .../invites/invite_join_sheet_test.dart | 24 +- .../invites/invite_relay_wait_test.dart | 119 ---- .../onboarding/hosted_journeys_test.dart | 37 +- .../test/helpers/hosted_journey_fixture.dart | 19 - .../test/shared/community/community_test.dart | 6 +- .../hosted_community_connection_test.dart | 53 +- .../hosted_invite_starter_recovery_test.dart | 197 ------ .../shared/push/push_subscription_test.dart | 20 +- 23 files changed, 149 insertions(+), 2014 deletions(-) delete mode 100644 mobile/test/features/invites/invite_fresh_membership_test.dart delete mode 100644 mobile/test/features/invites/invite_relay_wait_test.dart delete mode 100644 mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 301c35fd301..51d4627254a 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -15,19 +15,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:uuid/uuid.dart'; import 'features/age_gate/age_restriction_page.dart'; import 'features/age_gate/age_signal_provider.dart'; -import 'features/channels/channel.dart'; -import 'features/channels/channel_management_provider.dart'; -import 'features/channels/channels_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/channels/me/me_page.dart'; import 'features/channels/me/me_unread_provider.dart'; import 'features/invites/invite_create_page.dart'; -import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; @@ -49,293 +44,6 @@ import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; import 'shared/widgets/buzz_toast_host.dart'; -const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; - -const _starterChannels = [ - (slug: 'general', description: 'General conversation and community updates.'), - ( - slug: 'welcome-everyone', - description: 'Say hi, ask a question, or share what brought you here.', - ), -]; - -/// Waits for this attempt's community to hold an authenticated connection. -/// -/// Each attempt observes the live session, so a retry never reuses an earlier -/// attempt's failure or a connection that has since been lost. -Future _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) { - final connected = Completer(); - final sub = ref.listen(relaySessionProvider, (_, next) { - if (connected.isCompleted) return; - if (!isScopeCurrent()) { - connected.completeError( - StateError('Active community changed during invite recovery'), - ); - } else if (next.status == SessionStatus.connected) { - connected.complete(); - } - }, fireImmediately: true); - return connected.future - .timeout(const Duration(seconds: 15)) - .whenComplete(sub.close); -} - -/// App-level bridge from invite joining to the channels feature. -class MobileInviteJoinRecovery implements InviteJoinRecovery { - final Future> Function() _loadChannels; - final Future Function(String channelId) _fetchChannel; - final Future Function({ - required String channelId, - required String name, - required String channelType, - required String visibility, - String? description, - int? ttlSeconds, - }) - _createChannel; - final Future Function(String channelId) _joinChannel; - final String _relayHttpOrigin; - final bool Function() _isScopeCurrent; - - /// Creates recovery from channel-loading, single-channel lookup, creation, - /// and join operations. - MobileInviteJoinRecovery({ - required Future> Function() loadChannels, - required Future Function(String channelId) fetchChannel, - required Future Function({ - required String channelId, - required String name, - required String channelType, - required String visibility, - String? description, - int? ttlSeconds, - }) - createChannel, - required Future Function(String channelId) joinChannel, - required String relayHttpOrigin, - bool Function()? isScopeCurrent, - }) : _loadChannels = loadChannels, - _fetchChannel = fetchChannel, - _createChannel = createChannel, - _joinChannel = joinChannel, - _relayHttpOrigin = relayHttpOrigin, - _isScopeCurrent = isScopeCurrent ?? _alwaysCurrent; - - /// Ensures memberships in the same public starter channels as desktop. - /// - /// All starter work is fenced to the community and identity that started it. - /// A partial setup remains recoverable, so a failed operation deliberately - /// propagates to the invite flow instead of being reported as success. The - /// one exception is a starter whose name or id the relay reports as taken - /// by a channel the directory shows cannot serve as the starter, or an - /// archived channel at the starter's own id: both - /// are permanent, so the starter is skipped instead of blocking the join. - @override - Future ensureStarterChannels() async { - _ensureScopeCurrent(); - var channels = await _loadChannels(); - _ensureScopeCurrent(); - String? welcomeEveryoneId; - - for (final starter in _starterChannels) { - _ensureScopeCurrent(); - var channel = _findStarterChannel(channels, starter.slug); - if (channel == null) { - final channelId = desktopStarterChannelId( - relayHttpOrigin: _relayHttpOrigin, - slug: starter.slug, - ); - try { - channel = await _createChannel( - channelId: channelId, - name: starter.slug, - channelType: 'stream', - visibility: 'open', - description: starter.description, - ); - _ensureScopeCurrent(); - } catch (error) { - final nameTaken = _isChannelNameTakenError(error); - final archivedAtId = _isArchivedChannelError(error); - if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) { - rethrow; - } - _ensureScopeCurrent(); - channels = await _loadChannels(); - _ensureScopeCurrent(); - final holder = - _findStarterChannel(channels, starter.slug) ?? - channels - .where((candidate) => candidate.id == channelId) - .firstOrNull ?? - (nameTaken ? _findListedNamesake(channels, starter.slug) : null); - if (holder == null) { - // The relay refuses a create at an archived channel's id before - // its duplicate check, and keeps that id, so the starter can never - // be created there. - if (archivedAtId) continue; - // The relay keeps the id (desktop uses the same one), so a - // duplicate with no match in the reload may be an archived - // starter whose metadata the reload did not return. Read that id - // directly. - if (!nameTaken && _isDuplicateChannelError(error)) { - final atId = await _fetchChannel(channelId); - _ensureScopeCurrent(); - if (atId != null && atId.isArchived) continue; - } - // A holder not visible yet may still be usable, so keep the setup - // recoverable. - rethrow; - } - // The relay keeps a channel's id and a listed channel's name, archived - // ones included, so an unusable holder never frees the starter. Skip - // it rather than strand the invite in permanent recovery. - if (!_isUsableStarter(holder)) continue; - channel = holder; - } - } - - _ensureScopeCurrent(); - if (!channel.isMember) { - await _joinChannel(channel.id); - _ensureScopeCurrent(); - } - if (starter.slug == 'welcome-everyone') { - welcomeEveryoneId = channel.id; - } - } - - return welcomeEveryoneId; - } - - static bool _alwaysCurrent() => true; - - void _ensureScopeCurrent() { - if (!_isScopeCurrent()) { - throw StateError('Active community changed during invite recovery'); - } - } -} - -Channel? _findStarterChannel(List channels, String name) => channels - .where((channel) => _hasName(channel, name) && _isUsableStarter(channel)) - .firstOrNull; - -/// The channel that holds [name] in the relay's listed-name rule: an open, -/// not unlisted stream or forum (`LISTED_CHANNEL_SQL` in buzz-db). -Channel? _findListedNamesake(List channels, String name) => channels - .where( - (channel) => - _hasName(channel, name) && - channel.visibility == 'open' && - !channel.isUnlisted && - (channel.isStream || channel.isForum), - ) - .firstOrNull; - -bool _hasName(Channel channel, String name) => - channel.name.trim().toLowerCase() == name.trim().toLowerCase(); - -bool _isUsableStarter(Channel channel) => - channel.isStream && channel.visibility == 'open' && !channel.isArchived; - -bool _isDuplicateChannelError(Object error) => - error.toString().contains('duplicate: channel already exists'); - -/// The relay refuses any channel event whose `h` is an archived channel, -/// a create at that id included: `invalid: channel is archived`. -bool _isArchivedChannelError(Object error) => - error.toString().contains('invalid: channel is archived'); - -/// The relay refuses creating an open channel whose name another listed -/// channel already holds, ignoring case: -/// `invalid: an open channel named "general" already exists`. -bool _isChannelNameTakenError(Object error) { - final message = error.toString().toLowerCase(); - return message.contains('invalid: an open channel named') && - message.contains('already exists'); -} - -/// Returns desktop's deterministic per-relay UUID for a public starter. -@visibleForTesting -String desktopStarterChannelId({ - required String relayHttpOrigin, - required String slug, -}) { - final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), ''); - return const Uuid().v5( - _starterChannelNamespace, - 'starter-channel:v1:$scope:$slug', - ); -} - -/// Builds one fresh, identity-scoped invite recovery against the app container. -InviteJoinRecovery buildMobileInviteJoinRecovery( - Ref ref, - InviteJoinRecoveryScope scope, -) { - final expectedConfig = ref.read(relayConfigProvider); - if (expectedConfig.baseUrl != scope.relayHttpOrigin || - expectedConfig.nsec != scope.nsec) { - throw StateError('Active community changed before invite recovery'); - } - final channelActions = ref.read(channelActionsProvider); - - bool isScopeCurrent() { - final currentConfig = ref.read(relayConfigProvider); - return currentConfig.baseUrl == scope.relayHttpOrigin && - currentConfig.nsec == scope.nsec; - } - - void ensureScopeCurrent() { - if (!isScopeCurrent()) { - throw StateError('Active community changed during invite recovery'); - } - } - - return MobileInviteJoinRecovery( - loadChannels: () async { - ensureScopeCurrent(); - await ref.read(activeCommunityProvider.future); - ensureScopeCurrent(); - await _waitForInviteRelay(ref, isScopeCurrent); - ensureScopeCurrent(); - final channels = await ref - .read(channelsProvider.notifier) - .fetchFresh(fetchDirectory: true); - ensureScopeCurrent(); - return channels; - }, - fetchChannel: (channelId) async { - ensureScopeCurrent(); - final channel = await ref - .read(channelsProvider.notifier) - .fetchOpenChannel(channelId); - ensureScopeCurrent(); - return channel; - }, - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) => channelActions.createChannel( - channelId: channelId, - name: name, - channelType: channelType, - visibility: visibility, - description: description, - ttlSeconds: ttlSeconds, - ), - joinChannel: channelActions.joinChannel, - relayHttpOrigin: scope.relayHttpOrigin, - isScopeCurrent: isScopeCurrent, - ); -} - final _mobileRootNavigatorKey = GlobalKey(); class App extends HookConsumerWidget { diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 74f7f8037ff..1499ede5e35 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -343,31 +343,9 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); consumed = true; if (!navigatorContext.mounted) return; - final inviteState = ref.read(inviteJoinProvider); - final status = inviteState.status; - if (status == InviteJoinStatus.confirming || - inviteState.isStarterSetupRecovery) { - final sheet = showInviteJoinSheet(navigatorContext); - if (status == InviteJoinStatus.claiming && - inviteState.isStarterSetupRecovery) { - unawaited( - ref.read(inviteJoinProvider.notifier).startStarterSetupRecovery(), - ); - } - final shouldFocusStarter = await sheet; - final focusChannelId = ref.read(inviteJoinProvider).focusChannelId; - if (shouldFocusStarter == true && - focusChannelId != null && - ref.read(pendingDeepLinkProvider) == null && - navigatorContext.mounted) { - final channels = ref.read(channelsProvider).asData?.value; - final channel = channels - ?.where((candidate) => candidate.id == focusChannelId) - .firstOrNull; - if (channel != null) { - _pushChannel(channel, ChannelDeepLink(channelId: focusChannelId)); - } - } + final status = ref.read(inviteJoinProvider).status; + if (status == InviteJoinStatus.confirming) { + await showInviteJoinSheet(navigatorContext); } else if (status == InviteJoinStatus.switchedExisting) { messenger?.showFeedbackSnackBar( const SnackBar(content: Text('Community switched')), diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 7b376397c1a..85b5e1a1f96 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -9,7 +9,6 @@ import '../../shared/hosted/hosted_relay_auth.dart'; import '../../shared/hosted/community_entry_requirements.dart'; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; -import '../../shared/relay/relay_provider.dart'; import '../../shared/relay/relay_session.dart'; import '../../shared/relay/relay_validation.dart'; @@ -34,37 +33,6 @@ typedef InviteKeyGenerator = nostr.Keys Function(); const _unset = Object(); -/// Ensures starter-channel memberships after an invite membership claim. -abstract interface class InviteJoinRecovery { - /// Ensures the public starters and returns the preferred focus. - Future ensureStarterChannels(); -} - -/// The active relay and signing identity a recovery instance must be bound to. -class InviteJoinRecoveryScope { - const InviteJoinRecoveryScope({ - required this.relayHttpOrigin, - required this.nsec, - }); - - /// Canonical HTTP(S) origin used by the active relay session. - final String relayHttpOrigin; - - /// Signing identity that must remain active for the recovery lifetime. - final String? nsec; -} - -/// Constructs a fresh recovery operation for one scoped setup attempt. -typedef InviteJoinRecoveryFactory = - InviteJoinRecovery Function(InviteJoinRecoveryScope scope); - -/// Provides recovery construction after the active community is authenticated. -final inviteJoinRecoveryProvider = Provider((ref) { - throw StateError( - 'inviteJoinRecoveryProvider must be configured by the app root', - ); -}); - enum InviteJoinStatus { idle, confirming, @@ -82,8 +50,6 @@ class InviteJoinState { final String? errorMessage; final bool requiresFreshInvite; final bool requiresPolicyAcceptance; - final bool isStarterSetupRecovery; - final String? focusChannelId; const InviteJoinState({ this.status = InviteJoinStatus.idle, @@ -93,8 +59,6 @@ class InviteJoinState { this.errorMessage, this.requiresFreshInvite = false, this.requiresPolicyAcceptance = false, - this.isStarterSetupRecovery = false, - this.focusChannelId, }); InviteJoinState copyWith({ @@ -105,8 +69,6 @@ class InviteJoinState { Object? errorMessage = _unset, bool? requiresFreshInvite, bool? requiresPolicyAcceptance, - bool? isStarterSetupRecovery, - Object? focusChannelId = _unset, }) => InviteJoinState( status: status ?? this.status, invite: invite ?? this.invite, @@ -118,18 +80,10 @@ class InviteJoinState { requiresFreshInvite: requiresFreshInvite ?? this.requiresFreshInvite, requiresPolicyAcceptance: requiresPolicyAcceptance ?? this.requiresPolicyAcceptance, - isStarterSetupRecovery: - isStarterSetupRecovery ?? this.isStarterSetupRecovery, - focusChannelId: identical(focusChannelId, _unset) - ? this.focusChannelId - : focusChannelId as String?, ); } class InviteJoinNotifier extends Notifier { - Community? _pendingStarterSetupCommunity; - Future? _starterSetupInFlight; - @override InviteJoinState build() => const InviteJoinState(); @@ -142,18 +96,6 @@ class InviteJoinNotifier extends Notifier { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); - if (existing.starterSetupIncomplete) { - _pendingStarterSetupCommunity = existing; - state = InviteJoinState( - status: InviteJoinStatus.claiming, - invite: invite, - host: _hostFromRelay(invite.relayUrl), - communityName: existing.name, - isStarterSetupRecovery: true, - ); - return; - } - _pendingStarterSetupCommunity = null; state = InviteJoinState( status: InviteJoinStatus.switchedExisting, invite: invite, @@ -163,7 +105,6 @@ class InviteJoinNotifier extends Notifier { return; } - _pendingStarterSetupCommunity = null; state = InviteJoinState( status: InviteJoinStatus.confirming, invite: invite, @@ -195,24 +136,9 @@ class InviteJoinNotifier extends Notifier { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); - if (existing.starterSetupIncomplete || state.isStarterSetupRecovery) { - _pendingStarterSetupCommunity = existing; - await startStarterSetupRecovery(); - } else { - state = state.copyWith( - status: InviteJoinStatus.switchedExisting, - communityName: existing.name, - isStarterSetupRecovery: false, - ); - } - return; - } - - if (state.isStarterSetupRecovery) { state = state.copyWith( - status: InviteJoinStatus.error, - errorMessage: - 'This community is no longer available. Re-open the invite link to try again.', + status: InviteJoinStatus.switchedExisting, + communityName: existing.name, ); return; } @@ -270,15 +196,15 @@ class InviteJoinNotifier extends Notifier { pubkey: keys.public, nsec: keys.nsec, sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser, - starterSetupIncomplete: true, ); await ref .read(authProvider.notifier) .authenticateWithCommunity(community); await _retirePendingInvite(invite, community); - _pendingStarterSetupCommunity = community; - state = state.copyWith(isStarterSetupRecovery: true); - await startStarterSetupRecovery(); + state = state.copyWith( + status: InviteJoinStatus.success, + communityName: community.name, + ); } catch (error) { final requiresFreshInvite = _requiresFreshInvite(error); state = state.copyWith( @@ -327,90 +253,7 @@ class InviteJoinNotifier extends Notifier { } } - /// Runs the pending recovery after its progress UI has been presented. - Future startStarterSetupRecovery() async { - final community = _pendingStarterSetupCommunity; - if (community == null || - state.status != InviteJoinStatus.claiming || - !state.isStarterSetupRecovery) { - return; - } - final inFlight = _starterSetupInFlight; - if (inFlight != null) return inFlight; - - final setup = _finishStarterSetup(community); - _starterSetupInFlight = setup; - try { - await setup; - } finally { - if (identical(_starterSetupInFlight, setup)) { - _starterSetupInFlight = null; - } - } - } - - Future _finishStarterSetup(Community community) async { - try { - // Authentication and community switches invalidate the scoped providers. - // Wait for the active community projection to settle before constructing - // the recovery, otherwise it could capture the previous relay's actions. - final activeCommunity = await ref.read(activeCommunityProvider.future); - if (activeCommunity?.id != community.id || - activeCommunity?.relayUrl != community.relayUrl || - activeCommunity?.nsec != community.nsec) { - throw StateError('Active community changed before invite recovery'); - } - final config = RelayConfig( - baseUrl: community.relayUrl, - nsec: community.nsec, - ); - final focusChannelId = await ref - .read(inviteJoinRecoveryProvider)( - InviteJoinRecoveryScope( - relayHttpOrigin: config.baseUrl, - nsec: config.nsec, - ), - ) - .ensureStarterChannels(); - await _saveStarterSetupState(community, incomplete: false); - state = state.copyWith( - status: InviteJoinStatus.success, - communityName: community.name, - isStarterSetupRecovery: false, - focusChannelId: focusChannelId, - ); - } catch (error) { - Object visibleError = error; - try { - await _saveStarterSetupState(community, incomplete: true); - } catch (storageError) { - visibleError = storageError; - } - state = state.copyWith( - status: InviteJoinStatus.error, - errorMessage: _friendlyStarterSetupError(visibleError), - requiresFreshInvite: false, - isStarterSetupRecovery: true, - focusChannelId: null, - ); - } - } - - Future _saveStarterSetupState( - Community community, { - required bool incomplete, - }) { - return ref.read(communityTransitionProvider).runExclusive(() async { - final updated = community.copyWith(starterSetupIncomplete: incomplete); - await ref.read(communityStorageProvider).save(updated); - ref.invalidate(communityListProvider); - ref.invalidate(activeCommunityProvider); - ref.invalidate(authProvider); - }); - } - void reset() { - _pendingStarterSetupCommunity = null; state = const InviteJoinState(); } } @@ -523,14 +366,3 @@ String _friendlyInviteError(Object error) { } return 'Could not join this community: $message'; } - -String _friendlyStarterSetupError(Object error) { - final message = error.toString(); - if (message.contains('SocketException') || - message.contains('Connection refused') || - message.contains('Network is unreachable') || - message.contains('No route to host')) { - return 'Starter channels could not reach the relay. Check your connection and retry setup.'; - } - return 'Starter channels could not be set up. Retry setup to try again.'; -} diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index d88c91c19fd..7431c8d8957 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -14,8 +14,7 @@ Future showInviteJoinSheet(BuildContext context) async { final container = ProviderScope.containerOf(context); final state = container.read(inviteJoinProvider); if (defaultTargetPlatform == TargetPlatform.iOS && - state.status == InviteJoinStatus.confirming && - !state.isStarterSetupRecovery) { + state.status == InviteJoinStatus.confirming) { final joined = await NativeCommunityEntry.show( name: state.host ?? 'Unknown community', dark: Theme.of(context).brightness == Brightness.dark, @@ -55,13 +54,13 @@ Future showInviteJoinSheet(BuildContext context) async { ); if (joined == false) return false; if (!context.mounted) return joined; - // Preserve the existing success screen and starter-channel destination. + // Preserve the existing success screen. } return showBuzzModalBottomSheet( context: context, isScrollControlled: true, // The route remains a normal modal while idle, but its contents install a - // PopScope once a membership claim or starter setup begins. Keep drag and + // PopScope once a membership claim begins. Keep drag and // the shared close affordance out of this sheet so they cannot bypass that // in-flight guard. enableDrag: false, @@ -88,25 +87,16 @@ class InviteJoinSheet extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(inviteJoinProvider); final isClaiming = state.status == InviteJoinStatus.claiming; - final isStarterSetupRecovery = state.isStarterSetupRecovery; final host = state.host ?? 'unknown host'; final derivedName = state.communityName; final primaryLabel = state.requiresPolicyAcceptance ? 'Review requirements' - : switch ((isClaiming, isStarterSetupRecovery, state.status)) { - (true, true, _) => 'Finishing setup…', - (true, false, _) => 'Joining…', - (false, true, InviteJoinStatus.error) => 'Retry setup', - (false, true, _) => 'Finish setting up', - _ => 'Join', - }; + : isClaiming + ? 'Joining…' + : 'Join'; if (state.status == InviteJoinStatus.success) { - return _InviteJoinSuccess( - host: host, - communityName: derivedName, - hasFocusChannel: state.focusChannelId != null, - ); + return _InviteJoinSuccess(host: host, communityName: derivedName); } return SafeArea( @@ -119,9 +109,7 @@ class InviteJoinSheet extends ConsumerWidget { Icon(BuzzIcons.userPlus, size: 40, color: context.colors.primary), const SizedBox(height: Grid.sm), Text( - isStarterSetupRecovery - ? 'Finish setting up' - : 'Join this Buzz community?', + 'Join this Buzz community?', style: context.textTheme.titleLarge, ), const SizedBox(height: Grid.xxs), @@ -159,15 +147,6 @@ class InviteJoinSheet extends ConsumerWidget { ), ], const SizedBox(height: Grid.sm), - if (isStarterSetupRecovery) ...[ - Text( - 'Your membership is ready, but starter channels still need to be set up. You can safely retry without claiming the invite again.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.sm), - ], Text( 'This phone is the only copy of this identity. If you lose it before pairing or backing up, you’ll lose access as this member.', style: context.textTheme.bodyMedium?.copyWith( @@ -224,9 +203,7 @@ class InviteJoinSheet extends ConsumerWidget { height: 16, child: BuzzLoadingIndicator( size: 16, - semanticLabel: isStarterSetupRecovery - ? 'Finishing setup' - : 'Joining community', + semanticLabel: 'Joining community', ), ) : const Icon(BuzzIcons.check), @@ -245,13 +222,8 @@ class InviteJoinSheet extends ConsumerWidget { class _InviteJoinSuccess extends StatelessWidget { final String host; final String? communityName; - final bool hasFocusChannel; - const _InviteJoinSuccess({ - required this.host, - required this.hasFocusChannel, - this.communityName, - }); + const _InviteJoinSuccess({required this.host, this.communityName}); @override Widget build(BuildContext context) { @@ -294,10 +266,8 @@ class _InviteJoinSuccess extends StatelessWidget { ), const SizedBox(height: Grid.xs), TextButton( - onPressed: () => Navigator.of(context).pop(hasFocusChannel), - child: Text( - hasFocusChannel ? 'Continue to #welcome-everyone' : 'Not now', - ), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Not now'), ), ], ), diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 25f750d453a..f9f534183c6 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -82,8 +82,7 @@ class HostedAppGate extends HookConsumerWidget { (item) => relayIdentity(item.relayUrl) == relayIdentity(relay), ) .firstOrNull; - if (existing != null && - !(existing.starterSetupIncomplete && requiresAuth)) { + if (existing != null) { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c771ef56b8b..ad20c06a130 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,7 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'features/age_gate/age_signal_push_bootstrap.dart'; import 'features/agents/agents_page.dart'; -import 'features/invites/invite_join_provider.dart'; import 'shared/agents/agents_surface.dart'; import 'shared/push/push_bridge.dart'; import 'shared/relay/relay_closed_policy.dart'; @@ -41,10 +40,6 @@ ProviderScope buildRootProviderScope({ (context, link) async => unawaited(showAgents(context, draft: link)), ), savedPrefsProvider.overrideWithValue(prefs), - inviteJoinRecoveryProvider.overrideWith( - (ref) => - (scope) => buildMobileInviteJoinRecovery(ref, scope), - ), ], child: AgeSignalPushBootstrap(child: child), ); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index a992c282285..9a0d9e4a41c 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -46,8 +46,6 @@ class Community { /// installation id so their already-published lease remains addressable. final String? pushLeaseInstallationId; - /// Whether invite-created starter channels still need to be recovered. - final bool starterSetupIncomplete; final DateTime addedAt; const Community({ @@ -60,7 +58,6 @@ class Community { this.pushNotificationsEnabled = _defaultPushNotificationsEnabled, this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), this.pushLeaseInstallationId, - this.starterSetupIncomplete = false, required this.addedAt, }) : _name = name; @@ -71,7 +68,6 @@ class Community { String? nsec, SensitiveActionPolicy sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, - bool starterSetupIncomplete = false, }) { return Community( id: _uuid.v4(), @@ -81,7 +77,6 @@ class Community { nsec: nsec, sensitiveActionPolicy: sensitiveActionPolicy, pushLeaseInstallationId: _newPushLeaseInstallationId(), - starterSetupIncomplete: starterSetupIncomplete, addedAt: DateTime.now(), ); } @@ -95,7 +90,6 @@ class Community { bool? pushNotificationsEnabled, BuzzPushLeaseSubscriptionState? pushSubscriptionState, Object? pushLeaseInstallationId = _sentinel, - bool? starterSetupIncomplete, }) { return Community( id: id, @@ -112,8 +106,6 @@ class Community { pushLeaseInstallationId: pushLeaseInstallationId == _sentinel ? this.pushLeaseInstallationId : pushLeaseInstallationId as String?, - starterSetupIncomplete: - starterSetupIncomplete ?? this.starterSetupIncomplete, addedAt: addedAt, ); } @@ -129,7 +121,6 @@ class Community { 'pushSubscriptionState': pushSubscriptionState.toJson(), if (pushLeaseInstallationId != null) 'pushLeaseInstallationId': pushLeaseInstallationId, - 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), }; @@ -167,7 +158,6 @@ class Community { pushNotificationsEnabled: pushNotificationsEnabled, pushSubscriptionState: pushSubscriptionState, pushLeaseInstallationId: pushLeaseInstallationId, - starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, addedAt: DateTime.parse(json['addedAt'] as String), ); } diff --git a/mobile/lib/shared/hosted/hosted_community_connection.dart b/mobile/lib/shared/hosted/hosted_community_connection.dart index e61fdf793b2..8d27f4ff9d0 100644 --- a/mobile/lib/shared/hosted/hosted_community_connection.dart +++ b/mobile/lib/shared/hosted/hosted_community_connection.dart @@ -30,8 +30,6 @@ Future connectHostedCommunity({ required CommunityStorage storage, required HostedAdmission admission, required Future Function(Community, bool Function()) persist, - required Future Function(Community, bool Function()) - recoverStarterChannels, }) async { final relay = link.relayUrl ?? (throw AccountFailure.invalidResponse); final communities = await storage.loadAll(); @@ -42,8 +40,8 @@ Future connectHostedCommunity({ throw const SavedCommunityIdentityConflict(); } if (!isCurrent()) return; - // Saved membership survives a partially completed invite setup. Revalidate - // identity and probe the relay, but never reclaim its consumed invite. + // A saved membership is reused. Revalidate identity and probe the relay, but + // never reclaim its consumed invite. final admissionLink = existing != null && link is InviteDeepLink ? AddCommunityDeepLink(relayUrl: relay, name: existing.name) : link; @@ -54,12 +52,8 @@ Future connectHostedCommunity({ isCurrent: isCurrent, ); final community = existing == null - ? prepared.copyWith(starterSetupIncomplete: link is InviteDeepLink) + ? prepared : existing.copyWith(nsec: prepared.nsec); if (!isCurrent()) return; await persist(community, isCurrent); - if (!isCurrent()) return; - if (community.starterSetupIncomplete) { - await recoverStarterChannels(community, isCurrent); - } } diff --git a/mobile/lib/shared/hosted/hosted_flow_provider.dart b/mobile/lib/shared/hosted/hosted_flow_provider.dart index d91f6223253..94aaf7c9da4 100644 --- a/mobile/lib/shared/hosted/hosted_flow_provider.dart +++ b/mobile/lib/shared/hosted/hosted_flow_provider.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../features/invites/invite_join_provider.dart'; import '../../features/profile/profile_provider.dart'; -import '../relay/relay_provider.dart'; import '../relay/relay_session.dart'; import '../auth/auth.dart'; import '../deeplink/deep_link.dart'; @@ -135,11 +133,8 @@ HostedFlow createHostedFlow( ), shouldContinue: current, activate: - community.starterSetupIncomplete || - (flow.addedCommunityCount == 0 && !flow.restoringAfterAdd), + flow.addedCommunityCount == 0 && !flow.restoringAfterAdd, ), - recoverStarterChannels: (community, current) => - finishHostedStarterSetup(ref, community, current), ); if (flow.addedCommunityCount == 0 && !flow.restoringAfterAdd && @@ -222,53 +217,3 @@ Future offerHostedProfile( subscription.close(); } } - -/// Completes the existing scoped invite recovery only after activation. The -/// pending flag remains durable on failure, cancellation or a community switch. -Future finishHostedStarterSetup( - Ref ref, - Community community, - bool Function() isCurrent, -) async { - void checkCurrent() { - if (!ref.mounted || !isCurrent()) throw AccountFailure.unavailable; - } - - bool matches(Community? candidate) => - candidate?.id == community.id && - candidate?.relayUrl == community.relayUrl && - candidate?.pubkey == community.pubkey && - candidate?.nsec == community.nsec; - - checkCurrent(); - final active = await ref.read(activeCommunityProvider.future); - checkCurrent(); - if (!matches(active)) throw AccountFailure.unavailable; - final config = RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec); - await ref - .read(inviteJoinRecoveryProvider)( - InviteJoinRecoveryScope( - relayHttpOrigin: config.baseUrl, - nsec: config.nsec, - ), - ) - .ensureStarterChannels(); - checkCurrent(); - await ref.read(communityTransitionProvider).runExclusive(() async { - checkCurrent(); - final storage = ref.read(communityStorageProvider); - final saved = (await storage.loadAll()) - .where((item) => item.id == community.id) - .firstOrNull; - final activeId = await storage.loadActiveId(); - checkCurrent(); - if (!matches(saved) || activeId != community.id) { - throw AccountFailure.unavailable; - } - await storage.save(saved!.copyWith(starterSetupIncomplete: false)); - await syncStoredCommunitySnapshot(ref); - ref.invalidate(communityListProvider); - ref.invalidate(activeCommunityProvider); - ref.invalidate(authProvider); - }); -} diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 788a1d3ab20..4b2e21ee8df 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -15,7 +15,7 @@ const buzzPushHellthreadParticipantLimit = 20; const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); -// Channel IDs use canonical UUID text; starter channels are UUIDv5. +// Channel IDs use canonical UUID text. // Match the relay's UUID parser without imposing a version or variant. final _channelIdPattern = RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 28834132767..c535ce28f25 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1801,31 +1801,34 @@ void main() { ); }); - test('directory-aware refresh discovers open starter channels', () async { - final session = _FakeRelaySession( - memberships: const [], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'welcome-everyone'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); + test( + 'directory-aware refresh discovers open channels the user has not joined', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'welcome-everyone'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); - expect(await container.read(channelsProvider.future), isEmpty); + expect(await container.read(channelsProvider.future), isEmpty); - await container - .read(channelsProvider.notifier) - .refresh(fetchDirectory: true); + await container + .read(channelsProvider.notifier) + .refresh(fetchDirectory: true); - final channels = container.read(channelsProvider).requireValue; - expect(channels.map((channel) => channel.name), [ - 'general', - 'welcome-everyone', - ]); - expect(channels.every((channel) => channel.isMember), isFalse); - expect(session.directoryQueryFilters, isNotEmpty); - }); + final channels = container.read(channelsProvider).requireValue; + expect(channels.map((channel) => channel.name), [ + 'general', + 'welcome-everyone', + ]); + expect(channels.every((channel) => channel.isMember), isFalse); + expect(session.directoryQueryFilters, isNotEmpty); + }, + ); test('deduplicates joined channels from directory discovery', () async { final session = _FakeRelaySession( @@ -2884,7 +2887,7 @@ void main() { final active = channels.firstWhere((c) => c.name == 'active'); expect(active.isArchived, isFalse); // The bare `["unlisted"]` flag: an unlisted namesake does not hold a - // name in the relay's listed-name rule (starter setup relies on it). + // name in the relay's listed-name rule. expect(active.isUnlisted, isTrue); expect(expired.isUnlisted, isFalse); }, diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index b213b925ffd..4ba992ceaf3 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -10,7 +10,6 @@ import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:nostr/nostr.dart' as nostr; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_storage_test.dart'; @@ -736,173 +735,6 @@ void main() { expect(find.text('Join this Buzz community?'), findsOneWidget); }); - testWidgets('opens retry setup after durable starter recovery fails', ( - tester, - ) async { - const link = InviteDeepLink( - relayUrl: 'wss://relay.example.com', - code: 'invite-code', - ); - final container = ProviderContainer( - overrides: [ - pendingDeepLinkProvider.overrideWith( - () => _FakePendingDeepLinkNotifier(link), - ), - inviteJoinProvider.overrideWith( - _FailedStarterRecoveryInviteJoinNotifier.new, - ), - ], - ); - addTearDown(container.dispose); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const MaterialApp( - home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Finish setting up'), findsOneWidget); - expect(find.text('Retry setup'), findsOneWidget); - expect(find.text('Starter setup failed'), findsOneWidget); - }); - - testWidgets( - 'shows saved starter recovery progress, then opens welcome-everyone', - (tester) async { - const relayUrl = 'wss://relay.example.com'; - const welcomeId = 'welcome-everyone-id'; - final storage = CommunityStorage(secure: FakeSecureStorage()); - await storage.save( - Community( - id: 'incomplete-community', - name: 'Relay', - relayUrl: relayUrl, - pubkey: 'pubkey', - nsec: nostr.Keys.generate().nsec, - addedAt: DateTime.utc(2026), - starterSetupIncomplete: true, - ), - ); - final recovery = _DeferredInviteJoinRecovery(); - final container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - pendingDeepLinkProvider.overrideWith( - () => _FakePendingDeepLinkNotifier( - const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'), - ), - ), - inviteJoinRecoveryProvider.overrideWithValue((_) => recovery), - channelsProvider.overrideWith( - () => - _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])), - ), - ], - ); - addTearDown(container.dispose); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: MaterialApp( - home: DeepLinkDispatcher( - destinationBuilder: (channel, link) => - _CapturedDestination(channel: channel, link: link), - child: const Scaffold(body: SizedBox()), - ), - ), - ), - ); - await tester.pump(); - await tester.pump(); - expect(recovery.started.isCompleted, isTrue); - - expect(find.text('Finish setting up'), findsOneWidget); - expect(find.text('Finishing setup…'), findsOneWidget); - expect( - tester - .widget( - find.widgetWithText(FilledButton, 'Finishing setup…'), - ) - .onPressed, - isNull, - ); - - recovery.complete(welcomeId); - await tester.pumpAndSettle(); - - expect(find.text('Continue to #welcome-everyone'), findsOneWidget); - await tester.tap(find.text('Continue to #welcome-everyone')); - await tester.pumpAndSettle(); - final destination = tester.widget<_CapturedDestination>( - find.byType(_CapturedDestination), - ); - expect(destination.link, const ChannelDeepLink(channelId: welcomeId)); - }, - ); - - testWidgets('renders setup-specific recovery failures after membership', ( - tester, - ) async { - const relayUrl = 'wss://relay.example.com'; - final storage = CommunityStorage(secure: FakeSecureStorage()); - await storage.save( - Community( - id: 'incomplete-community', - name: 'Relay', - relayUrl: relayUrl, - pubkey: 'pubkey', - nsec: nostr.Keys.generate().nsec, - addedAt: DateTime.utc(2026), - starterSetupIncomplete: true, - ), - ); - final recovery = _DeferredInviteJoinRecovery(); - final container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - pendingDeepLinkProvider.overrideWith( - () => _FakePendingDeepLinkNotifier( - const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'), - ), - ), - inviteJoinRecoveryProvider.overrideWithValue((_) => recovery), - channelsProvider.overrideWith( - () => _FakeChannelsNotifier(Future.value(const [])), - ), - ], - ); - addTearDown(container.dispose); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const MaterialApp( - home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), - ), - ), - ); - await tester.pump(); - await tester.pump(); - expect(recovery.started.isCompleted, isTrue); - - recovery.completeError(Exception('relay did not respond')); - await tester.pumpAndSettle(); - - expect( - find.text( - 'Starter channels could not be set up. Retry setup to try again.', - ), - findsOneWidget, - ); - expect(find.text('Retry setup'), findsOneWidget); - expect(find.textContaining('Could not join this community'), findsNothing); - }); - testWidgets('waits for an invite modal before preparing the next invite', ( tester, ) async { @@ -1066,7 +898,7 @@ void main() { }, ); - testWidgets('continues a successful invite into welcome-everyone', ( + testWidgets('a successful invite ends on the success sheet, not a channel', ( tester, ) async { const invite = InviteDeepLink( @@ -1079,7 +911,7 @@ void main() { () => _FakePendingDeepLinkNotifier(invite), ), channelsProvider.overrideWith( - () => _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])), + () => _FakeChannelsNotifier(Future.value([_channel])), ), inviteJoinProvider.overrideWith(_SuccessfulInviteJoinNotifier.new), ], @@ -1102,19 +934,14 @@ void main() { await tester.tap(find.widgetWithText(FilledButton, 'Join')); await tester.pumpAndSettle(); - expect(find.text('Continue to #welcome-everyone'), findsOneWidget); + expect(find.text('You joined Example'), findsOneWidget); + expect(find.textContaining('welcome-everyone'), findsNothing); - await tester.tap(find.text('Continue to #welcome-everyone')); + await tester.tap(find.text('Not now')); await tester.pumpAndSettle(); - final destination = tester.widget<_CapturedDestination>( - find.byType(_CapturedDestination), - ); - expect(destination.channel.id, 'welcome-everyone-id'); - expect( - destination.link, - const ChannelDeepLink(channelId: 'welcome-everyone-id'), - ); + expect(find.text('You joined Example'), findsNothing); + expect(find.byType(_CapturedDestination), findsNothing); }); } @@ -1143,17 +970,6 @@ final _notificationCommunity = Community( relayUrl: 'wss://notification.example', addedAt: DateTime(2026), ); -final _welcomeEveryoneChannel = Channel( - id: 'welcome-everyone-id', - name: 'welcome-everyone', - channelType: 'stream', - visibility: 'open', - description: 'Say hi', - createdBy: 'creator', - createdAt: DateTime(2026), - memberCount: 1, - isMember: true, -); class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier { @override @@ -1171,27 +987,7 @@ class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier { @override Future confirmJoin() async { - state = state.copyWith( - status: InviteJoinStatus.success, - focusChannelId: 'welcome-everyone-id', - ); - } -} - -class _FailedStarterRecoveryInviteJoinNotifier extends InviteJoinNotifier { - @override - InviteJoinState build() => const InviteJoinState(); - - @override - Future prepare(InviteDeepLink invite) async { - state = InviteJoinState( - status: InviteJoinStatus.error, - invite: invite, - host: 'relay.example.com', - communityName: 'Relay', - errorMessage: 'Starter setup failed', - isStarterSetupRecovery: true, - ); + state = state.copyWith(status: InviteJoinStatus.success); } } @@ -1346,21 +1142,6 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future> build() => channels; } -class _DeferredInviteJoinRecovery implements InviteJoinRecovery { - final Completer _result = Completer(); - final Completer started = Completer(); - - @override - Future ensureStarterChannels() { - started.complete(); - return _result.future; - } - - void complete(String? focusChannelId) => _result.complete(focusChannelId); - - void completeError(Object error) => _result.completeError(error); -} - class _CapturedDestination extends StatelessWidget { const _CapturedDestination({required this.channel, required this.link}); diff --git a/mobile/test/features/invites/invite_active_community_race_test.dart b/mobile/test/features/invites/invite_active_community_race_test.dart index b870788a6dd..e439ca641d3 100644 --- a/mobile/test/features/invites/invite_active_community_race_test.dart +++ b/mobile/test/features/invites/invite_active_community_race_test.dart @@ -13,7 +13,7 @@ import '../../shared/community/community_storage_test.dart'; void main() { for (final finishReadBeforeJoin in [false, true]) { - test('first invite setup succeeds when old active read finishes ' + test('first invite claim succeeds when old active read finishes ' '${finishReadBeforeJoin ? 'before' : 'after'} new selection', () async { final storage = _PausedActiveReadStorage(); final old = Community.create( @@ -23,7 +23,6 @@ void main() { ); await storage.save(old); await storage.saveActiveId(old.id); - var recoveryCalls = 0; final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), @@ -36,10 +35,6 @@ void main() { ), ), ), - inviteJoinRecoveryProvider.overrideWithValue((_) { - recoveryCalls++; - return _SuccessfulRecovery(); - }), ], ); addTearDown(container.dispose); @@ -65,11 +60,9 @@ void main() { final result = container.read(inviteJoinProvider); expect(result.status, InviteJoinStatus.success); expect(result.errorMessage, isNull); - expect(recoveryCalls, 1); final communities = await storage.loadAll(); final joined = communities.singleWhere((c) => c.id != old.id); expect(await storage.loadActiveId(), joined.id); - expect(joined.starterSetupIncomplete, isFalse); expect( (await container.read(activeCommunityProvider.future))?.id, joined.id, @@ -78,11 +71,6 @@ void main() { } } -class _SuccessfulRecovery implements InviteJoinRecovery { - @override - Future ensureStarterChannels() async => 'welcome'; -} - /// Delays the storage read after the provider captures its community list. /// All reads and writes succeed; only their ordering changes. class _PausedActiveReadStorage extends CommunityStorage { diff --git a/mobile/test/features/invites/invite_fresh_membership_test.dart b/mobile/test/features/invites/invite_fresh_membership_test.dart deleted file mode 100644 index 50752bf45ff..00000000000 --- a/mobile/test/features/invites/invite_fresh_membership_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:buzz/app.dart'; -import 'package:buzz/features/channels/channel.dart'; -import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/invites/invite_join_provider.dart'; -import 'package:buzz/shared/community/community.dart'; -import 'package:buzz/shared/community/community_provider.dart'; -import 'package:buzz/shared/relay/relay.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; - -void main() { - test( - 'invite recovery propagates fresh-read failure despite cached memberships', - () async { - final keys = nostr.Keys.generate(); - final scope = InviteJoinRecoveryScope( - relayHttpOrigin: 'https://relay.example', - nsec: keys.nsec, - ); - final channels = _CachedChannels(); - final recovery = Provider( - (ref) => buildMobileInviteJoinRecovery(ref, scope), - ); - final container = ProviderContainer( - overrides: [ - relayConfigProvider.overrideWith(() => _Config(keys.nsec)), - relaySessionProvider.overrideWith(_Connected.new), - activeCommunityProvider.overrideWith( - (ref) async => Community.create( - name: 'Test', - relayUrl: scope.relayHttpOrigin, - nsec: keys.nsec, - ), - ), - channelsProvider.overrideWith(() => channels), - ], - ); - addTearDown(container.dispose); - await container.read(channelsProvider.future); - await expectLater( - container.read(recovery).ensureStarterChannels(), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'membership fetch failed', - ), - ), - ); - expect(channels.freshReads, 1); - expect(container.read(channelsProvider).requireValue, hasLength(2)); - }, - ); -} - -class _CachedChannels extends ChannelsNotifier { - int freshReads = 0; - @override - Future> build() async => [ - for (final name in ['general', 'welcome-everyone']) - Channel( - id: name, - name: name, - channelType: 'stream', - visibility: 'open', - description: '', - createdBy: 'me', - createdAt: DateTime.utc(2026), - memberCount: 1, - isMember: true, - ), - ]; - // A best-effort refresh can retain stale data without failing its caller. - @override - Future refresh({bool fetchDirectory = false}) async {} - @override - Future> fetchFresh({bool fetchDirectory = false}) async { - freshReads++; - throw StateError('membership fetch failed'); - } -} - -class _Config extends RelayConfigNotifier { - _Config(this.nsec); - final String nsec; - @override - RelayConfig build() => - RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); -} - -class _Connected extends RelaySessionNotifier { - @override - SessionState build() => const SessionState(status: SessionStatus.connected); -} diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 959ff296d0f..74bb3415cd8 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -1,5 +1,4 @@ import 'package:buzz/shared/hosted/hosted_environment.dart'; -import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -9,8 +8,6 @@ import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; -import 'package:buzz/app.dart'; -import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; @@ -133,9 +130,6 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { capturedRequest = request; @@ -171,7 +165,6 @@ void main() { final state = container.read(inviteJoinProvider); expect(state.status, InviteJoinStatus.success); - expect(state.focusChannelId, 'welcome-everyone-id'); expect(capturedRequest, isNotNull); expect( capturedRequest!.url.toString(), @@ -227,567 +220,6 @@ void main() { expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); }); - test('starter channel ids match desktop for the same relay scope', () async { - expect( - desktopStarterChannelId( - relayHttpOrigin: 'https://relay.example.com/', - slug: 'general', - ), - '9ed9563a-84d6-586d-8007-ae294a6dfdaf', - ); - expect( - desktopStarterChannelId( - relayHttpOrigin: 'https://relay.example.com', - slug: 'welcome-everyone', - ), - '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a', - ); - }); - - test( - 'invite recovery joins existing public general and welcome-everyone', - () async { - final joined = []; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async => [ - _channel(id: 'general-id', name: ' General '), - _channel(id: 'welcome-id', name: 'WELCOME-EVERYONE'), - _channel( - id: 'private-welcome-id', - name: 'Welcome', - visibility: 'private', - ), - ], - fetchChannel: (_) async => fail('no direct lookup expected'), - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => throw StateError('starters already exist'), - joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: 'https://relay.example.com', - ); - - final focusChannelId = await recovery.ensureStarterChannels(); - - expect(joined, ['general-id', 'welcome-id']); - expect(focusChannelId, 'welcome-id'); - }, - ); - - test( - 'invite recovery creates missing public starters with desktop ids', - () async { - final created = >{}; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async => const [], - fetchChannel: (_) async => fail('no direct lookup expected'), - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async { - created[name] = { - 'id': channelId, - 'channelType': channelType, - 'visibility': visibility, - 'description': description, - 'ttlSeconds': ttlSeconds, - }; - return _channel(id: channelId, name: name, isMember: true); - }, - joinChannel: (_) async => fail('creator is already a member'), - relayHttpOrigin: 'https://relay.example.com/', - ); - - final focusChannelId = await recovery.ensureStarterChannels(); - - expect(created.keys, ['general', 'welcome-everyone']); - expect(created['general'], { - 'id': '9ed9563a-84d6-586d-8007-ae294a6dfdaf', - 'channelType': 'stream', - 'visibility': 'open', - 'description': 'General conversation and community updates.', - 'ttlSeconds': null, - }); - expect(created['welcome-everyone'], { - 'id': '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a', - 'channelType': 'stream', - 'visibility': 'open', - 'description': - 'Say hi, ask a question, or share what brought you here.', - 'ttlSeconds': null, - }); - expect(focusChannelId, '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a'); - }, - ); - - test( - 'duplicate starter creation converges and joins relay channels', - () async { - var loadCount = 0; - final joined = []; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async { - loadCount++; - if (loadCount == 1) return const []; - return [ - _channel(id: 'relay-general', name: 'general'), - _channel(id: 'relay-welcome', name: 'welcome-everyone'), - ]; - }, - fetchChannel: (_) async => fail('no direct lookup expected'), - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => throw Exception( - 'relay rejected event: duplicate: channel already exists', - ), - joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: 'https://relay.example.com', - ); - - final focusChannelId = await recovery.ensureStarterChannels(); - - expect(loadCount, 2); - expect(joined, ['relay-general', 'relay-welcome']); - expect(focusChannelId, 'relay-welcome'); - }, - ); - - test( - 'taken starter names are skipped or converged instead of failing the invite', - () async { - const origin = 'https://relay.example.com'; - const refusal = 'invalid: an open channel named "general" already exists'; - const duplicateId = 'duplicate: channel already exists'; - final generalId = desktopStarterChannelId( - relayHttpOrigin: origin, - slug: 'general', - ); - final welcomeId = desktopStarterChannelId( - relayHttpOrigin: origin, - slug: 'welcome-everyone', - ); - ({ - List created, - List joined, - List lookups, - MobileInviteJoinRecovery recovery, - int Function() loads, - }) - starterSetup({ - required List Function(int load) channelsOnLoad, - required String refusalError, - Channel? atId, - Set refused = const {'general'}, - bool Function()? isScopeCurrent, - void Function()? onLookup, - }) { - final created = []; - final joined = []; - final lookups = []; - var loadCount = 0; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async => channelsOnLoad(++loadCount), - fetchChannel: (channelId) async { - lookups.add(channelId); - onLookup?.call(); - return atId; - }, - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async { - created.add(name); - if (refused.contains(name)) { - throw Exception(refusalError); - } - return _channel(id: channelId, name: name, isMember: true); - }, - joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: origin, - isScopeCurrent: isScopeCurrent, - ); - return ( - created: created, - joined: joined, - lookups: lookups, - recovery: recovery, - loads: () => loadCount, - ); - } - - // A holder the directory shows cannot serve as the starter is neither - // joined nor retried; the other starter is still created and focused. - final unusableHolders = [ - ( - _channel( - id: 'old-general', - name: 'General', - archivedAt: DateTime.utc(2025), - ), - refusal, - ), - ( - _channel(id: 'forum-general', name: 'general', channelType: 'forum'), - refusal, - ), - ( - _channel( - id: generalId, - name: 'general', - archivedAt: DateTime.utc(2025), - ), - duplicateId, - ), - ]; - for (final (holder, error) in unusableHolders) { - final setup = starterSetup( - channelsOnLoad: (_) => [holder], - refusalError: error, - ); - - final focusChannelId = await setup.recovery.ensureStarterChannels(); - - expect(setup.created, [ - 'general', - 'welcome-everyone', - ], reason: holder.id); - expect(setup.joined, isEmpty, reason: holder.id); - expect(setup.loads(), 2, reason: holder.id); - expect(focusChannelId, welcomeId, reason: holder.id); - } - - // An archived channel at the starter's own id, which the directory does - // not return, is found by a direct lookup and skipped, not rethrown. - final archivedAtId = starterSetup( - channelsOnLoad: (_) => const [], - refusalError: duplicateId, - atId: _channel( - id: generalId, - name: 'general', - archivedAt: DateTime.utc(2025), - ), - ); - final focusAfterArchived = await archivedAtId.recovery - .ensureStarterChannels(); - expect(archivedAtId.created, ['general', 'welcome-everyone']); - expect(archivedAtId.lookups, [generalId]); - expect(archivedAtId.joined, isEmpty); - expect(archivedAtId.loads(), 2); - expect(focusAfterArchived, welcomeId); - - // The relay refuses a create at an archived channel's id with - // `channel is archived`; that starter is skipped without a lookup. - final refusedAsArchived = starterSetup( - channelsOnLoad: (_) => const [], - refusalError: 'invalid: channel is archived', - ); - final focusAfterRefusal = await refusedAsArchived.recovery - .ensureStarterChannels(); - expect(refusedAsArchived.created, ['general', 'welcome-everyone']); - expect(refusedAsArchived.lookups, isEmpty); - expect(refusedAsArchived.joined, isEmpty); - expect(refusedAsArchived.loads(), 2); - expect(focusAfterRefusal, welcomeId); - - // The scope fence also holds across the direct lookup, checked on the - // last starter so the next starter's own fence cannot mask it. - var lookedUp = false; - final fencedLookup = starterSetup( - channelsOnLoad: (_) => const [], - refusalError: duplicateId, - refused: const {'welcome-everyone'}, - atId: _channel( - id: welcomeId, - name: 'welcome-everyone', - archivedAt: DateTime.utc(2025), - ), - isScopeCurrent: () => !lookedUp, - onLookup: () => lookedUp = true, - ); - await expectLater( - fencedLookup.recovery.ensureStarterChannels(), - throwsStateError, - ); - expect(fencedLookup.lookups, [welcomeId]); - - // Any other duplicate stays recoverable: nothing at the id, or a - // channel there that is not archived, may be a starter still settling. - for (final atId in [null, _channel(id: generalId, name: 'general')]) { - final settling = starterSetup( - channelsOnLoad: (_) => const [], - refusalError: duplicateId, - atId: atId, - ); - await expectLater( - settling.recovery.ensureStarterChannels(), - throwsException, - reason: '$atId', - ); - expect(settling.created, ['general']); - expect(settling.lookups, [generalId]); - expect(settling.joined, isEmpty); - } - - // A taken name whose holder the directory does not show yet stays - // recoverable: it may be a usable starter another client just created. - final unseen = starterSetup( - channelsOnLoad: (_) => const [], - refusalError: refusal, - ); - await expectLater( - unseen.recovery.ensureStarterChannels(), - throwsException, - ); - expect(unseen.created, ['general']); - expect(unseen.joined, isEmpty); - expect(unseen.loads(), 2); - expect(unseen.lookups, isEmpty); - - // An unlisted namesake does not hold the name, so it does not explain - // the refusal either: the setup stays recoverable, and a retry joins - // the real holder once it appears. - final unlistedForum = _channel( - id: 'unlisted-general', - name: 'general', - channelType: 'forum', - isUnlisted: true, - isMember: true, - ); - final realGeneral = _channel(id: 'real-general', name: 'general'); - final unlisted = starterSetup( - channelsOnLoad: (load) => - load <= 2 ? [unlistedForum] : [unlistedForum, realGeneral], - refusalError: refusal, - ); - await expectLater( - unlisted.recovery.ensureStarterChannels(), - throwsException, - ); - expect(unlisted.created, ['general']); - expect(unlisted.joined, isEmpty); - final focusAfterRetry = await unlisted.recovery.ensureStarterChannels(); - expect(unlisted.joined, ['real-general']); - expect(focusAfterRetry, welcomeId); - - // A usable open channel that appeared after the first load (lost race) - // is joined rather than skipped. - final raced = starterSetup( - channelsOnLoad: (load) => - load == 1 ? const [] : [_channel(id: 'raced', name: 'general')], - refusalError: refusal, - ); - await raced.recovery.ensureStarterChannels(); - expect(raced.joined, ['raced']); - - // The scope fence still holds after a refusal and its reload. - // The reload after the last starter's refusal must be the one that - // observes the change, so only the post-reload fence can catch it. - var current = true; - var fencedLoads = 0; - final fenced = starterSetup( - channelsOnLoad: (_) { - if (++fencedLoads > 2) current = false; - return [unusableHolders.first.$1]; - }, - refusalError: refusal, - refused: const {'general', 'welcome-everyone'}, - isScopeCurrent: () => current, - ); - await expectLater( - fenced.recovery.ensureStarterChannels(), - throwsStateError, - ); - expect(fenced.created, ['general', 'welcome-everyone']); - expect(fenced.loads(), 3); - expect(fenced.joined, isEmpty); - }, - ); - - test('starter setup surfaces an unavailable starter for recovery', () async { - final joined = []; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async => [ - _channel(id: 'welcome-id', name: 'welcome-everyone'), - ], - fetchChannel: (_) async => fail('no direct lookup expected'), - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => throw Exception('relay unavailable'), - joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: 'https://relay.example.com', - ); - - await expectLater( - recovery.ensureStarterChannels(), - throwsA(isA()), - ); - - expect(joined, isEmpty); - }); - - test( - 'starter setup recovery survives dismissal and container recreation', - () async { - final keys = nostr.Keys.generate(); - var generatedKeys = 0; - var claimRequests = 0; - final storage = CommunityStorage(secure: FakeSecureStorage()); - final firstContainer = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - inviteKeyGeneratorProvider.overrideWithValue(() { - generatedKeys++; - return keys; - }), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => - _FakeInviteJoinRecovery(error: Exception('relay disconnected')), - ), - inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { - claimRequests++; - return http.Response( - jsonEncode({ - 'status': 'joined', - 'host': 'relay.example.com', - 'role': 'member', - }), - 200, - ); - }), - ), - ], - ); - await firstContainer - .read(inviteJoinProvider.notifier) - .prepare( - const InviteDeepLink( - relayUrl: 'wss://relay.example.com', - code: 'code', - ), - ); - await firstContainer.read(inviteJoinProvider.notifier).confirmJoin(); - - final failed = firstContainer.read(inviteJoinProvider); - expect(failed.status, InviteJoinStatus.error); - expect(failed.isStarterSetupRecovery, isTrue); - expect(generatedKeys, 1); - expect(claimRequests, 1); - expect((await storage.loadAll()).single.starterSetupIncomplete, isTrue); - - firstContainer.dispose(); - - final secondContainer = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - inviteKeyGeneratorProvider.overrideWithValue(() { - generatedKeys++; - return nostr.Keys.generate(); - }), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), - inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { - claimRequests++; - return http.Response('{}', 500); - }), - ), - ], - ); - addTearDown(secondContainer.dispose); - - await secondContainer - .read(inviteJoinProvider.notifier) - .prepare( - const InviteDeepLink( - relayUrl: 'wss://relay.example.com', - code: 'code', - ), - ); - await secondContainer - .read(inviteJoinProvider.notifier) - .startStarterSetupRecovery(); - - final recovered = secondContainer.read(inviteJoinProvider); - expect(recovered.status, InviteJoinStatus.success); - expect(recovered.focusChannelId, 'welcome-everyone-id'); - expect((await storage.loadAll()).single.starterSetupIncomplete, isFalse); - expect(generatedKeys, 1); - expect(claimRequests, 1); - }, - ); - - test( - 'starter recovery makes no replacement-tenant submission after a scope switch', - () async { - var isScopeCurrent = true; - final firstJoinStarted = Completer(); - final releaseFirstJoin = Completer(); - final submitted = []; - final recovery = MobileInviteJoinRecovery( - loadChannels: () async => [ - _channel(id: 'general-id', name: 'general'), - _channel(id: 'welcome-id', name: 'welcome-everyone'), - ], - fetchChannel: (_) async => fail('no direct lookup expected'), - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => throw StateError('starters already exist'), - joinChannel: (channelId) async { - submitted.add(channelId); - if (channelId == 'general-id') { - firstJoinStarted.complete(); - await releaseFirstJoin.future; - } - }, - relayHttpOrigin: 'https://relay.example.com', - isScopeCurrent: () => isScopeCurrent, - ); - - final setup = recovery.ensureStarterChannels(); - await firstJoinStarted.future; - isScopeCurrent = false; - releaseFirstJoin.complete(); - - await expectLater(setup, throwsA(isA())); - expect(submitted, ['general-id']); - }, - ); - test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; @@ -846,9 +278,6 @@ void main() { communityStorageProvider.overrideWithValue(storage), communitySnapshotWriterProvider.overrideWithValue((_) async {}), authProvider.overrideWith(_RecordingAuthNotifier.new), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), invitePolicyLauncherProvider.overrideWithValue((uri) async { opened.add(uri); return ++launches > 1; @@ -985,9 +414,6 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { attempts++; @@ -1046,9 +472,6 @@ void main() { ), authProvider.overrideWith(() => auth), // Keep the production fresh-key generator: a fixed fake masks key loss. - inviteJoinRecoveryProvider.overrideWithValue( - (_) => _successfulRecovery(), - ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { attempts++; @@ -1193,30 +616,22 @@ void main() { }, ); - test('builds a fresh recovery with the second community identity', () async { + test('each claimed community gets its own identity', () async { final firstKeys = nostr.Keys.generate(); final secondKeys = nostr.Keys.generate(); - final scopes = []; - final recoveries = []; + final auth = _RecordingAuthNotifier(); var nextKeys = 0; final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue( CommunityStorage(secure: FakeSecureStorage()), ), - authProvider.overrideWith(_RecordingAuthNotifier.new), + authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() { final keys = nextKeys == 0 ? firstKeys : secondKeys; nextKeys++; return keys; }), - inviteJoinRecoveryProvider.overrideWithValue((scope) { - scopes.add(scope); - return _RecordingInviteJoinRecovery(() async { - recoveries.add(scope); - return 'welcome-everyone-id'; - }); - }), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient( (request) async => http.Response( @@ -1245,68 +660,17 @@ void main() { ); } - expect(scopes.map((scope) => scope.relayHttpOrigin), [ - 'https://first.example.com', - 'https://second.example.com', - ]); - expect(scopes.map((scope) => scope.nsec), [ + expect(auth.authenticatedCommunities.map((community) => community.nsec), [ firstKeys.nsec, secondKeys.nsec, ]); - expect(recoveries, hasLength(2)); - expect(identical(recoveries[0], scopes[0]), isTrue); - expect(identical(recoveries[1], scopes[1]), isTrue); - expect(identical(recoveries[1], scopes[0]), isFalse); + expect( + auth.authenticatedCommunities.map((community) => community.relayUrl), + ['wss://first.example.com', 'wss://second.example.com'], + ); }); } -InviteJoinRecovery _successfulRecovery() => - const _FakeInviteJoinRecovery(focusChannelId: 'welcome-everyone-id'); - -class _FakeInviteJoinRecovery implements InviteJoinRecovery { - final String? focusChannelId; - final Object? error; - - const _FakeInviteJoinRecovery({this.focusChannelId, this.error}); - - @override - Future ensureStarterChannels() async { - if (error case final failure?) throw failure; - return focusChannelId; - } -} - -class _RecordingInviteJoinRecovery implements InviteJoinRecovery { - const _RecordingInviteJoinRecovery(this._ensure); - - final Future Function() _ensure; - - @override - Future ensureStarterChannels() => _ensure(); -} - -Channel _channel({ - required String id, - required String name, - String visibility = 'open', - bool isMember = false, - String channelType = 'stream', - DateTime? archivedAt, - bool isUnlisted = false, -}) => Channel( - id: id, - name: name, - channelType: channelType, - archivedAt: archivedAt, - isUnlisted: isUnlisted, - visibility: visibility, - description: '', - createdBy: 'me', - createdAt: DateTime.utc(2026), - memberCount: isMember ? 1 : 0, - isMember: isMember, -); - class _RecordingAuthNotifier extends AuthNotifier { final List authenticatedCommunities = []; diff --git a/mobile/test/features/invites/invite_join_sheet_test.dart b/mobile/test/features/invites/invite_join_sheet_test.dart index 68fe9b5fb72..170f2a3abb1 100644 --- a/mobile/test/features/invites/invite_join_sheet_test.dart +++ b/mobile/test/features/invites/invite_join_sheet_test.dart @@ -125,9 +125,7 @@ void main() { expect(config?['onPrimaryArgb'], theme.colorScheme.onPrimary.toARGB32()); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); - testWidgets('recovery error is scrollable and exposes retry setup', ( - tester, - ) async { + testWidgets('claim error is scrollable and exposes retry', (tester) async { tester.view.physicalSize = const Size(375, 400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -136,14 +134,14 @@ void main() { WidgetHelpers.testable( child: const InviteJoinSheet(), overrides: [ - inviteJoinProvider.overrideWith(_RecoveryErrorInviteJoinNotifier.new), + inviteJoinProvider.overrideWith(_ClaimErrorInviteJoinNotifier.new), ], ), ); await tester.pump(); - expect(find.text('Finish setting up'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Retry setup'), findsOneWidget); + expect(find.text('Join this Buzz community?'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Join'), findsOneWidget); expect(find.byType(SingleChildScrollView), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -157,15 +155,6 @@ void main() { ), label: 'Joining…', ), - ( - name: 'starter recovery', - state: const InviteJoinState( - status: InviteJoinStatus.claiming, - host: 'relay.example.com', - isStarterSetupRecovery: true, - ), - label: 'Finishing setup…', - ), ]) { testWidgets('${fixture.name} cannot dismiss the in-flight invite sheet', ( tester, @@ -225,15 +214,14 @@ class _StaticInviteJoinNotifier extends InviteJoinNotifier { InviteJoinState build() => _state; } -class _RecoveryErrorInviteJoinNotifier extends InviteJoinNotifier { +class _ClaimErrorInviteJoinNotifier extends InviteJoinNotifier { @override InviteJoinState build() => const InviteJoinState( status: InviteJoinStatus.error, host: 'relay.example.com', communityName: 'Example', errorMessage: - 'Starter setup could not reach the relay. Retry when the connection is available.', - isStarterSetupRecovery: true, + 'Could not reach the relay. Check your connection and try again.', ); } diff --git a/mobile/test/features/invites/invite_relay_wait_test.dart b/mobile/test/features/invites/invite_relay_wait_test.dart deleted file mode 100644 index 4a004489650..00000000000 --- a/mobile/test/features/invites/invite_relay_wait_test.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:buzz/app.dart'; -import 'package:buzz/features/channels/channel.dart'; -import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/invites/invite_join_provider.dart'; -import 'package:buzz/shared/community/community.dart'; -import 'package:buzz/shared/community/community_provider.dart'; -import 'package:buzz/shared/relay/relay.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; - -const _relay = 'https://relay.example'; - -void main() { - late nostr.Keys keys; - late InviteJoinRecoveryScope scope; - late _Channels channels; - late ProviderContainer container; - late Provider recovery; - - setUp(() { - keys = nostr.Keys.generate(); - scope = InviteJoinRecoveryScope(relayHttpOrigin: _relay, nsec: keys.nsec); - channels = _Channels(); - // Production builds a fresh recovery for every attempt. - recovery = Provider((ref) => buildMobileInviteJoinRecovery(ref, scope)); - container = ProviderContainer( - overrides: [ - relayConfigProvider.overrideWith(() => _Config(keys.nsec)), - relaySessionProvider.overrideWith(_Session.new), - activeCommunityProvider.overrideWith( - (ref) async => - Community.create(name: 'Test', relayUrl: _relay, nsec: keys.nsec), - ), - channelsProvider.overrideWith(() => channels), - ], - ); - addTearDown(container.dispose); - }); - - Future attempt() { - container.invalidate(recovery); - return container.read(recovery).ensureStarterChannels(); - } - - void setStatus(SessionStatus status) => - (container.read(relaySessionProvider.notifier) as _Session).set(status); - - _Config config() => container.read(relayConfigProvider.notifier) as _Config; - - test('retry succeeds after an interrupted connection wait', () async { - await container.read(activeCommunityProvider.future); - setStatus(SessionStatus.connecting); - final first = attempt(); - await pumpEventQueue(); - config().update(baseUrl: 'https://other.example', nsec: keys.nsec); - setStatus(SessionStatus.reconnecting); - await expectLater(first, throwsA(isA())); - - config().update(baseUrl: _relay, nsec: keys.nsec); - setStatus(SessionStatus.connected); - expect(await attempt(), 'welcome-everyone'); - }); - - test('retry waits for a connection lost after an earlier wait', () async { - await container.read(activeCommunityProvider.future); - setStatus(SessionStatus.connected); - expect(await attempt(), 'welcome-everyone'); - expect(channels.freshReads, 1); - - setStatus(SessionStatus.disconnected); - final retry = attempt(); - await pumpEventQueue(); - expect(channels.freshReads, 1); - - setStatus(SessionStatus.connected); - expect(await retry, 'welcome-everyone'); - expect(channels.freshReads, 2); - }); -} - -class _Channels extends ChannelsNotifier { - int freshReads = 0; - final _starters = [ - for (final name in ['general', 'welcome-everyone']) - Channel( - id: name, - name: name, - channelType: 'stream', - visibility: 'open', - description: '', - createdBy: 'me', - createdAt: DateTime.utc(2026), - memberCount: 1, - isMember: true, - ), - ]; - @override - Future> build() async => _starters; - @override - Future> fetchFresh({bool fetchDirectory = false}) async { - freshReads++; - return _starters; - } -} - -class _Config extends RelayConfigNotifier { - _Config(this.nsec); - final String nsec; - @override - RelayConfig build() => RelayConfig(baseUrl: _relay, nsec: nsec); -} - -class _Session extends RelaySessionNotifier { - @override - SessionState build() => - const SessionState(status: SessionStatus.disconnected); - void set(SessionStatus status) => state = SessionState(status: status); -} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index fe5179defe4..c03fc2f6d0e 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -848,7 +848,7 @@ void main() { for (final savedIncomplete in [false, true]) { journey( - '${savedIncomplete ? 'incomplete saved' : 'fresh'} account signs in, backs up, accepts invite and retries starters before home', + '${savedIncomplete ? 'signed-in account needing setup' : 'fresh account'} signs in, backs up and accepts invite before home', (tester, f) async { await f.initialize( localIdentity: false, @@ -856,7 +856,6 @@ void main() { signedIn: savedIncomplete, ); f.needsAccountSetup = savedIncomplete; - f.starterFailures = 2; final browserReturn = Completer(); f.beforeBrowserReturn = () async { await browserReturn.future; @@ -943,9 +942,7 @@ void main() { await tester.tap(find.byType(CheckboxListTile)); await f.settle(tester); await f.tap(tester, 'Accept and join'); - expect(find.text('Try again').hitTestable(), findsOneWidget); final pending = (await f.storage.loadAll()).single; - expect(pending.starterSetupIncomplete, isTrue); expect(pending.pubkey, generated.pubkey); expect(f.sent('/claim'), hasLength(1)); expect( @@ -956,15 +953,6 @@ void main() { jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'], isTrue, ); - // Reopen the saved invite after its claim was consumed. Starter recovery - // must retain the durable marker and must not claim it again. - await f.link(tester, 'one'); - expect( - (await f.storage.loadAll()).single.starterSetupIncomplete, - isTrue, - ); - expect(find.text('Try again').hitTestable(), findsOneWidget); - await f.tap(tester, 'Try again'); expect( find.byType(ProfileSetupPage, skipOffstage: false), findsOneWidget, @@ -977,11 +965,6 @@ void main() { findsNothing, ); expect(f.sent('/claim'), hasLength(1)); - expect(f.recoveryScopes, hasLength(3)); - expect( - f.recoveryScopes.every((scope) => scope.nsec == pending.nsec), - isTrue, - ); await tester.enterText( find.byType(TextField).hitTestable(), 'Journey Person', @@ -992,10 +975,15 @@ void main() { expect(f.profileWrites, ['Journey Person']); final saved = (await f.storage.loadAll()).single; expect(saved.id, pending.id); - expect(saved.starterSetupIncomplete, isFalse); expect(await f.storage.loadActiveId(), saved.id); expect(find.byType(OnboardingAppEntry), findsNothing); expect(find.text('Community home').hitTestable(), findsOneWidget); + // Reopening the consumed invite opens the saved community without + // claiming again. + await f.link(tester, 'one'); + expect(f.sent('/claim'), hasLength(1)); + expect((await f.storage.loadAll()).single.id, pending.id); + expect(find.text('Community home').hitTestable(), findsOneWidget); }, requiresAuthDomain: true, ); @@ -1237,9 +1225,7 @@ void main() { ), isEmpty, ); - // Reopening an interrupted self-hosted invite follows legacy recovery. - await f.storage.save(saved.copyWith(starterSetupIncomplete: true)); - f.container.invalidate(communityListProvider); + // Reopening an invite for the saved community switches to it. f.container .read(pendingDeepLinkProvider.notifier) .open( @@ -1253,11 +1239,7 @@ void main() { ), ); await f.settle(tester); - expect(f.recoveryScopes, hasLength(1)); - expect( - (await f.storage.loadAll()).single.starterSetupIncomplete, - isFalse, - ); + expect((await f.storage.loadAll()).single.id, saved.id); expect(f.sent('/claim'), isEmpty); expect(find.byType(OnboardingAppEntry), findsNothing); @@ -1517,7 +1499,6 @@ void main() { await f.tap(tester, 'Accept and join'); final saved = (await f.storage.loadAll()).single; expect(saved.relayUrl, 'wss://two.communities.buzz.xyz'); - expect(saved.starterSetupIncomplete, isFalse); expect(f.sent('/claim'), hasLength(1)); expect(f.sent('/claim').single.url.host, 'two.communities.buzz.xyz'); expect(f.sent('/accept-policy'), hasLength(1)); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index f6293c2fdd5..a2098619e84 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -7,7 +7,6 @@ import 'package:flutter/services.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/hosted_app_gate.dart'; @@ -53,14 +52,12 @@ class HostedJourneyFixture { String? refusedSelfHostedPubkey; final profileWrites = []; final profileAvatars = []; - final recoveryScopes = []; final rows = >[]; final cloudKeys = []; HostedFlow? activeFlow; HostedFlow get flow => activeFlow!; late ProviderContainer container; String? bound; - int starterFailures = 0; final policyResponses = []; int browserStarts = 0; final browserUrls = []; @@ -499,9 +496,6 @@ class HostedJourneyFixture { communitySnapshotWriterProvider.overrideWithValue((_) async {}), channelsProvider.overrideWith(_NoChannels.new), profileProvider.overrideWith(() => _Profile(this)), - inviteJoinRecoveryProvider.overrideWithValue( - (scope) => _Recovery(this, scope), - ), selfHostedEntryProvider.overrideWithValue( SelfHostedEntry( accountStore: AccountIdentityStore(storage: identityStorage), @@ -667,19 +661,6 @@ class _Profile extends ProfileNotifier { } } -// This boundary proves pending-flag/retry ownership, not relay memberships. -class _Recovery implements InviteJoinRecovery { - _Recovery(this.fixture, this.scope); - final HostedJourneyFixture fixture; - final InviteJoinRecoveryScope scope; - @override - Future ensureStarterChannels() async { - fixture.recoveryScopes.add(scope); - if (fixture.starterFailures-- > 0) throw AccountFailure.unavailable; - return 'general'; - } -} - class _Browser extends BrowserAuth { _Browser(Future Function(Uri) onStart, {super.origin}) : super( diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index 93c98818492..77c48f8b58d 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -7,6 +7,8 @@ void main() { 'id': 'one', 'name': 'Buzz', 'relayUrl': 'https://relay.test', + // Written by earlier builds; saved records must still load. + 'starterSetupIncomplete': true, 'addedAt': '2026-08-05T00:00:00.000Z', }); @@ -14,7 +16,7 @@ void main() { community.sensitiveActionPolicy, SensitiveActionPolicy.disabledByUser, ); - expect(community.starterSetupIncomplete, isFalse); + expect(community.toJson(), isNot(contains('starterSetupIncomplete'))); expect(community.pushLeaseInstallationId, isNull); expect(community.pushNotificationsEnabled, isTrue); expect(community.pushSubscriptionState.pendingTombstoneGeneration, isNull); @@ -43,13 +45,11 @@ void main() { relayUrl: 'https://relay.test', sensitiveActionPolicy: SensitiveActionPolicy.enabled, pushLeaseInstallationId: 'a' * 32, - starterSetupIncomplete: true, addedAt: DateTime.utc(2026, 8, 5), ); final roundTrip = Community.fromJson(community.toJson()); expect(roundTrip.sensitiveActionPolicy, SensitiveActionPolicy.enabled); - expect(roundTrip.starterSetupIncomplete, isTrue); expect(roundTrip.pushLeaseInstallationId, 'a' * 32); }); diff --git a/mobile/test/shared/hosted/hosted_community_connection_test.dart b/mobile/test/shared/hosted/hosted_community_connection_test.dart index 1f487c034cb..6928fc82f28 100644 --- a/mobile/test/shared/hosted/hosted_community_connection_test.dart +++ b/mobile/test/shared/hosted/hosted_community_connection_test.dart @@ -6,6 +6,8 @@ import 'package:buzz/shared/hosted/hosted_admission.dart'; import 'package:buzz/shared/hosted/hosted_community_connection.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../helpers/hosted_communities_fixture.dart'; @@ -59,8 +61,6 @@ void main() { isCurrent: () => true, storage: storage, admission: admission, - recoverStarterChannels: (_, _) async => - fail('Saved non-invite community needs no starter repair'), persist: (community, current) async { if (current()) await storage.save(community); }, @@ -84,4 +84,53 @@ void main() { }, ); } + + test('an invite claims once and never reclaims a saved community', () async { + final f = HostedCommunitiesFixture(); + await f.initialize(); + addTearDown(f.dispose); + final storage = CommunityStorage(); + const invite = InviteDeepLink( + relayUrl: 'wss://invited.communities.buzz.xyz', + code: 'single-use', + ); + var claims = 0; + final admission = HostedAdmission( + account: f.account, + relayAuth: HostedRelayAuth(f.account, enabled: false), + store: f.store, + probe: (_) async {}, + client: MockClient((request) async { + if (request.method == 'GET' && request.url.path == '/api/join-policy') { + return http.Response('{}', 200); + } + expect(request.url.path, '/api/invites/claim'); + claims++; + return http.Response('{}', claims == 1 ? 200 : 410); + }), + ); + addTearDown(admission.dispose); + Future connect() => connectHostedCommunity( + link: invite, + subject: 'owner', + pubkey: f.keys.public, + isCurrent: () => true, + storage: storage, + admission: admission, + persist: (community, current) async { + if (current()) await storage.save(community); + }, + ); + + await connect(); + expect(claims, 1); + final saved = (await storage.loadAll()).single; + expect(saved.relayUrl, invite.relayUrl); + + // Reopening the consumed invite reuses the saved record without a claim. + await connect(); + expect(claims, 1); + final reopened = (await storage.loadAll()).single; + expect(reopened.id, saved.id); + }); } diff --git a/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart b/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart deleted file mode 100644 index 7091a263ca4..00000000000 --- a/mobile/test/shared/hosted/hosted_invite_starter_recovery_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; -import 'package:buzz/features/invites/invite_join_provider.dart'; -import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; -import 'package:buzz/shared/auth/auth.dart'; -import 'package:buzz/shared/deeplink/deep_link.dart'; -import 'package:buzz/shared/hosted/builderlab_account.dart'; -import 'package:buzz/shared/hosted/hosted_admission.dart'; -import 'package:buzz/shared/hosted/hosted_community_connection.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:nostr/nostr.dart' as nostr; - -import '../../helpers/hosted_communities_fixture.dart'; - -final _finishProvider = Provider( - (ref) => - (Community community, bool Function() current) => - finishHostedStarterSetup(ref, community, current), -); - -class _Recovery implements InviteJoinRecovery { - _Recovery(this.run); - final Future Function() run; - @override - Future ensureStarterChannels() async { - await run(); - return 'welcome-everyone'; - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - const invite = InviteDeepLink( - relayUrl: 'wss://invited.communities.buzz.xyz', - code: 'single-use', - ); - late HostedCommunitiesFixture f; - late CommunityStorage storage; - late HostedAdmission admission; - late ProviderContainer container; - late Community unrelated; - late List scopes; - late List probed; - late Future Function() recover; - var claims = 0; - var current = true; - - setUp(() async { - FlutterSecureStorage.setMockInitialValues({}); - f = HostedCommunitiesFixture(); - await f.initialize(); - storage = CommunityStorage(); - unrelated = Community.create( - name: 'Other', - relayUrl: 'wss://other.example', - pubkey: f.keys.public, - nsec: f.keys.nsec, - ); - await storage.save(unrelated); - await storage.saveActiveId(unrelated.id); - claims = 0; - current = true; - scopes = []; - probed = []; - recover = () async {}; - admission = HostedAdmission( - account: f.account, - relayAuth: HostedRelayAuth(f.account, enabled: false), - store: f.store, - probe: (community) async { - probed.add(community); - }, - client: MockClient((request) async { - if (request.method == 'GET' && request.url.path == '/api/join-policy') { - return http.Response('{}', 200); - } - expect(request.method, 'POST'); - expect(request.url.path, '/api/invites/claim'); - claims++; - return http.Response('{}', claims == 1 ? 200 : 410); - }), - ); - container = ProviderContainer( - overrides: [ - communityStorageProvider.overrideWithValue(storage), - inviteJoinRecoveryProvider.overrideWithValue((scope) { - scopes.add(scope); - return _Recovery(() => recover()); - }), - ], - ); - }); - tearDown(() async { - container.dispose(); - admission.dispose(); - await f.dispose(); - }); - - Future savedInvite() async => (await storage.loadAll()) - .singleWhere((community) => community.relayUrl == invite.relayUrl); - - Future connect() => connectHostedCommunity( - link: invite, - subject: 'owner', - pubkey: f.keys.public, - isCurrent: () => current, - storage: storage, - admission: admission, - persist: (community, isCurrent) => - container.read(communityTransitionProvider).runExclusive(() async { - if (!isCurrent()) return; - await storage.save(community); - await storage.saveActiveId(community.id); - container.invalidate(communityListProvider); - container.invalidate(activeCommunityProvider); - }), - recoverStarterChannels: container.read(_finishProvider), - ); - - test( - 'pending retry still rejects a changed account binding before probe or recovery', - () async { - recover = () async { - throw StateError('Starter membership refused'); - }; - await expectLater(connect(), throwsStateError); - f.bound = nostr.Keys('2'.padLeft(64, '0')).public; - await expectLater(connect(), throwsA(AccountFailure.denied)); - expect((await savedInvite()).starterSetupIncomplete, isTrue); - expect(claims, 1); - expect(probed, hasLength(1)); - expect(scopes, hasLength(1)); - }, - ); - - test('cancellation during recovery keeps durable pending flag', () async { - recover = () async { - current = false; - }; - await expectLater(connect(), throwsA(AccountFailure.unavailable)); - expect((await savedInvite()).starterSetupIncomplete, isTrue); - expect(claims, 1); - }); - - test( - 'switching active community during recovery cannot clear pending flag', - () async { - recover = () => storage.saveActiveId(unrelated.id); - await expectLater(connect(), throwsA(AccountFailure.unavailable)); - expect((await savedInvite()).starterSetupIncomplete, isTrue); - expect(await storage.loadActiveId(), unrelated.id); - }, - ); - - test( - 'clearing pending flag preserves newer saved community metadata', - () async { - recover = () => - container.read(communityTransitionProvider).runExclusive(() async { - await storage.save( - (await savedInvite()).copyWith(name: 'Renamed while joining'), - ); - }); - await connect(); - final saved = await savedInvite(); - expect(saved.starterSetupIncomplete, isFalse); - expect(saved.name, 'Renamed while joining'); - expect((await storage.loadAll()).first.toJson(), unrelated.toJson()); - }, - ); - - test( - 'saved invite with another key is never reclaimed or overwritten', - () async { - final other = nostr.Keys('2'.padLeft(64, '0')); - final saved = Community.create( - name: 'Existing', - relayUrl: invite.relayUrl, - nsec: other.nsec, - pubkey: other.public, - starterSetupIncomplete: true, - ); - await storage.save(saved); - await expectLater( - connect(), - throwsA(isA()), - ); - expect(claims, 0); - expect(probed, isEmpty); - expect(scopes, isEmpty); - expect((await savedInvite()).toJson(), saved.toJson()); - }, - ); -} diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 079f2739e07..e7f51e0a6c9 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -8,20 +8,20 @@ void main() { const channelA = '123e4567-e89b-42d3-a456-426614174000'; const channelB = '123e4567-e89b-42d3-a456-426614174001'; - test('subscribes to and mutes UUIDv5 starter channels', () { - const starter = 'fee88e92-da9f-5958-a910-48db3264ad38'; + test('subscribes to and mutes non-v4 UUID channels', () { + const uuidV5 = 'fee88e92-da9f-5958-a910-48db3264ad38'; final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, - channelIds: [starter, channelA], + channelIds: [uuidV5, channelA], ); - expect(subscriptions.last.filter.hTags, [channelA, starter]); + expect(subscriptions.last.filter.hTags, [channelA, uuidV5]); final muted = buildDesiredBuzzPushSubscriptions( myPubkey: me, - channelIds: [starter, channelA], - mutedChannelIds: [starter], + channelIds: [uuidV5, channelA], + mutedChannelIds: [uuidV5], ); expect(muted.last.filter.hTags, [channelA]); - expect(muted.first.ignore.last.hTags, [starter]); + expect(muted.first.ignore.last.hTags, [uuidV5]); expect( BuzzPushSubscription.fromJson(muted.first.toJson()).toJson(), muted.first.toJson(), @@ -144,13 +144,13 @@ void main() { }); test('ignores mutes outside the push channel grammar', () { - // Starter channels use UUIDv5 IDs; muting one must not break push setup. - const starterChannel = 'fee88e92-da9f-5958-a910-48db3264ad38'; + // Channel IDs of any UUID version are valid; muting one must not break push setup. + const uuidV5Channel = 'fee88e92-da9f-5958-a910-48db3264ad38'; final subscriptions = buildDesiredBuzzPushSubscriptions( myPubkey: me, channelIds: [channelA], - mutedChannelIds: [starterChannel, 'not-a-channel'], + mutedChannelIds: [uuidV5Channel, 'not-a-channel'], ); expect(subscriptions, hasLength(2));