Files
buzz/mobile/lib/features/invites/invite_join_sheet.dart
Tom Brow 6eff84d127 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>
2026-08-24 12:59:21 -07:00

247 lines
8.6 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../pairing/pairing_page.dart';
import 'invite_join_provider.dart';
Future<bool?> showInviteJoinSheet(BuildContext context) {
return showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
// The route remains a normal modal while idle, but its contents install a
// PopScope once a membership claim or starter setup begins. Keep drag and
// 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});
@override
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,
hasFocusChannel: state.focusChannelId != null,
);
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(LucideIcons.userPlus, size: 40, color: context.colors.primary),
const SizedBox(height: Grid.sm),
Text(
isStarterSetupRecovery
? 'Finish setting up'
: 'Join this Buzz community?',
style: context.textTheme.titleLarge,
),
const SizedBox(height: Grid.xxs),
Text(
'Check the relay host before you join:',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xs),
Container(
padding: const EdgeInsets.all(Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(
alpha: 0.7,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.colors.outlineVariant),
),
child: Text(
host,
style: context.textTheme.titleMedium?.copyWith(
fontFamily: 'GeistMono',
fontWeight: FontWeight.w700,
),
),
),
if (derivedName != null && derivedName != host) ...[
const SizedBox(height: Grid.xxs),
Text(
'Display name: $derivedName',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
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(
color: context.colors.onSurfaceVariant,
),
),
if (state.status == InviteJoinStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: Grid.sm),
Text(
state.errorMessage!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.lg),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: isClaiming
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const SizedBox(width: Grid.sm),
Expanded(
child: FilledButton.icon(
onPressed: isClaiming || state.requiresFreshInvite
? null
: () async => ref
.read(inviteJoinProvider.notifier)
.confirmJoin(),
icon: isClaiming
? SizedBox(
width: 16,
height: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel: isStarterSetupRecovery
? 'Finishing setup'
: 'Joining community',
),
)
: const Icon(LucideIcons.check),
label: Text(primaryLabel),
),
),
],
),
],
),
),
);
}
}
class _InviteJoinSuccess extends StatelessWidget {
final String host;
final String? communityName;
final bool hasFocusChannel;
const _InviteJoinSuccess({
required this.host,
required this.hasFocusChannel,
this.communityName,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
LucideIcons.circleCheck,
size: 40,
color: context.colors.primary,
),
const SizedBox(height: Grid.sm),
Text(
'You joined ${communityName ?? host}',
style: context.textTheme.titleLarge,
),
const SizedBox(height: Grid.xs),
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(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.lg),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(addingCommunity: true),
),
);
},
icon: const Icon(LucideIcons.scanLine),
label: const Text('Back it up now'),
),
const SizedBox(height: Grid.xs),
TextButton(
onPressed: () => Navigator.of(context).pop(hasFocusChannel),
child: Text(
hasFocusChannel ? 'Continue to #welcome-everyone' : 'Not now',
),
),
],
),
),
);
}
}