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.
This commit is contained in:
2026-10-14 21:12:30 -05:00
parent 5888e07586
commit b39b89be20
3 changed files with 77 additions and 3 deletions

65
src/backButton.ts Normal file
View File

@@ -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<PluginListener | null> } | null = null;
function syncListener() {
if (!isAndroid) return;
if (stack.length > 0 && !current) {
const reg: { listener: Promise<PluginListener | null> } = { 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]);
}

View File

@@ -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 (
<div className="dialog-backdrop" onClick={onCancel}>
@@ -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 (
<HoverCtx.Provider value={{ enter, move, leave }}>

View File

@@ -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);