SkateSpots/src/hooks/useCitrine.ts
shakespeare.diy 67e422307a Citrine audit: endpoint fallback, a11y, reduced-motion, UX polish (1.3.6)
Full self-audit of the Citrine feature. Fixes found:

Robustness
- Probe now tries BOTH ws://127.0.0.1 and ws://localhost and routes to whichever
  actually answers (probeCitrineEndpoints + CitrineProbeResult.url). We no longer
  bet the whole feature on one loopback address form; useCitrine.url reflects the
  winning endpoint so reads/writes go to the right place.

Correctness
- Preview toggle was mutating the REAL setting while in dev-preview; handleToggle
  is now a no-op under preview (forced state owns the visuals).

Accessibility / "flawless" bar
- Respect prefers-reduced-motion: status pips, chip pop-in and the expand slide
  now motion-reduce:animate-none (no looping glow/pulse for reduced-motion users).
- Recheck button gets an aria-label; status text is role="status" aria-live so
  screen readers announce searching→detected transitions; decorative pips are
  aria-hidden.

UX copy
- "Waiting" state no longer presumes Citrine isn't installed. Clearer two-step
  guidance (Open Citrine → Start; come back → Recheck) covering the common
  "installed but not started/detected yet" case.

Honest note: MIXED_CONTENT_ALWAYS_ALLOW (1.3.5) is the high-probability fix for
the 0-connections block but is not device-verified from here; the dual-endpoint
probe hedges the second most likely cause (address form).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 10:54:22 +07:00

99 lines
4.1 KiB
TypeScript

import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import {
CITRINE_RELAY_URL,
citrinePreview,
isCitrinePossible,
probeCitrineEndpoints,
} from '@/lib/citrine';
/**
* useCitrine — availability + opt-in state for the local on-device Citrine relay.
*
* Exposes everything the app needs to decide whether the local relay should be
* folded into reads/writes:
* - `possible`: cheap sync gate — are we on native Android at all?
* - `reachable`: async probe result — is a Citrine relay actually running?
* - `enabled`: the user's opt-in setting (config.useCitrineRelay).
* - `active`: enabled AND reachable — the single boolean callers should key on.
* - `url`: the effective relay URL to use when `active`, else null.
* - `setEnabled`: toggle the opt-in setting.
*
* The reachability probe is cached via react-query so the whole app shares one
* result and we don't spam localhost connection attempts. On non-Android the
* probe short-circuits to false, so this hook is a safe no-op everywhere else.
*/
export function useCitrine() {
const { config, updateConfig } = useAppContext();
const possible = isCitrinePossible();
const enabled = config.useCitrineRelay ?? false;
const { data: probe, refetch, isFetching, isFetched } = useQuery({
queryKey: ['citrine-reachable'],
// Only bother probing when the platform could host Citrine AND the user has
// opted in — no point connecting to localhost otherwise.
enabled: possible && enabled,
// Try each candidate endpoint (127.0.0.1 / localhost) and use whichever
// actually answers as a Nostr relay.
queryFn: () => probeCitrineEndpoints(),
staleTime: 60_000,
// Poll only while we're still WAITING for Citrine (not yet reachable), so a
// freshly-started relay is picked up automatically. Once connected, stop the
// background churn — a per-session localhost relay doesn't vanish silently,
// and any failed read will naturally re-probe. This avoids opening a fresh
// ws:// probe every few minutes for the whole session.
refetchInterval: (query) => (query.state.data?.ok ? false : 30_000),
// Re-verify on focus only until connected (e.g. user returns after starting
// Citrine). No need to re-poke localhost on every Amber round-trip once up.
refetchOnWindowFocus: (query) => !query.state.data?.ok,
retry: false,
});
const reachable = probe?.ok ?? false;
const active = possible && enabled && reachable;
// DEV PREVIEW ONLY: a forced visual state for browser design review. Purely
// cosmetic — it never affects `active`/`url`, so no traffic is ever rerouted.
const preview = citrinePreview();
const setEnabled = useCallback(
(next: boolean) => {
updateConfig((current) => ({ ...current, useCitrineRelay: next }));
},
[updateConfig],
);
return {
possible,
enabled,
reachable,
active,
/** True while a reachability probe is in flight (for spinner UI). */
probing: isFetching,
/**
* True once a probe has completed at least once. Used to avoid flashing the
* "not detected" state on the very first render after enabling, before the
* initial probe has had a chance to run.
*/
settled: isFetched,
/** Diagnostic reason from the last probe (shown on-device for debugging). */
reason: probe?.reason ?? null,
/** The relay URL we probe/connect to — shown in the debug readout. */
probeUrl: probe?.url ?? CITRINE_RELAY_URL,
// Route to the endpoint that actually answered (127.0.0.1 or localhost),
// falling back to the default when active but somehow missing a url.
url: active ? (probe?.url ?? CITRINE_RELAY_URL) : null,
/**
* DEV PREVIEW ONLY — a forced UI state ('off'|'searching'|'live'|'waiting')
* when the citrine:preview flag is set, else null. Cosmetic; does not affect
* `active`/`url`. The Settings card uses it to override its computed state.
*/
preview,
setEnabled,
/** Re-run the reachability probe (e.g. after the user starts Citrine). */
recheck: refetch,
};
}