mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## Summary Unify mobile conversations in Messages with All / Solo / Team filters, a collapsing header, and separate Search, New, and default-agent shortcuts. Move workspace actions into the community menu and keep collapsed sections intact across filters. Align Inbox and list typography, spacing, and muted controls. Preserve native glass layering through navigation and give every filtered list enough clearance above the footer. The default-agent shortcut reads the shared encrypted preference and opens an existing chat immediately. When unset, it opens Agents; a star action selects the default through a production signed, self-encrypted preference writer. Save failures remain visible and retryable. Android now uses rounded navigation controls and filters, optically balanced search icons, and expanding New/community menus with leading Tabler icons matching iOS. Preset avatars retain transparent WebM animation through an Android WebView renderer, with lifecycle pausing and poster fallback. ### Related issue None found in searches for mobile navigation issues and open PRs. ### Testing - Android follow-up: ARM64 debug build, Dart analysis, and emulator checks passed for menu placement, selection, icon alignment, search, and avatar playback/pause/resume. The avatar implementation passed 3,840 Flutter tests and native Android tests before subsequent styling iterations. Automated tests for the later menu/icon iterations were skipped at the author’s request; those earlier results do not cover the final styling changes. - Mobile formatting/analysis and full test suite (3,838 passed, 23 skipped, plus 4 push-configuration tests); regression journey covers filtering, collapse retention, and scrolling the last Solo row above the footer. - iPhone 18 Pro simulator (Honeycomb): filters, menus, Inbox, Invite/Back, Switch Community, and default-agent chat/Back exercised. iOS release build and Pairing Preview install/launch passed during iteration, preserving the saved profile. - P1 follow-up: production writer-to-reader widget journey covers selection, publish failure/retry, and the unset footer opening Agents. Simulator Agents open/close passes; its saved community has no selectable agents, so live selection was not verified there. - Independent agent review completed; its Solo footer-clearance finding is fixed. The final clearance adjustment is covered by widget tests; physical-device acceptance of that adjustment and hosted CI remain pending. Final Messages layout:  Switcher layering during iteration (before → after): | Before: filter edge showing through | After: Home hidden behind switcher | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
485 lines
17 KiB
Dart
485 lines
17 KiB
Dart
import 'features/channels/default_agent_launcher.dart';
|
|
import 'features/agents/agents_sheet.dart';
|
|
import 'dart:async';
|
|
import 'hosted_app_gate.dart';
|
|
import 'onboarding_app_entry.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/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/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 '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/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();
|
|
final channels = await ref
|
|
.read(channelsProvider.notifier)
|
|
.fetchFresh(fetchDirectory: true);
|
|
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 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;
|
|
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.
|
|
var hasUnreadInbox = false;
|
|
var hasUnreadSolo = false;
|
|
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, (_, _) {});
|
|
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;
|
|
hasUnreadSolo = ref.watch(
|
|
meUnreadChannelIdsProvider.select((ids) => ids.isNotEmpty),
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
return MaterialApp(
|
|
navigatorKey: _mobileRootNavigatorKey,
|
|
navigatorObservers: [appRouteObserver],
|
|
title: 'Buzz',
|
|
theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent),
|
|
darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent),
|
|
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(),
|
|
_ => 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
home: authState.when(
|
|
loading: () => const _SplashScreen(),
|
|
error: (_, _) => const PairingPage(),
|
|
data: (state) => switch (state.status) {
|
|
AuthStatus.authenticated => HostedAppGate(
|
|
authenticated: true,
|
|
child: HomePage(
|
|
onAgents: showAgents,
|
|
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
|
onChooseAgent: () => showAgents(context),
|
|
),
|
|
mePageBuilder: (_, chat, workspace) => MePage(
|
|
sliver: true,
|
|
newChatAction: chat,
|
|
newWorkspaceAction: workspace,
|
|
),
|
|
settingsPageBuilder: _buildSettingsPage,
|
|
profilePageBuilder: (_) =>
|
|
const _SettingsPageContent(embedded: true),
|
|
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
|
|
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
|
|
hasUnreadInbox: hasUnreadInbox,
|
|
hasUnreadSolo: hasUnreadSolo,
|
|
),
|
|
),
|
|
_ => const HostedAppGate(
|
|
authenticated: false,
|
|
child: OnboardingAppEntry(),
|
|
),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildSettingsPage(BuildContext context) => const _SettingsPageContent();
|
|
|
|
class _SettingsPageContent extends ConsumerWidget {
|
|
const _SettingsPageContent({this.embedded = false});
|
|
final bool embedded;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return SettingsPage(
|
|
embedded: embedded,
|
|
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'),
|
|
),
|
|
);
|
|
}
|
|
}
|