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,
|
maskToLetters,
|
||||||
PreviewCard,
|
PreviewCard,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { useBackButton } from "./backButton";
|
||||||
|
|
||||||
// --- Icons ------------------------------------------------------------
|
// --- Icons ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -148,9 +149,10 @@ export const Icons = {
|
|||||||
/**
|
/**
|
||||||
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
|
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
|
||||||
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
|
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
|
||||||
* command palette). Dismisses on Escape, backdrop click, or the title-bar
|
* command palette). Dismisses on Escape, Android back, backdrop click, or the
|
||||||
* close button — the explicit button matters most on mobile, where there's
|
* title-bar close button — the explicit button matters most on mobile, where
|
||||||
* no Escape key and a `wide` dialog can leave little to no backdrop to tap.
|
* there's no Escape key and a `wide` dialog can leave little to no backdrop
|
||||||
|
* to tap.
|
||||||
*/
|
*/
|
||||||
export function DialogFrame({
|
export function DialogFrame({
|
||||||
title,
|
title,
|
||||||
@@ -169,6 +171,7 @@ export function DialogFrame({
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [onCancel]);
|
}, [onCancel]);
|
||||||
|
useBackButton(onCancel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog-backdrop" onClick={onCancel}>
|
<div className="dialog-backdrop" onClick={onCancel}>
|
||||||
@@ -732,6 +735,7 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) {
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [clicked, close]);
|
}, [clicked, close]);
|
||||||
|
useBackButton(close, !!clicked);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCtx.Provider value={{ enter, move, leave }}>
|
<HoverCtx.Provider value={{ enter, move, leave }}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
applyCardCriteria,
|
applyCardCriteria,
|
||||||
useCardCriteria,
|
useCardCriteria,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
import { useBackButton } from "../backButton";
|
||||||
|
|
||||||
/** Sets whose release date is still ahead of us, newest-announced first. */
|
/** Sets whose release date is still ahead of us, newest-announced first. */
|
||||||
function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } {
|
function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } {
|
||||||
@@ -98,6 +99,10 @@ export function NewsView() {
|
|||||||
load(false);
|
load(false);
|
||||||
}, [load]);
|
}, [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) {
|
function openSetBrowser(set: SetInfo) {
|
||||||
setOpenSet(set);
|
setOpenSet(set);
|
||||||
setCards(null);
|
setCards(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user