Files
buzz/mobile/lib/app.dart
Tom Brow 3d3f9cf153 fix(mobile): keep publishing presence while the app is open (#9441)
Mobile stopped publishing presence on November 1. The heartbeat in
`PresenceNotifier` only runs while something holds `presenceProvider`,
and its last always-mounted watcher was the app-bar `ProfileAvatar`,
deleted as dead code in [#8880 dead code
cleanup](https://github.com/block/buzz/pull/8880). Since then the app
sends "online" only while Settings is open, stops when Settings closes,
and never sends "away" when backgrounded. Other people see a phone user
as offline (or stuck on whatever Settings last sent).

This keeps the provider alive from the signed-in shell, next to the
other app-wide `ref.listen` keep-alives in `app.dart`.

Found during a combined performance and network hunt on a physical
iPhone SE running main ([bf3bdd0 GIF
timing](https://github.com/block/buzz/commit/bf3bdd0eae)): a 5-minute
foreground idle window on the channel list sent no websocket events at
all.

### Regressions

The app goes back to its pre-#8880 traffic: one small signed kind:20001
event per minute while foregrounded, plus one on each lifecycle change.
The heartbeat code is unchanged. `ref.listen` doesn't rebuild the App
when presence changes.

### Validation

- Added tests: `presence_shell_test.dart` pumps the signed-in `App`
without opening Settings and expects an "online" presence publish. It
fails without the `app.dart` change (expected `['online']`, got `[]`)
and passes with it.
- Full mobile suite passes (4785 tests).
- Not yet done: device confirmation that heartbeats resume and "away"
goes out on backgrounding.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 22:58:03 -08:00

558 lines
20 KiB
Dart

import 'features/activity/activity_provider.dart';
import 'features/channels/bestie_conversation.dart';
import 'features/channels/default_agent_launcher.dart';
import 'features/agents/agents_page.dart';
import 'dart:async';
import 'hosted_app_gate.dart';
import 'onboarding_app_entry.dart';
import 'organization_app_entry.dart';
import 'shared/share/signed_out_share_notice.dart';
import 'shared/hosted/organization_environment.dart';
import 'shared/widgets/ios_navigation_metrics.dart';
import 'shared/community/paired_community_landing.dart';
import 'shared/community/community_loading_surface.dart';
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/age_gate/age_restriction_page.dart';
import 'features/age_gate/age_signal_provider.dart';
import 'features/channels/channel.dart';
import 'features/channels/channel_management_provider.dart';
import 'features/channels/channels_provider.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/channels/me/me_page.dart';
import 'features/channels/me/me_unread_provider.dart';
import 'features/invites/invite_create_page.dart';
import 'features/invites/invite_join_provider.dart';
import 'features/pairing/pairing_page.dart';
import 'features/pairing/pairing_provider.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/channel_detail_page.dart';
import 'shared/widgets/app_route_observer.dart';
import 'shared/widgets/root_page_route.dart';
import 'features/profile/profile_provider.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.dart';
import 'features/profile/set_status_sheet.dart';
import 'features/profile/user_status_provider.dart';
import 'features/profile/profile_edit_page.dart';
import 'features/profile/profile_text_editor.dart';
import 'features/settings/settings_page.dart';
import 'features/settings/theme_picker_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/theme/theme.dart';
import 'shared/widgets/buzz_loading_indicator.dart';
import 'shared/widgets/buzz_toast_host.dart';
import 'shared/widgets/covered_route_keyboard_insets.dart';
const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0';
const _starterChannels = [
(slug: 'general', description: 'General conversation and community updates.'),
(
slug: 'welcome-everyone',
description: 'Say hi, ask a question, or share what brought you here.',
),
];
/// Waits for this attempt's community to hold an authenticated connection.
///
/// Each attempt observes the live session, so a retry never reuses an earlier
/// attempt's failure or a connection that has since been lost.
Future<void> _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) {
final connected = Completer<void>();
final sub = ref.listen(relaySessionProvider, (_, next) {
if (connected.isCompleted) return;
if (!isScopeCurrent()) {
connected.completeError(
StateError('Active community changed during invite recovery'),
);
} else if (next.status == SessionStatus.connected) {
connected.complete();
}
}, fireImmediately: true);
return connected.future
.timeout(const Duration(seconds: 15))
.whenComplete(sub.close);
}
/// App-level bridge from invite joining to the channels feature.
class MobileInviteJoinRecovery implements InviteJoinRecovery {
final Future<List<Channel>> Function() _loadChannels;
final Future<Channel?> Function(String channelId) _fetchChannel;
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, single-channel lookup, creation,
/// and join operations.
MobileInviteJoinRecovery({
required Future<List<Channel>> Function() loadChannels,
required Future<Channel?> Function(String channelId) fetchChannel,
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,
_fetchChannel = fetchChannel,
_createChannel = createChannel,
_joinChannel = joinChannel,
_relayHttpOrigin = relayHttpOrigin,
_isScopeCurrent = isScopeCurrent ?? _alwaysCurrent;
/// Ensures memberships in the same public starter channels as desktop.
///
/// All starter work is fenced to the community and identity that started it.
/// A partial setup remains recoverable, so a failed operation deliberately
/// propagates to the invite flow instead of being reported as success. The
/// one exception is a starter whose name or id the relay reports as taken
/// by a channel the directory shows cannot serve as the starter, or an
/// archived channel at the starter's own id: both
/// are permanent, so the starter is skipped instead of blocking the join.
@override
Future<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) {
final nameTaken = _isChannelNameTakenError(error);
final archivedAtId = _isArchivedChannelError(error);
if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) {
rethrow;
}
_ensureScopeCurrent();
channels = await _loadChannels();
_ensureScopeCurrent();
final holder =
_findStarterChannel(channels, starter.slug) ??
channels
.where((candidate) => candidate.id == channelId)
.firstOrNull ??
(nameTaken ? _findListedNamesake(channels, starter.slug) : null);
if (holder == null) {
// The relay refuses a create at an archived channel's id before
// its duplicate check, and keeps that id, so the starter can never
// be created there.
if (archivedAtId) continue;
// The relay keeps the id (desktop uses the same one), so a
// duplicate with no match in the reload may be an archived
// starter whose metadata the reload did not return. Read that id
// directly.
if (!nameTaken && _isDuplicateChannelError(error)) {
final atId = await _fetchChannel(channelId);
_ensureScopeCurrent();
if (atId != null && atId.isArchived) continue;
}
// A holder not visible yet may still be usable, so keep the setup
// recoverable.
rethrow;
}
// The relay keeps a channel's id and a listed channel's name, archived
// ones included, so an unusable holder never frees the starter. Skip
// it rather than strand the invite in permanent recovery.
if (!_isUsableStarter(holder)) continue;
channel = holder;
}
}
_ensureScopeCurrent();
if (!channel.isMember) {
await _joinChannel(channel.id);
_ensureScopeCurrent();
}
if (starter.slug == 'welcome-everyone') {
welcomeEveryoneId = channel.id;
}
}
return welcomeEveryoneId;
}
static bool _alwaysCurrent() => true;
void _ensureScopeCurrent() {
if (!_isScopeCurrent()) {
throw StateError('Active community changed during invite recovery');
}
}
}
Channel? _findStarterChannel(List<Channel> channels, String name) => channels
.where((channel) => _hasName(channel, name) && _isUsableStarter(channel))
.firstOrNull;
/// The channel that holds [name] in the relay's listed-name rule: an open,
/// not unlisted stream or forum (`LISTED_CHANNEL_SQL` in buzz-db).
Channel? _findListedNamesake(List<Channel> channels, String name) => channels
.where(
(channel) =>
_hasName(channel, name) &&
channel.visibility == 'open' &&
!channel.isUnlisted &&
(channel.isStream || channel.isForum),
)
.firstOrNull;
bool _hasName(Channel channel, String name) =>
channel.name.trim().toLowerCase() == name.trim().toLowerCase();
bool _isUsableStarter(Channel channel) =>
channel.isStream && channel.visibility == 'open' && !channel.isArchived;
bool _isDuplicateChannelError(Object error) =>
error.toString().contains('duplicate: channel already exists');
/// The relay refuses any channel event whose `h` is an archived channel,
/// a create at that id included: `invalid: channel is archived`.
bool _isArchivedChannelError(Object error) =>
error.toString().contains('invalid: channel is archived');
/// The relay refuses creating an open channel whose name another listed
/// channel already holds, ignoring case:
/// `invalid: an open channel named "general" already exists`.
bool _isChannelNameTakenError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('invalid: an open channel named') &&
message.contains('already exists');
}
/// Returns desktop's deterministic per-relay UUID for a public starter.
@visibleForTesting
String desktopStarterChannelId({
required String relayHttpOrigin,
required String slug,
}) {
final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), '');
return const Uuid().v5(
_starterChannelNamespace,
'starter-channel:v1:$scope:$slug',
);
}
/// Builds one fresh, identity-scoped invite recovery against the app container.
InviteJoinRecovery buildMobileInviteJoinRecovery(
Ref ref,
InviteJoinRecoveryScope scope,
) {
final expectedConfig = ref.read(relayConfigProvider);
if (expectedConfig.baseUrl != scope.relayHttpOrigin ||
expectedConfig.nsec != scope.nsec) {
throw StateError('Active community changed before invite recovery');
}
final channelActions = ref.read(channelActionsProvider);
bool isScopeCurrent() {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == scope.relayHttpOrigin &&
currentConfig.nsec == scope.nsec;
}
void ensureScopeCurrent() {
if (!isScopeCurrent()) {
throw StateError('Active community changed during invite recovery');
}
}
return MobileInviteJoinRecovery(
loadChannels: () async {
ensureScopeCurrent();
await ref.read(activeCommunityProvider.future);
ensureScopeCurrent();
await _waitForInviteRelay(ref, isScopeCurrent);
ensureScopeCurrent();
final channels = await ref
.read(channelsProvider.notifier)
.fetchFresh(fetchDirectory: true);
ensureScopeCurrent();
return channels;
},
fetchChannel: (channelId) async {
ensureScopeCurrent();
final channel = await ref
.read(channelsProvider.notifier)
.fetchOpenChannel(channelId);
ensureScopeCurrent();
return channel;
},
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) => channelActions.createChannel(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
),
joinChannel: channelActions.joinChannel,
relayHttpOrigin: scope.relayHttpOrigin,
isScopeCurrent: isScopeCurrent,
);
}
final _mobileRootNavigatorKey = GlobalKey<NavigatorState>();
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ageSignalState = ref.watch(ageSignalProvider);
ref.listen(ageSignalProvider, (_, next) {
if (next == AgeSignalState.restricted) {
ref.read(pairingProvider.notifier).reset();
}
});
final communityTheme = ageSignalState != AgeSignalState.restricted
? ref.watch(communityThemeProvider)
: defaultCommunityTheme;
final themeMode = communityTheme.mode;
// The app rebuilds on every lifecycle change. A new ThemeData never
// equals the last one, so MaterialApp would animate between identical
// themes, and each frame of that makes every message re-parse its
// markdown. Build the themes only when the accent changes.
final accent = communityTheme.accent;
final lightTheme = useMemoized(
() => mobileAppearanceTheme(Brightness.light, accent),
[accent],
);
final darkTheme = useMemoized(
() => mobileAppearanceTheme(Brightness.dark, accent),
[accent],
);
final authState = ref.watch(authProvider);
final pairedCommunity = ref.watch(pairedCommunityLandingProvider);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(ref.read(ageSignalProvider.notifier).request());
});
return null;
}, const []);
// Eagerly initialize websocket session and lifecycle observer when
// authenticated. These providers connect and manage the websocket.
if (ageSignalState != AgeSignalState.restricted &&
authState.value?.status == AuthStatus.authenticated) {
ref.watch(relaySessionProvider);
// Listen, not watch: these publish often, and an App rebuild rebuilds
// every route, including an open chat.
ref.listen(observerRelayProvider, (_, _) {});
ref.watch(appLifecycleProvider);
ref.listen(userStatusCacheProvider, (_, _) {});
// Presence heartbeats run only while something holds this provider.
ref.listen(presenceProvider, (_, _) {});
// Activity owns hidden-DM recovery even when Inbox is never opened.
ref.listen(activityProvider, (_, _) {});
// Preserve initial read seeding even while Solo is hidden.
ref.listen(meUnreadChannelIdsProvider, (_, _) {});
}
// 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(() {
if (ageSignalState != AgeSignalState.restricted) {
applyBadge(ref.read(unreadBadgeProvider));
} else {
AppBadgePlus.updateBadge(0);
}
return null;
}, [ageSignalState]);
if (ageSignalState != AgeSignalState.restricted) {
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
applyBadge(next);
});
}
final home = authState.when(
loading: () => const _SplashScreen(),
// An internal build never offers pairing, even when auth state is
// unreadable: pairing could make another relay the active community.
error: (_, _) => OrganizationEnvironment.enabled
? const OrganizationAppEntry()
: const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => HostedAppGate(
authenticated: true,
child: CoveredRouteKeyboardInsets(
child: HomePage(
onAgents: showAgents,
defaultAgentBuilder: (context) => DefaultAgentLauncher(
onChooseAgent: () => showAgents(context, createOnOpen: true),
),
openBestieConversation: openBestieConversation,
mePageBuilder: (_, chat, workspace) => MePage(
sliver: true,
newChatAction: chat,
newWorkspaceAction: workspace,
),
settingsPageBuilder: _buildSettingsPage,
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
),
),
),
_ => const HostedAppGate(
authenticated: false,
child: SignedOutShareNotice(child: OnboardingAppEntry()),
),
},
);
final latestHome = useRef<Widget?>(null)..value = home;
return MaterialApp(
navigatorKey: _mobileRootNavigatorKey,
navigatorObservers: [appRouteObserver],
title: 'Buzz',
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeMode,
// Above the navigator, so an age restriction cannot be bypassed by a
// route that was pushed while the store signal request was in flight.
builder: (context, child) => switch (ageSignalState) {
AgeSignalState.restricted => const AgeRestrictionPage(),
_ => BuzzToastHost(
child: IosNavigationMetricsHost(
child: AppMarkdownTheme(
child: MobileHuddleShell(
navigatorKey: _mobileRootNavigatorKey,
child: EmojiBurstOverlay(
child: Stack(
fit: StackFit.expand,
children: [
ExcludeSemantics(
excluding: pairedCommunity != null,
child: AbsorbPointer(
absorbing: pairedCommunity != null,
child: child ?? const SizedBox.shrink(),
),
),
// Cover the navigator while onboarding is removed and the
// destination installs the matching avatar-flight route.
if (pairedCommunity != null)
CommunityLoadingSurface(
name: pairedCommunity.name,
relayUrl: pairedCommunity.relayUrl,
),
],
),
),
),
),
),
),
},
// A root route that refuses pops, so a stray pop can never leave an
// empty navigator. It reads [home] when built, so it follows auth.
onGenerateRoute: (settings) => settings.name == Navigator.defaultRouteName
? RootPageRoute<void>(
settings: settings,
builder: (_) => latestHome.value!,
)
: null,
);
}
}
Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent();
class _SettingsPageContent extends ConsumerWidget {
const _SettingsPageContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingsPage(
profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true),
onSetStatus: (context) => showSetStatusSheet(
context,
currentStatus: ref.read(userStatusProvider).asData?.value,
),
onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor,
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'),
),
);
}
}