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