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 <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Tom Brow
2026-08-24 12:59:21 -07:00
committed by GitHub
parent a0298539f7
commit 6eff84d127
15 changed files with 1439 additions and 38 deletions

View File

@@ -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<void, String>((
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<void>();
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<List<Channel>> Function() _loadChannels;
final Future<Channel> Function({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
})
_createChannel;
final Future<void> Function(String channelId) _joinChannel;
final String _relayHttpOrigin;
final bool Function() _isScopeCurrent;
/// Creates recovery from channel-loading, creation, and join operations.
MobileInviteJoinRecovery({
required Future<List<Channel>> Function() loadChannels,
required Future<Channel> Function({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
})
createChannel,
required Future<void> 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<String?> 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<Channel> 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

View File

@@ -34,15 +34,16 @@ class ChannelActions {
_isCommunityValid = isCommunityValid;
Future<Channel> 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.

View File

@@ -906,7 +906,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
}
Future<void> 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<void> 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<List<Channel>> {
// 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;

View File

@@ -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<DeepLinkDispatcher> {
}
if (!context.mounted) return;
_pushChannel(channel, link);
ref.read(pendingDeepLinkProvider.notifier).consume();
}
void _pushChannel(Channel channel, BuzzDeepLink link) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
@@ -112,7 +119,6 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
),
),
);
ref.read(pendingDeepLinkProvider.notifier).consume();
}
void _maybeDispatchInvite(InviteDeepLink link) {
@@ -127,9 +133,31 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
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')),

View File

@@ -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<InviteKeyGenerator>((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<String?> 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<InviteJoinRecoveryFactory>((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<InviteJoinState> {
Community? _pendingStarterSetupCommunity;
Future<void>? _starterSetupInFlight;
@override
InviteJoinState build() => const InviteJoinState();
@@ -76,6 +126,18 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
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<InviteJoinState> {
return;
}
_pendingStarterSetupCommunity = null;
state = InviteJoinState(
status: InviteJoinStatus.confirming,
invite: invite,
@@ -102,7 +165,11 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
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<InviteJoinState> {
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<InviteJoinState> {
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<InviteJoinState> {
}
}
/// Runs the pending recovery after its progress UI has been presented.
Future<void> 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<void> _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<void> _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.';
}

View File

@@ -8,15 +8,31 @@ import '../../shared/widgets/modal_presentation.dart';
import '../pairing/pairing_page.dart';
import 'invite_join_provider.dart';
Future<void> showInviteJoinSheet(BuildContext context, WidgetRef ref) {
return showBuzzModalBottomSheet<void>(
Future<bool?> showInviteJoinSheet(BuildContext context) {
return showBuzzModalBottomSheet<bool>(
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',
),
),
],
),

View File

@@ -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(),
),
);

View File

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

View File

@@ -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<ChannelActions>((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<StateError>()),
);
await expectLater(
actions.joinChannel(_channelId),
throwsA(isA<StateError>()),
);
expect(session.publishedEvents, isEmpty);
},
);
group('Huddle channel lifecycle', () {
test(
'starts from accepted signed events without refreshing all channels',

View File

@@ -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)],

View File

@@ -447,7 +447,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
Future<List<Channel>> build() async => _channels;
@override
Future<void> refresh() async {
Future<void> refresh({bool fetchDirectory = false}) async {
state = AsyncData(_channels);
}

View File

@@ -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<FilledButton>(
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<void> prepare(InviteDeepLink invite) async {
state = InviteJoinState(
status: InviteJoinStatus.confirming,
invite: invite,
host: 'relay.example.com',
communityName: 'Example',
);
}
@override
Future<void> confirmJoin() async {
state = state.copyWith(
status: InviteJoinStatus.success,
focusChannelId: 'welcome-everyone-id',
);
}
}
class _FailedStarterRecoveryInviteJoinNotifier extends InviteJoinNotifier {
@override
InviteJoinState build() => const InviteJoinState();
@override
Future<void> 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<List<Channel>> build() => channels;
}
class _DeferredInviteJoinRecovery implements InviteJoinRecovery {
final Completer<String?> _result = Completer<String?>();
final Completer<void> started = Completer<void>();
@override
Future<String?> 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});

View File

@@ -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 = <String>[];
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 = <String, Map<String, Object?>>{};
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 = <String>[];
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 = <String>[];
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<Exception>()),
);
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<void>();
final releaseFirstJoin = Completer<void>();
final submitted = <String>[];
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<StateError>()));
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 = <InviteJoinRecoveryScope>[];
final recoveries = <InviteJoinRecoveryScope>[];
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<String?> ensureStarterChannels() async {
if (error case final failure?) throw failure;
return focusChannelId;
}
}
class _RecordingInviteJoinRecovery implements InviteJoinRecovery {
const _RecordingInviteJoinRecovery(this._ensure);
final Future<String?> Function() _ensure;
@override
Future<String?> 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<Community> authenticatedCommunities = [];
@@ -332,6 +750,11 @@ class _RecordingAuthNotifier extends AuthNotifier {
@override
Future<void> 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),

View File

@@ -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,
);
}

View File

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