import { useEffect, useMemo, useRef, useState } from "react"; import { apiClient } from "../../../api-client"; import type { ScannerFlowPayload, ScannerHiloPayload, ScannerPayload, } from "../../../api-client"; import type { PaneFooterSegment } from "../../../components"; import { tf } from "../../../i18n"; import { useCloudAccessFooter } from "../shared/cloud-upgrade"; import { usePaneStatusFooter } from "../shared/pane-footer"; import { CLOUD_QUOTE_DELAY_MINUTES } from "../shared/plan-access"; import { useAppVisible } from "../../../state/app/activity"; export interface ScannerFeedState { payload: T | null; /** True once the server refused the subscription (not signed in, or not Pro). */ denied: boolean; deniedReason: string | null; } const INITIAL_STATE = { payload: null, denied: false, deniedReason: null } as const; function useScannerFeed(scanner: "hilo" | "flow"): ScannerFeedState { const [state, setState] = useState>(INITIAL_STATE); // A hidden app drops the subscription like every other stream; the last // payload stays on screen until the server resends on return. const appVisible = useAppVisible(); const subscribedScannerRef = useRef(scanner); useEffect(() => { if (subscribedScannerRef.current !== scanner) { subscribedScannerRef.current = scanner; setState(INITIAL_STATE); } if (!appVisible) return; return apiClient.subscribeScanner(scanner, (event) => { setState(event.type === "denied" ? { payload: null, denied: true, deniedReason: event.reason } : { payload: event.payload as unknown as T, denied: false, deniedReason: null }); }); }, [appVisible, scanner]); return state; } export function useHiloFeed(): ScannerFeedState { return useScannerFeed("hilo"); } export function useFlowFeed(): ScannerFeedState { return useScannerFeed("flow"); } /** * The only status a scanner pane can change between: the cloud entitlement this * feed arrived on, stream health while it is entitled, or the auth/entitlement * refusal. The delay comes from the payload, since the server moves a session * between the realtime and delayed instances without a remount. */ export function useScannerStatusFooter( registrationId: string, state: ScannerFeedState, focused: boolean, extra: PaneFooterSegment | null = null, ): void { const { hint: upgradeHint, segment } = useCloudAccessFooter({ delayLabel: tf("{count}m", { count: state.payload?.delayMinutes || CLOUD_QUOTE_DELAY_MINUTES }), degraded: state.payload?.access === "delayed", focused, segmentId: `${registrationId}-access`, shortcutScope: `${registrationId}:upgrade`, }); const status = useMemo(() => { if (state.denied) { return [{ id: "scanner-status", parts: [{ text: state.deniedReason === "pro_required" ? "pro required" : "sign in required", tone: "warning" as const, }], }]; } const status = state.payload?.status; // The feed connects in the background; nothing to report until it answers. if (!status || status === "starting") return []; if (status === "live") { return [{ id: "scanner-status", parts: [{ text: "live", tone: "positive" as const }] }]; } if (status === "closed") { return [{ id: "scanner-status", parts: [{ text: "market closed", tone: "muted" as const }] }]; } return [{ id: "scanner-status", parts: [{ text: "degraded", tone: "warning" as const }] }]; }, [state.denied, state.deniedReason, state.payload?.status]); // The upgrade segment carries the delay for free accounts; a delayed feed reaching Pro still says so. const delayMinutes = state.payload?.access === "delayed" ? state.payload.delayMinutes || CLOUD_QUOTE_DELAY_MINUTES : null; const info = useMemo( () => { const base = segment ? [segment, ...status] : delayMinutes != null ? [{ id: "scanner-delay", parts: [{ text: tf("{delay} delayed", { delay: tf("{count}m", { count: delayMinutes }) }), tone: "muted" as const }] }, ...status] : status; return extra ? [...base, extra] : base; }, [delayMinutes, extra, segment, status], ); usePaneStatusFooter({ registrationId, info, hints: upgradeHint ? [upgradeHint] : undefined }); }