From cdd4b057553ebebe664628c4ef16fc8ec025b218 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:34:36 +0000 Subject: [PATCH] Scroll Home feed to top on Android back press when scrolled down On Android, pressing the hardware back button while on the Home screen now scrolls the focused feed back to the top if it is scrolled down (past the same threshold that shows the "Load new posts" button). When already at the top, the press falls through to the default behavior as before. The focused feed page registers a fallback handler in a small module registry, and the shell's existing back handler runs it only after closeAnyActiveElement declines the press, so open dialogs, the lightbox, the composer, and the drawer keep their priority. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Q8GRaUcvhqy2G1M4odtZx --- src/state/shell/hardware-back-press.ts | 31 ++++++++++++++++++++++++++ src/view/com/feeds/FeedPage.tsx | 23 ++++++++++++++++++- src/view/shell/index.tsx | 17 ++++++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/state/shell/hardware-back-press.ts 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