From b39b89be20d3b656d704f728adc558b2f144f88f Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Wed, 14 Oct 2026 21:12:30 -0500 Subject: [PATCH] Android back button closes modals and leaves the News set browser Adds useBackButton, a stack of back-button claimants (last claimed wins) backed by Tauri's app-plugin back-button event. The listener is only registered while something has claimed the button, so at the root back keeps its native leave-the-app behavior. Claimed by: the click-to-preview modal, DialogFrame (so Suggest cards, Sample hand and every other dialog), and NewsView while a set's card list is open. A preview opened from inside a dialog closes first. --- src/backButton.ts | 65 ++++++++++++++++++++++++++++++++++++++++++ src/components.tsx | 10 +++++-- src/views/NewsView.tsx | 5 ++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/backButton.ts diff --git a/src/backButton.ts b/src/backButton.ts new file mode 100644 index 0000000..b526501 --- /dev/null +++ b/src/backButton.ts @@ -0,0 +1,65 @@ +// Android hardware/gesture back button, routed to whatever's on top: an open +// modal, a drill-down view, etc. +// +// Tauri's app plugin only hands the back press to JS while a `back-button` +// listener is registered; with none, it falls back to the native behavior +// (leave/background the app). So the listener is registered only while +// something has claimed the back button — at the root, back behaves exactly +// like any other Android app. +import { onBackButtonPress } from "@tauri-apps/api/app"; +import { PluginListener } from "@tauri-apps/api/core"; +import { useEffect, useRef } from "react"; + +// The plugin's register/remove commands only exist on mobile. +const isAndroid = /Android/i.test(navigator.userAgent); + +/** Claimants in the order they claimed the back button; the last one wins. */ +const stack: { handle: () => void }[] = []; + +// Registration is async IPC, so a quick close-then-open can briefly leave an +// old listener still registered alongside its replacement. Each one checks +// it's the current one before acting, so a press never pops twice. +let current: { listener: Promise } | null = null; + +function syncListener() { + if (!isAndroid) return; + if (stack.length > 0 && !current) { + const reg: { listener: Promise } = { listener: Promise.resolve(null) }; + reg.listener = onBackButtonPress(() => { + if (current === reg) stack[stack.length - 1]?.handle(); + }).catch((e) => { + console.error("back button: failed to register listener", e); + return null; + }); + current = reg; + } else if (stack.length === 0 && current) { + const { listener } = current; + current = null; + listener.then((l) => l?.unregister()).catch(() => {}); + } +} + +/** + * While `active`, the Android back button calls `onBack` instead of leaving + * the app — or of reaching anything that claimed it earlier, so a card + * preview opened from inside a dialog closes before the dialog does. + * `onBack` is expected to undo whatever made the caller active (close the + * modal, leave the drill-down). + */ +export function useBackButton(onBack: () => void, active = true) { + const onBackRef = useRef(onBack); + onBackRef.current = onBack; + // Depends only on `active`, not `onBack`: re-running would move this entry + // to the top of the stack, above things that claimed it after us. + useEffect(() => { + if (!active) return; + const entry = { handle: () => onBackRef.current() }; + stack.push(entry); + syncListener(); + return () => { + const i = stack.indexOf(entry); + if (i >= 0) stack.splice(i, 1); + syncListener(); + }; + }, [active]); +} diff --git a/src/components.tsx b/src/components.tsx index 909fb10..368c90a 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -19,6 +19,7 @@ import { maskToLetters, PreviewCard, } from "./api"; +import { useBackButton } from "./backButton"; // --- Icons ------------------------------------------------------------ @@ -148,9 +149,10 @@ export const Icons = { /** * A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`], * [`PromptDialog`], and other dialogs built outside this module (e.g. the - * command palette). Dismisses on Escape, backdrop click, or the title-bar - * close button — the explicit button matters most on mobile, where there's - * no Escape key and a `wide` dialog can leave little to no backdrop to tap. + * command palette). Dismisses on Escape, Android back, backdrop click, or the + * title-bar close button — the explicit button matters most on mobile, where + * there's no Escape key and a `wide` dialog can leave little to no backdrop + * to tap. */ export function DialogFrame({ title, @@ -169,6 +171,7 @@ export function DialogFrame({ window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onCancel]); + useBackButton(onCancel); return (
@@ -732,6 +735,7 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) { window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [clicked, close]); + useBackButton(close, !!clicked); return ( diff --git a/src/views/NewsView.tsx b/src/views/NewsView.tsx index 4fff279..88727a7 100644 --- a/src/views/NewsView.tsx +++ b/src/views/NewsView.tsx @@ -9,6 +9,7 @@ import { applyCardCriteria, useCardCriteria, } from "../components"; +import { useBackButton } from "../backButton"; /** Sets whose release date is still ahead of us, newest-announced first. */ function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } { @@ -98,6 +99,10 @@ export function NewsView() { load(false); }, [load]); + // Android back from a set's card list returns to the news, like the + // "Back to news" button. + useBackButton(() => setOpenSet(null), !!openSet); + function openSetBrowser(set: SetInfo) { setOpenSet(set); setCards(null);