From 8d7fbfc99c5bc793ae5f2b129f7adee60c58b8ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:39:41 +0200 Subject: [PATCH] fix: clear the Files selection on Android back instead of switching to Mail --- .../__tests__/use-back-while-focused.test.ts | 58 +++++++++++++++++++ src/lib/use-back-while-focused.ts | 23 ++++++++ src/screens/FilesScreen.tsx | 5 ++ 3 files changed, 86 insertions(+) create mode 100644 src/lib/__tests__/use-back-while-focused.test.ts create mode 100644 src/lib/use-back-while-focused.ts diff --git a/src/lib/__tests__/use-back-while-focused.test.ts b/src/lib/__tests__/use-back-while-focused.test.ts new file mode 100644 index 00000000..209e03f1 --- /dev/null +++ b/src/lib/__tests__/use-back-while-focused.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const handlers: Array<() => boolean> = []; +const remove = vi.fn(); + +vi.mock('react-native', () => ({ + BackHandler: { + addEventListener: vi.fn((_event: string, handler: () => boolean) => { + handlers.push(handler); + return { remove }; + }), + }, +})); + +// Run the focus effect as if the screen had just gained focus and keep its +// cleanup, which react-navigation calls on blur. +let blur: void | (() => void); +vi.mock('@react-navigation/native', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + blur = effect(); + }, +})); + +vi.mock('react', () => ({ useCallback: (fn: T) => fn })); + +import { BackHandler } from 'react-native'; +import { useBackWhileFocused } from '../use-back-while-focused'; + +beforeEach(() => { + vi.clearAllMocks(); + handlers.length = 0; + blur = undefined; +}); + +describe('useBackWhileFocused', () => { + it('runs onBack and consumes the back press while active', () => { + const onBack = vi.fn(); + useBackWhileFocused(true, onBack); + + expect(BackHandler.addEventListener).toHaveBeenCalledWith('hardwareBackPress', expect.any(Function)); + expect(handlers[0]()).toBe(true); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('leaves the back press to navigation while inactive', () => { + useBackWhileFocused(false, vi.fn()); + + expect(BackHandler.addEventListener).not.toHaveBeenCalled(); + }); + + it('stops listening when the screen loses focus', () => { + useBackWhileFocused(true, vi.fn()); + + expect(typeof blur).toBe('function'); + (blur as () => void)(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/use-back-while-focused.ts b/src/lib/use-back-while-focused.ts new file mode 100644 index 00000000..7108d83d --- /dev/null +++ b/src/lib/use-back-while-focused.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; +import { BackHandler } from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; + +/** + * While `active` and the screen is focused, Android's hardware back runs + * `onBack` instead of navigating (e.g. to leave a selection mode first). + * Tab screens stay mounted while another tab is shown, so the listener is + * tied to focus: a plain BackHandler listener would also swallow the back + * presses meant for the other tabs. + */ +export function useBackWhileFocused(active: boolean, onBack: () => void): void { + useFocusEffect( + useCallback(() => { + if (!active) return undefined; + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + onBack(); + return true; + }); + return () => subscription.remove(); + }, [active, onBack]), + ); +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 5b59860a..3cbcbb1b 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -52,6 +52,7 @@ import { jmapClient } from '../api/jmap-client'; import { downloadAttachment, shareAttachment } from '../lib/email-export'; import { secureFetch } from '../lib/client-cert'; import { getUniqueName } from '../lib/filenode-name'; +import { useBackWhileFocused } from '../lib/use-back-while-focused'; import type { FileNode } from '../api/types'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -321,6 +322,10 @@ export default function FilesScreen() { const clearSelection = useCallback(() => setSelection(new Set()), []); + // Android back leaves selection mode first, like the header's X, instead + // of switching to the Mail tab. + useBackWhileFocused(selectionMode, clearSelection); + // Shared-with-me rows stay out of multi-select: batch delete routes to our // own account and would fail (or worse, mismatch) on namespaced ids. const toggleSelect = useCallback((row: FileRow) => {