Files
buzz/mobile/lib/app.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

415 lines
14 KiB
Dart

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';
import 'features/channels/deep_link_dispatcher.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.dart';
import 'features/profile/profile_edit_page.dart';
import 'features/profile/profile_text_editor.dart';
import 'features/settings/settings_page.dart';
import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.dart';
import 'shared/relay/relay.dart';
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
/// Activity feature providers for a sibling navigation surface.
final _unreadInboxItemCountProvider = Provider<int>((ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return 0;
final localState = ref.watch(inboxLocalStateProvider);
final items = ref.watch(inboxItemsProvider);
return items
.where(
(item) => !isInboxItemDone(
item,
markerOf: readState.effectiveTimestamp,
localUnreadOverrides: localState.unreadIds,
localDoneSet: localState.doneIds,
),
)
.length;
});
final _mobileRootNavigatorKey = GlobalKey<NavigatorState>();
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final communityTheme = ref.watch(communityThemeProvider);
final themeMode = communityTheme.mode;
final accentIndex = effectiveAccentIndex(
communityTheme.theme,
communityTheme.accent,
);
final schemeName = communityTheme.theme;
final authState = ref.watch(authProvider);
final resolved = resolveSchemes(schemeName, themeMode);
final lightScheme = applyAccent(resolved.light, accentIndex);
final darkScheme = applyAccent(resolved.dark, accentIndex);
// Light/Dark modes pin the brightness; System leaves it null so Flutter
// follows the OS across the selected theme and its pair.
final effectiveMode = resolved.forcedMode ?? themeMode;
// Derive the gradient from the themes that produced each color scheme.
// This keeps fallbacks and pinned brightness changes aligned with the
// rendered palette rather than the raw persisted selection.
final buzzLightGradient = buzzTopSectionGradient(
resolved.lightTheme?.name ?? '',
lightScheme.brightness,
);
final buzzDarkGradient = buzzTopSectionGradient(
resolved.darkTheme?.name ?? '',
darkScheme.brightness,
);
// Eagerly initialize websocket session and lifecycle observer when
// authenticated. These providers connect and manage the websocket.
var hasUnreadInbox = false;
if (authState.value?.status == AuthStatus.authenticated) {
ref.watch(relaySessionProvider);
ref.watch(observerRelayProvider);
ref.watch(appLifecycleProvider);
ref.watch(userStatusCacheProvider);
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;
}
// Start listening for buzz:// links immediately (even pre-auth) so a
// cold-start link survives until the authenticated UI can dispatch it.
ref.watch(pendingDeepLinkProvider);
void applyBadge(UnreadBadgeState state) {
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
} else if (state.generalUnreadCount > 0) {
AppBadgePlus.updateBadge(1);
} else {
AppBadgePlus.updateBadge(0);
}
}
useEffect(() {
applyBadge(ref.read(unreadBadgeProvider));
return null;
}, const []);
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
applyBadge(next);
});
return MaterialApp(
navigatorKey: _mobileRootNavigatorKey,
title: 'Buzz',
theme: AppTheme.light(
colorScheme: lightScheme,
topSectionGradient: buzzLightGradient,
),
darkTheme: AppTheme.dark(
colorScheme: darkScheme,
topSectionGradient: buzzDarkGradient,
),
themeMode: effectiveMode,
// Above the navigator, so a burst keeps playing over a pushed thread page
// or a modal sheet — the same reason desktop pins its canvas to the
// viewport rather than to the message row.
builder: (context, child) => MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
),
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => DeepLinkDispatcher(
child: HomePage(
settingsPageBuilder: _buildSettingsPage,
hasUnreadInbox: hasUnreadInbox,
),
),
_ => const DeepLinkDispatcher(
dispatchMessageLinks: false,
child: PairingPage(),
),
},
),
);
}
}
Widget _buildSettingsPage(BuildContext context) => SettingsPage(
profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor,
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'),
),
);
}
}