Files
bsky-social-app/src/App.tsx
T
Claude 6d455c9098 Replace the compose FAB with a Liquid Glass compose pill in the bottom bar
## Summary

- Remove the compose FAB, the new chat FAB, the "load latest" button and the
  Discover/Following inline composer prompt. The new chat entry point still
  needs a replacement.
- Add `features/composePrompt`: screens register a pill config (label,
  accessibility text, an `open` callback) and the bottom bar renders a single
  pill driven by that context. Registration follows the screen's presence, so
  the pill fades in and out with pushes, pops and swipe-back, and the label
  switches to whichever screen is most present mid-transition. The bar's top
  border hides while a pill is registered.
- The pill is a Liquid Glass view with `isInteractive`, toggling its glass
  style with the system animation as it comes and goes, with a gradient behind
  it that fades further than the thread version so content shows through the
  glass. It includes the camera and gallery buttons from the old feed prompt.
- When the bottom bar hides, the pill drops into the bar's space and scales
  down, inset equally from the left, right and bottom edges so its corners sit
  concentric with the device bevels; while the bar shows it hugs the edges.
- Home, Notifications, Feeds, custom feeds and lists open the composer; a
  profile pre-fills a mention as the FAB did; a thread opens the reply
  composer, including media picked from the pill.

## Test plan

- [ ] Home: pill above the bar; tap opens composer; camera/gallery open with media
- [ ] Home: scroll down hides the bar and the pill drops, shrinks and sits inset from the bevels; scroll up restores it
- [ ] Push a thread from Home: label crossfades to "Write your reply" mid-transition; tap opens the reply composer
- [ ] Thread with replies disabled: pill fades out on push and back in on pop
- [ ] Profile of another user: composer opens with their handle
- [ ] Search tab / Messages tab: pill fades out; pill fades back when returning
- [ ] Conversation screen: pill fades out while the bar slides away
- [ ] Android / iOS < 26: plain pill fallback fades in and out
- [ ] Mobile web: pill shows above the web bottom bar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9
2026-09-07 21:25:55 +00:00

275 lines
12 KiB
TypeScript

import '#/logger/sentry/setup'
import {Fragment, useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
initialWindowMetrics,
SafeAreaProvider,
} from 'react-native-safe-area-context'
import * as ScreenOrientation from 'expo-screen-orientation'
import * as SplashScreen from 'expo-splash-screen'
import * as SystemUI from 'expo-system-ui'
import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {Provider as ServiceAccountManager} from '#/state/service-config'
import {
Provider as SessionProvider,
type SessionAccount,
useSession,
useSessionApi,
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import * as Toast from '#/components/Toast'
import {ToastOutlet} from '#/components/Toast'
import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {Provider as ComposePromptProvider} from '#/features/composePrompt'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
void SplashScreen.preventAutoHideAsync()
if (IS_IOS) {
void SystemUI.setBackgroundColorAsync('black')
}
if (IS_ANDROID) {
// iOS is handled by the config plugin -sfn
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
).catch(error =>
logger.debug('Could not lock orientation', {safeMessage: error}),
)
}
/**
* Begin geolocation ASAP
*/
void Geo.resolve()
void prefetchAgeAssuranceConfig()
void prefetchLiveEvents()
void prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedLanding = useLandingEntry()
// init
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
try {
if (account) {
await resumeSession(account)
} else {
await features.init
}
} catch (e) {
logger.warn(`session: resume failed`, {message: e})
}
setIsReady(true)
}
const account = readLastActiveAccount()
void onLaunch(account)
}, [resumeSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {
type: 'info',
})
})
}, [l])
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider>
<Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<BetaUserStorageSync />
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<ComposePromptProvider>
<GestureHandlerRootView
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</ComposePromptProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</Fragment>
</VideoVolumeProvider>
</Splash>
</ContextMenuProvider>
</ThemeProvider>
</Alf>
)
}
function App() {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
() => setIsReady(true),
)
}, [])
if (!isReady) {
return null
}
/*
* NOTE: only nothing here can depend on other data or session state, since
* that is set up in the InnerApp component above.
*/
return (
<Geo.Provider>
<AppConfigProvider>
<A11yProvider>
<KeyboardControllerProvider preload={false}>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<LandingProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</LandingProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
export default Sentry.wrap(App)