From 6eff84d1271eb1b90e07c5a0673343a76a0753fc Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 12:59:21 -0700 Subject: [PATCH] fix(mobile): join starter channels after accepting invite (#5915) ### What changed? After a new mobile invite claim succeeds, Buzz now best-effort ensures membership in the same public starter channels as desktop: - `#general` - `#welcome-everyone` Missing starters use desktop's deterministic per-relay IDs and exact public channel configuration, so concurrent mobile and desktop setup converges safely. Setup failures do not invalidate or retry an already successful invite claim, and failure for one starter does not block the other. The success sheet offers **Continue to #welcome-everyone** when that channel is available. Mobile does not create the private `Welcome` channel because it cannot provision the desktop Welcome agents that make that channel useful. This PR is stacked on #6145 because it deliberately reuses that PR's open-channel directory and join behavior. Once #6145 merges, this PR can be retargeted to `main` without changing its BUZZ-12 diff. Fixes [BUZZ-12](https://linear.app/squareup/issue/BUZZ-12/bug-community-appears-empty-after-using-invite-link-on-mobile). ### How is it tested? - Desktop/mobile deterministic starter-ID parity coverage. - Existing-channel join and missing-channel creation coverage. - Duplicate-create convergence and per-channel failure isolation coverage. - Invite success remains successful when starter setup fails. - Widget coverage for continuing directly into `#welcome-everyone`. - Focused invite/deep-link tests: 23 passed. - `just mobile-check`: passed. - `just mobile-test`: 1,483 passed. - Pre-push repository checks: passed. --------- Signed-off-by: Tom Brow Co-authored-by: Codex --- mobile/lib/app.dart | 233 ++++++++++ .../channels/channel_management_actions.dart | 7 +- .../features/channels/channels_provider.dart | 10 +- .../channels/deep_link_dispatcher.dart | 36 +- .../invites/invite_join_provider.dart | 194 +++++++- .../features/invites/invite_join_sheet.dart | 78 +++- mobile/lib/main.dart | 9 +- mobile/lib/shared/community/community.dart | 11 + .../channel_management_provider_test.dart | 36 ++ .../channels/channels_provider_test.dart | 26 ++ .../features/channels/compose_bar_test.dart | 2 +- .../channels/deep_link_dispatcher_test.dart | 282 ++++++++++++ .../invites/invite_join_provider_test.dart | 423 ++++++++++++++++++ .../invites/invite_join_sheet_test.dart | 119 +++++ .../test/shared/community/community_test.dart | 11 +- 15 files changed, 1439 insertions(+), 38 deletions(-) create mode 100644 mobile/test/features/invites/invite_join_sheet_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 5ea5552152..655095045e 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,15 +1,22 @@ +import 'dart:async'; + import 'package:app_badge_plus/app_badge_plus.dart'; 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/activity/activity_provider.dart'; import 'features/activity/inbox_local_state_provider.dart'; import 'features/activity/inbox_read_state.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/invites/invite_create_page.dart'; +import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; @@ -27,6 +34,232 @@ import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.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.', + ), +]; + +final _inviteRelayConnectedProvider = FutureProvider.family(( + ref, + expectedRelayUrl, +) async { + final currentConfig = ref.read(relayConfigProvider); + if (currentConfig.baseUrl != expectedRelayUrl) { + throw StateError('Active community changed before invite recovery'); + } + if (ref.read(relaySessionProvider).status == SessionStatus.connected) return; + + final connected = Completer(); + ref.listen(relaySessionProvider, (_, next) { + if (connected.isCompleted) return; + if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { + connected.completeError( + StateError('Active community changed during invite recovery'), + ); + } else if (next.status == SessionStatus.connected) { + connected.complete(); + } + }); + await connected.future; +}); + +/// App-level bridge from invite joining to the channels feature. +class MobileInviteJoinRecovery implements InviteJoinRecovery { + final Future> Function() _loadChannels; + 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, creation, and join operations. + MobileInviteJoinRecovery({ + required Future> Function() loadChannels, + 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, + _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. + @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) { + if (!_isDuplicateChannelError(error)) rethrow; + _ensureScopeCurrent(); + channels = await _loadChannels(); + _ensureScopeCurrent(); + channel = + _findStarterChannel(channels, starter.slug) ?? + channels + .where((candidate) => candidate.id == channelId) + .firstOrNull; + if (channel == null) rethrow; + } + } + + _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) { + final normalizedName = name.trim().toLowerCase(); + return channels + .where( + (channel) => + channel.name.trim().toLowerCase() == normalizedName && + channel.isStream && + channel.visibility == 'open' && + !channel.isArchived, + ) + .firstOrNull; +} + +bool _isDuplicateChannelError(Object error) => + error.toString().contains('duplicate: channel 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 ref + .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) + .timeout(const Duration(seconds: 15)); + ensureScopeCurrent(); + await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true); + ensureScopeCurrent(); + final channels = await ref.read(channelsProvider.future); + ensureScopeCurrent(); + return channels; + }, + 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, + ); +} + /// App-shell projection that joins Activity state for the Home navigation. /// /// This belongs at the composition root because it deliberately aggregates diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 34d359040b..e0879eb022 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -34,15 +34,16 @@ class ChannelActions { _isCommunityValid = isCommunityValid; Future createChannel({ + String? channelId, required String name, required String channelType, required String visibility, String? description, int? ttlSeconds, }) async { - final channelId = _newUuidV4(); + final resolvedChannelId = channelId ?? _newUuidV4(); final tags = buildCreateChannelTags( - channelId: channelId, + channelId: resolvedChannelId, name: name, channelType: channelType, visibility: visibility, @@ -52,7 +53,7 @@ class ChannelActions { _ensureCommunityValid(); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); _ensureCommunityValid(); - return _refreshChannelsAndRead(channelId); + return _refreshChannelsAndRead(resolvedChannelId); } /// Open (or create) a DM channel with the given pubkeys. diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 2258a493c9..03f29c58a1 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -906,7 +906,10 @@ class ChannelsNotifier extends AsyncNotifier> { } } - Future refresh() async { + /// Refreshes memberships and, when [fetchDirectory], the open directory. + /// Invite starter recovery opts in to converge on another identity's + /// starters instead of attempting duplicate creation from memberships. + Future refresh({bool fetchDirectory = false}) async { final sessionState = ref.read(relaySessionProvider); // Don't attempt to fetch when the session isn't connected — fetchHistory // would send REQs over an unauthenticated socket that either time out @@ -915,7 +918,10 @@ class ChannelsNotifier extends AsyncNotifier> { // when the session transitions to connected. if (sessionState.status != SessionStatus.connected) return; try { - final channels = await _fetch(subscribeLive: true); + final channels = await _fetch( + subscribeLive: true, + fetchDirectory: fetchDirectory, + ); state = AsyncData(channels); } on _StaleChannelRefresh { return; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 391a4f1a49..a7f509de13 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -99,6 +101,11 @@ class _DeepLinkDispatcherState extends ConsumerState { } if (!context.mounted) return; + _pushChannel(channel, link); + ref.read(pendingDeepLinkProvider.notifier).consume(); + } + + void _pushChannel(Channel channel, BuzzDeepLink link) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => @@ -112,7 +119,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ), ), ); - ref.read(pendingDeepLinkProvider.notifier).consume(); } void _maybeDispatchInvite(InviteDeepLink link) { @@ -127,9 +133,31 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); consumed = true; if (!navigatorContext.mounted) return; - final status = ref.read(inviteJoinProvider).status; - if (status == InviteJoinStatus.confirming) { - await showInviteJoinSheet(navigatorContext, ref); + 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)); + } + } } else if (status == InviteJoinStatus.switchedExisting) { messenger?.showSnackBar( const SnackBar(content: Text('Switched to this community')), diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b3b1e58144..a665dded1b 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -6,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr; 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'; @@ -21,6 +22,39 @@ final inviteKeyGeneratorProvider = Provider((ref) { 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, @@ -37,6 +71,8 @@ class InviteJoinState { final String? communityName; final String? errorMessage; final bool requiresFreshInvite; + final bool isStarterSetupRecovery; + final String? focusChannelId; const InviteJoinState({ this.status = InviteJoinStatus.idle, @@ -45,6 +81,8 @@ class InviteJoinState { this.communityName, this.errorMessage, this.requiresFreshInvite = false, + this.isStarterSetupRecovery = false, + this.focusChannelId, }); InviteJoinState copyWith({ @@ -52,19 +90,31 @@ class InviteJoinState { InviteDeepLink? invite, String? host, String? communityName, - String? errorMessage, + Object? errorMessage = _unset, bool? requiresFreshInvite, + bool? isStarterSetupRecovery, + Object? focusChannelId = _unset, }) => InviteJoinState( status: status ?? this.status, invite: invite ?? this.invite, host: host ?? this.host, communityName: communityName ?? this.communityName, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _unset) + ? this.errorMessage + : errorMessage as String?, requiresFreshInvite: requiresFreshInvite ?? this.requiresFreshInvite, + 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(); @@ -76,6 +126,18 @@ 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, @@ -85,6 +147,7 @@ class InviteJoinNotifier extends Notifier { return; } + _pendingStarterSetupCommunity = null; state = InviteJoinState( status: InviteJoinStatus.confirming, invite: invite, @@ -102,7 +165,11 @@ class InviteJoinNotifier extends Notifier { return; } - state = state.copyWith(status: InviteJoinStatus.claiming); + state = state.copyWith( + status: InviteJoinStatus.claiming, + errorMessage: null, + requiresFreshInvite: false, + ); try { final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); @@ -110,9 +177,24 @@ 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.switchedExisting, - communityName: existing.name, + status: InviteJoinStatus.error, + errorMessage: + 'This community is no longer available. Re-open the invite link to try again.', ); return; } @@ -160,14 +242,14 @@ class InviteJoinNotifier extends Notifier { pubkey: keys.public, nsec: keys.nsec, sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser, + starterSetupIncomplete: true, ); await ref .read(authProvider.notifier) .authenticateWithCommunity(community); - state = state.copyWith( - status: InviteJoinStatus.success, - communityName: community.name, - ); + _pendingStarterSetupCommunity = community; + state = state.copyWith(isStarterSetupRecovery: true); + await startStarterSetupRecovery(); } catch (error) { final requiresFreshInvite = _requiresFreshInvite(error); state = state.copyWith( @@ -178,7 +260,90 @@ 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(); } } @@ -285,3 +450,14 @@ 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 b0e0cb6f7b..deec5ba7ef 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -8,15 +8,31 @@ import '../../shared/widgets/modal_presentation.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; -Future showInviteJoinSheet(BuildContext context, WidgetRef ref) { - return showBuzzModalBottomSheet( +Future showInviteJoinSheet(BuildContext context) { + return showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - builder: (_) => const InviteJoinSheet(), + // 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 + // the shared close affordance out of this sheet so they cannot bypass that + // in-flight guard. + enableDrag: false, + showCloseButton: false, + builder: (_) => const _InviteJoinSheetRoute(), ); } +class _InviteJoinSheetRoute extends ConsumerWidget { + const _InviteJoinSheetRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isClaiming = + ref.watch(inviteJoinProvider).status == InviteJoinStatus.claiming; + return PopScope(canPop: !isClaiming, child: const InviteJoinSheet()); + } +} + class InviteJoinSheet extends ConsumerWidget { const InviteJoinSheet({super.key}); @@ -24,15 +40,31 @@ 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 = switch (( + isClaiming, + isStarterSetupRecovery, + state.status, + )) { + (true, true, _) => 'Finishing setup…', + (true, false, _) => 'Joining…', + (false, true, InviteJoinStatus.error) => 'Retry setup', + (false, true, _) => 'Finish setting up', + _ => 'Join', + }; if (state.status == InviteJoinStatus.success) { - return _InviteJoinSuccess(host: host, communityName: derivedName); + return _InviteJoinSuccess( + host: host, + communityName: derivedName, + hasFocusChannel: state.focusChannelId != null, + ); } return SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm), child: Column( mainAxisSize: MainAxisSize.min, @@ -41,7 +73,9 @@ class InviteJoinSheet extends ConsumerWidget { Icon(LucideIcons.userPlus, size: 40, color: context.colors.primary), const SizedBox(height: Grid.sm), Text( - 'Join this Buzz community?', + isStarterSetupRecovery + ? 'Finish setting up' + : 'Join this Buzz community?', style: context.textTheme.titleLarge, ), const SizedBox(height: Grid.xxs), @@ -79,6 +113,15 @@ 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( @@ -111,7 +154,7 @@ class InviteJoinSheet extends ConsumerWidget { child: FilledButton.icon( onPressed: isClaiming || state.requiresFreshInvite ? null - : () => ref + : () async => ref .read(inviteJoinProvider.notifier) .confirmJoin(), icon: isClaiming @@ -120,11 +163,13 @@ class InviteJoinSheet extends ConsumerWidget { height: 16, child: BuzzLoadingIndicator( size: 16, - semanticLabel: 'Joining community', + semanticLabel: isStarterSetupRecovery + ? 'Finishing setup' + : 'Joining community', ), ) : const Icon(LucideIcons.check), - label: Text(isClaiming ? 'Joining…' : 'Join'), + label: Text(primaryLabel), ), ), ], @@ -139,8 +184,13 @@ class InviteJoinSheet extends ConsumerWidget { class _InviteJoinSuccess extends StatelessWidget { final String host; final String? communityName; + final bool hasFocusChannel; - const _InviteJoinSuccess({required this.host, this.communityName}); + const _InviteJoinSuccess({ + required this.host, + required this.hasFocusChannel, + this.communityName, + }); @override Widget build(BuildContext context) { @@ -183,8 +233,10 @@ class _InviteJoinSuccess extends StatelessWidget { ), const SizedBox(height: Grid.xs), TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Not now'), + onPressed: () => Navigator.of(context).pop(hasFocusChannel), + child: Text( + hasFocusChannel ? 'Continue to #welcome-everyone' : 'Not now', + ), ), ], ), diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207..83ded086f2 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'features/invites/invite_join_provider.dart'; import 'shared/theme/theme_provider.dart'; void main() async { @@ -13,7 +14,13 @@ void main() async { runApp( ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + inviteJoinRecoveryProvider.overrideWith( + (ref) => + (scope) => buildMobileInviteJoinRecovery(ref, scope), + ), + ], child: const App(), ), ); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 409de10e39..6763f953ee 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -12,6 +12,9 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + + /// Whether invite-created starter channels still need to be recovered. + final bool starterSetupIncomplete; final DateTime addedAt; const Community({ @@ -21,6 +24,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.starterSetupIncomplete = false, required this.addedAt, }); @@ -31,6 +35,7 @@ class Community { String? nsec, SensitiveActionPolicy sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + bool starterSetupIncomplete = false, }) { return Community( id: _uuid.v4(), @@ -39,6 +44,7 @@ class Community { pubkey: pubkey, nsec: nsec, sensitiveActionPolicy: sensitiveActionPolicy, + starterSetupIncomplete: starterSetupIncomplete, addedAt: DateTime.now(), ); } @@ -49,6 +55,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + bool? starterSetupIncomplete, }) { return Community( id: id, @@ -58,6 +65,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + starterSetupIncomplete: + starterSetupIncomplete ?? this.starterSetupIncomplete, addedAt: addedAt, ); } @@ -69,6 +78,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), }; @@ -82,6 +92,7 @@ class Community { (value) => value.name == json['sensitiveActionPolicy'], orElse: () => SensitiveActionPolicy.disabledByUser, ), + starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 5700bfae96..04b8c16671 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -246,6 +246,42 @@ void main() { }); }); + test( + 'create and join stop before submitting after a community switch', + () async { + final keys = nostr.Keys.generate(); + final session = _RecordingPublishRelaySession(); + final actionsProvider = Provider((ref) { + return ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec), + currentPubkey: keys.public, + isCommunityValid: () => false, + ); + }); + final container = ProviderContainer(retry: (_, _) => null); + addTearDown(container.dispose); + + final actions = container.read(actionsProvider); + await expectLater( + actions.createChannel( + channelId: _channelId, + name: 'general', + channelType: 'stream', + visibility: 'open', + ), + throwsA(isA()), + ); + await expectLater( + actions.joinChannel(_channelId), + throwsA(isA()), + ); + + expect(session.publishedEvents, isEmpty); + }, + ); + group('Huddle channel lifecycle', () { test( 'starts from accepted signed events without refreshing all channels', diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index ea6c38cdfd..04f7cd917b 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1486,6 +1486,32 @@ 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); + + expect(await container.read(channelsProvider.future), isEmpty); + + 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); + }); + test('deduplicates joined channels from directory discovery', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index d1e2e63384..e7170c2afe 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -447,7 +447,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future> build() async => _channels; @override - Future refresh() async { + Future refresh({bool fetchDirectory = false}) async { state = AsyncData(_channels); } diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index ea6bc226c2..a26308b284 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -228,6 +228,170 @@ 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: '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(); + await recovery.started.future; + + 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: '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), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await recovery.started.future; + + 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 { @@ -390,6 +554,57 @@ void main() { expect(find.text('Pairing'), findsOneWidget); }, ); + + testWidgets('continues a successful invite into welcome-everyone', ( + tester, + ) async { + const invite = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-code', + ); + final container = ProviderContainer( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(invite), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])), + ), + inviteJoinProvider.overrideWith(_SuccessfulInviteJoinNotifier.new), + ], + ); + 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.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + 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.channel.id, 'welcome-everyone-id'); + expect( + destination.link, + const ChannelDeepLink(channelId: 'welcome-everyone-id'), + ); + }); } final _channel = Channel( @@ -404,6 +619,58 @@ final _channel = Channel( isMember: true, ); +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 + InviteJoinState build() => const InviteJoinState(); + + @override + Future prepare(InviteDeepLink invite) async { + state = InviteJoinState( + status: InviteJoinStatus.confirming, + invite: invite, + host: 'relay.example.com', + communityName: 'Example', + ); + } + + @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, + ); + } +} + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; @@ -476,6 +743,21 @@ 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_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 2e52f79490..83f7dc70c9 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +8,8 @@ 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'; @@ -88,6 +91,9 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { capturedRequest = request; @@ -123,6 +129,7 @@ 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(), @@ -178,6 +185,306 @@ 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', + ), + ], + 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 [], + 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'), + ]; + }, + 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('starter setup surfaces an unavailable starter for recovery', () async { + final joined = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => [ + _channel(id: 'welcome-id', name: 'welcome-everyone'), + ], + 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'), + ], + 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; @@ -276,6 +583,9 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { attempts++; @@ -321,8 +631,116 @@ void main() { ); expect(auth.authenticatedCommunities, hasLength(1)); }); + + test('builds a fresh recovery with the second community identity', () async { + final firstKeys = nostr.Keys.generate(); + final secondKeys = nostr.Keys.generate(); + final scopes = []; + final recoveries = []; + var nextKeys = 0; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + authProvider.overrideWith(_RecordingAuthNotifier.new), + 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( + jsonEncode({ + 'status': 'joined', + 'host': request.url.host, + 'role': 'member', + }), + 200, + ), + ), + ), + ], + ); + addTearDown(container.dispose); + + for (final invite in const [ + InviteDeepLink(relayUrl: 'wss://first.example.com', code: 'first'), + InviteDeepLink(relayUrl: 'wss://second.example.com', code: 'second'), + ]) { + await container.read(inviteJoinProvider.notifier).prepare(invite); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); + } + + expect(scopes.map((scope) => scope.relayHttpOrigin), [ + 'https://first.example.com', + 'https://second.example.com', + ]); + expect(scopes.map((scope) => scope.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); + }); } +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, +}) => Channel( + id: id, + name: name, + channelType: 'stream', + visibility: visibility, + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: isMember ? 1 : 0, + isMember: isMember, +); + class _RecordingAuthNotifier extends AuthNotifier { final List authenticatedCommunities = []; @@ -332,6 +750,11 @@ class _RecordingAuthNotifier extends AuthNotifier { @override Future authenticateWithCommunity(Community community) async { + final storage = ref.read(communityStorageProvider); + await storage.save(community); + await storage.saveActiveId(community.id); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); authenticatedCommunities.add(community); state = AsyncData( AuthState(status: AuthStatus.authenticated, community: community), diff --git a/mobile/test/features/invites/invite_join_sheet_test.dart b/mobile/test/features/invites/invite_join_sheet_test.dart new file mode 100644 index 0000000000..876429b7d9 --- /dev/null +++ b/mobile/test/features/invites/invite_join_sheet_test.dart @@ -0,0 +1,119 @@ +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/features/invites/invite_join_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('recovery error is scrollable and exposes retry setup', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: const InviteJoinSheet(), + overrides: [ + inviteJoinProvider.overrideWith(_RecoveryErrorInviteJoinNotifier.new), + ], + ), + ); + await tester.pump(); + + expect(find.text('Finish setting up'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Retry setup'), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final fixture in [ + ( + name: 'membership claim', + state: const InviteJoinState( + status: InviteJoinStatus.claiming, + host: 'relay.example.com', + ), + 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, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: const _InviteJoinSheetLauncher(), + overrides: [ + inviteJoinProvider.overrideWith( + () => _StaticInviteJoinNotifier(fixture.state), + ), + ], + ), + ); + + await tester.tap(find.text('Open invite')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(fixture.label), findsOneWidget); + expect( + find.byKey(const ValueKey('buzz-sheet-drag-handle')), + findsNothing, + ); + expect(find.byTooltip('Close sheet'), findsNothing); + + await tester.tapAt(const Offset(8, 8)); + await tester.pump(); + expect(find.text(fixture.label), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text(fixture.label), findsOneWidget); + }); + } +} + +class _InviteJoinSheetLauncher extends StatelessWidget { + const _InviteJoinSheetLauncher(); + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () => showInviteJoinSheet(context), + child: const Text('Open invite'), + ), + ), + ); +} + +class _StaticInviteJoinNotifier extends InviteJoinNotifier { + _StaticInviteJoinNotifier(this._state); + + final InviteJoinState _state; + + @override + InviteJoinState build() => _state; +} + +class _RecoveryErrorInviteJoinNotifier 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, + ); +} diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index e00f3cb17d..5c5b337a45 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -14,20 +14,21 @@ void main() { community.sensitiveActionPolicy, SensitiveActionPolicy.disabledByUser, ); + expect(community.starterSetupIncomplete, isFalse); }); - test('sensitive action policy round trips', () { + test('community settings round trip', () { final community = Community( id: 'one', name: 'Buzz', relayUrl: 'https://relay.test', sensitiveActionPolicy: SensitiveActionPolicy.enabled, + starterSetupIncomplete: true, addedAt: DateTime.utc(2026, 8, 5), ); - expect( - Community.fromJson(community.toJson()).sensitiveActionPolicy, - SensitiveActionPolicy.enabled, - ); + final roundTrip = Community.fromJson(community.toJson()); + expect(roundTrip.sensitiveActionPolicy, SensitiveActionPolicy.enabled); + expect(roundTrip.starterSetupIncomplete, isTrue); }); }