diff --git a/src/state/shell/hardware-back-press.ts b/src/state/shell/hardware-back-press.ts new file mode 100644 index 0000000000..766e0ff61f --- /dev/null +++ b/src/state/shell/hardware-back-press.ts @@ -0,0 +1,31 @@ +type HardwareBackPressFallback = () => boolean + +let fallback: HardwareBackPressFallback | undefined + +/** + * Registers the fallback handler for the Android hardware back press. The + * shell's back handler runs it only after any active overlays (lightbox, + * dialogs, composer, drawer) have been given the chance to close, so the + * fallback never steals the press from them. Return true from the fallback to + * consume the press, false to allow the system default behavior. + * + * Returns a function that unregisters the fallback (if it is still the + * registered one). + */ +export function setHardwareBackPressFallback( + fn: HardwareBackPressFallback, +): () => void { + fallback = fn + return () => { + if (fallback === fn) { + fallback = undefined + } + } +} + +/** + * Runs the registered fallback, returning true if it consumed the back press. + */ +export function runHardwareBackPressFallback(): boolean { + return fallback?.() ?? false +} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 331c62e0d1..86bc78e8cd 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -28,6 +28,7 @@ import { } from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {useSession} from '#/state/session' +import {setHardwareBackPressFallback} from '#/state/shell/hardware-back-press' import {PostFeed} from '#/view/com/posts/PostFeed' import {FAB} from '#/view/com/util/fab/FAB' import {type ListMethods} from '#/view/com/util/List' @@ -37,7 +38,7 @@ import {useTheme} from '#/alf' import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig' import {useAnalytics} from '#/analytics' -import {IS_NATIVE} from '#/env' +import {IS_ANDROID, IS_NATIVE} from '#/env' const POLL_FREQ = 60e3 // 60sec @@ -120,6 +121,26 @@ export function FeedPage({ return listenSoftReset(onSoftReset) }, [onSoftReset, isPageFocused]) + /* + * On Android, a hardware back press on the Home screen scrolls back to top + * when the feed is scrolled down, instead of leaving the app. + */ + useEffect(() => { + if (!IS_ANDROID || !isPageFocused) { + return + } + return setHardwareBackPressFallback(() => { + const isScreenFocused = + getTabState(getRootNavigation(navigation).getState(), 'Home') === + TabState.InsideAtRoot + if (isScreenFocused && isScrolledDown) { + scrollToTop() + return true + } + return false + }) + }, [navigation, isPageFocused, isScrolledDown, scrollToTop]) + const onPressCompose = useCallback(() => { openComposer({logContext: 'Fab'}) }, [openComposer]) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 126ce485b6..d18f667bdc 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -18,6 +18,7 @@ import { useIsDrawerSwipeDisabled, useSetDrawerOpen, } from '#/state/shell' +import {runHardwareBackPressFallback} from '#/state/shell/hardware-back-press' import {useCloseAnyActiveElement} from '#/state/util' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {Deactivated} from '#/screens/Deactivated' @@ -54,6 +55,7 @@ function ShellInner() { const {state: policyUpdateState} = usePolicyUpdateContext() const closeAnyActiveElement = useCloseAnyActiveElement() + const isDrawerOpen = useIsDrawerOpen() useNotificationsRegistration() useNotificationsHandler() @@ -61,14 +63,25 @@ function ShellInner() { useEffect(() => { if (IS_ANDROID) { const listener = BackHandler.addEventListener('hardwareBackPress', () => { - return closeAnyActiveElement() + if (closeAnyActiveElement()) { + return true + } + /* + * closeAnyActiveElement doesn't report closing the drawer, so check + * it here: when the drawer was open, keep the previous default + * behavior rather than running the fallback underneath it. + */ + if (isDrawerOpen) { + return false + } + return runHardwareBackPressFallback() }) return () => { listener.remove() } } - }, [closeAnyActiveElement]) + }, [closeAnyActiveElement, isDrawerOpen]) // HACK // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually