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:
65
src/backButton.ts
Normal file
65
src/backButton.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user