| 1 | /** |
| 2 | * A button at work, for a button that asks the desktop app to do |
| 3 | * something whose effect shows later, in a status the app sends: Retry, |
| 4 | * Start, Stop, Check for runner updates, Restart now. From the click it is |
| 5 | * busy (a spinner, pressed no more, `aria-busy`) until the status it waits |
| 6 | * on changes, the app says it couldn't, or `BUSY_LIMIT` passes with no |
| 7 | * change, so a press is never seen to do nothing and never leaves a |
| 8 | * button dead. The app's own pages do the same (apps/desktop launcher/busy.js). |
| 9 | */ |
| 10 | import { useCallback, useEffect, useState } from "react"; |
| 11 | |
| 12 | /** How long a button waits for the status that follows before it is given back. */ |
| 13 | export const BUSY_LIMIT = 20_000; |
| 14 | |
| 15 | /** A press: the status as it was when the button was pressed, and when. */ |
| 16 | export type Press = { status: string; at: number }; |
| 17 | |
| 18 | /** Whether a press still waits: the status is as it was, and not for too long. */ |
| 19 | export function stillBusy(press: Press | null, status: string, now: number): boolean { |
| 20 | return press !== null && press.status === status && now - press.at < BUSY_LIMIT; |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * A button's busy state, waiting on `status` (any string that changes when |
| 25 | * what the button asked for shows). `run` does the work: with `waits`, the |
| 26 | * button stays busy until the status changes; without, until the work is |
| 27 | * done. A failure gives the button back and goes to `onError`. |
| 28 | */ |
| 29 | export function useBusy(status: string, onError?: (error: unknown) => void) { |
| 30 | const [press, setPress] = useState<Press | null>(null); |
| 31 | const [now, setNow] = useState(0); |
| 32 | // Given back after the limit, with nothing heard. |
| 33 | useEffect(() => { |
| 34 | if (!press) return; |
| 35 | const timer = window.setTimeout(() => setNow(Date.now()), Math.max(0, press.at + BUSY_LIMIT - Date.now())); |
| 36 | return () => window.clearTimeout(timer); |
| 37 | }, [press]); |
| 38 | const busy = stillBusy(press, status, Math.max(now, press?.at ?? 0)); |
| 39 | const run = useCallback( |
| 40 | (work: () => Promise<unknown> | undefined, waits = true) => { |
| 41 | const pressed = { status, at: Date.now() }; |
| 42 | setPress(pressed); |
| 43 | const done = () => setPress((current) => (current === pressed ? null : current)); |
| 44 | Promise.resolve() |
| 45 | .then(work) |
| 46 | .then( |
| 47 | () => { |
| 48 | if (!waits) done(); |
| 49 | }, |
| 50 | (error: unknown) => { |
| 51 | done(); |
| 52 | onError?.(error); |
| 53 | }, |
| 54 | ); |
| 55 | }, |
| 56 | [status, onError], |
| 57 | ); |
| 58 | return { busy, run }; |
| 59 | } |