import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import QRCode from "qrcode"; import { Check, Loader2, Network, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { usePageVisibility } from "@/hooks/usePageVisibility"; import { cancelChannelConnect, pollChannelConnect, startChannelConnect, } from "@/lib/api"; import type { ChannelConnectPayload, NanobotFeaturesPayload, } from "@/lib/types"; export type ChannelQrConnectLabels = { qrAlt: string; scanTitle: string; scanDescription: string; waiting: string; connected: string; stopped: string; connecting: string; scanAgain: string; connect: string; }; export type ChannelConnectStartOptions = { domain?: string; instanceId?: string; mode?: "replace" | "create"; force?: boolean; }; export type ChannelQrConnectPendingContext = { connect: ChannelConnectPayload; busy: boolean; poll: ( params?: Readonly>, ) => Promise; }; export function ChannelQrConnectFlow({ token, channelName, startOptions = {}, idleLabel, connectRequestId, forceOnRepeat = false, labels, onFeaturesUpdate, pausePolling, renderPending, resolveMessage, suppressSucceeded = false, }: { token: string; channelName: string; startOptions?: ChannelConnectStartOptions; idleLabel?: string; connectRequestId?: number; forceOnRepeat?: boolean; labels: ChannelQrConnectLabels; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; pausePolling?: (payload: ChannelConnectPayload) => boolean; renderPending?: (context: ChannelQrConnectPendingContext) => ReactNode; resolveMessage?: (payload: ChannelConnectPayload) => string | undefined; suppressSucceeded?: boolean; }) { const pageVisible = usePageVisibility(); const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const [connect, setConnect] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [handledRequestId, setHandledRequestId] = useState(0); const pollInFlight = useRef(false); const tokenRef = useRef(token); tokenRef.current = token; const startDomain = startOptions.domain; const startInstanceId = startOptions.instanceId; const startMode = startOptions.mode; const startForce = startOptions.force; const pending = connect?.status === "pending"; const succeeded = connect?.status === "succeeded"; const canStart = !pending && !busy; const pollingPaused = Boolean(connect && pausePolling?.(connect)); const displayMessage = connect ? resolveMessage?.(connect) ?? connect.message : undefined; useEffect(() => { if (!connect?.qr_url) { setQrDataUrl(""); return; } let cancelled = false; void QRCode.toDataURL(connect.qr_url, { width: 184, margin: 1, color: { dark: "#111827", light: "#ffffff" }, }) .then((url) => { if (!cancelled) setQrDataUrl(url); }) .catch(() => { if (!cancelled) setQrDataUrl(""); }); return () => { cancelled = true; }; }, [connect?.qr_url]); useEffect(() => { if ( !connect?.session_id || connect.status !== "pending" || pollingPaused || !pageVisible ) return; let cancelled = false; const sessionId = connect.session_id; const poll = async () => { if (pollInFlight.current) return; pollInFlight.current = true; try { const payload = await pollChannelConnect( tokenRef.current, channelName, sessionId, ); if (cancelled) return; setConnect((current) => ({ ...(current ?? payload), ...payload, qr_url: payload.qr_url ?? current?.qr_url, })); if (payload.nanobot_features) { onFeaturesUpdate(payload.nanobot_features); } if (payload.status !== "pending") { setError(null); } } catch (err) { if (!cancelled) setError((err as Error).message); } finally { pollInFlight.current = false; } }; const initial = window.setTimeout(() => void poll(), 900); const interval = window.setInterval( () => void poll(), Math.max(2500, connect.interval_ms ?? 5000), ); return () => { cancelled = true; window.clearTimeout(initial); window.clearInterval(interval); }; }, [ channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, pageVisible, pollingPaused, ]); const start = useCallback(async (force = false) => { setBusy(true); setError(null); try { const payload = await startChannelConnect(tokenRef.current, channelName, { domain: startDomain, instanceId: startInstanceId, mode: startMode, force: force || startForce, }); setConnect(payload); } catch (err) { setError((err as Error).message); } finally { setBusy(false); } }, [channelName, startDomain, startForce, startInstanceId, startMode]); useEffect(() => { if (!connectRequestId || connectRequestId === handledRequestId) return; setHandledRequestId(connectRequestId); void start(); }, [connectRequestId, handledRequestId, start]); const cancel = async () => { if (!connect?.session_id) { setConnect(null); return; } setBusy(true); try { const payload = await cancelChannelConnect( tokenRef.current, channelName, connect.session_id, ); setConnect(payload); } catch (err) { setError((err as Error).message); } finally { setBusy(false); } }; const submitPoll = async ( params: Readonly> = {}, ): Promise => { if (!connect?.session_id) return null; setBusy(true); setError(null); try { const payload = await pollChannelConnect( tokenRef.current, channelName, connect.session_id, "", params, ); setConnect((current) => ({ ...(current ?? payload), ...payload, qr_url: payload.qr_url ?? current?.qr_url, })); if (payload.nanobot_features) { onFeaturesUpdate(payload.nanobot_features); } if (payload.status !== "pending") { setError(null); } return payload; } catch (err) { setError((err as Error).message); return null; } finally { setBusy(false); } }; return (
{pending ? (
{qrDataUrl ? ( {labels.qrAlt} ) : ( )}
{labels.scanTitle}

{labels.scanDescription}

{renderPending?.({ connect, busy, poll: submitPoll }) ?? (
{labels.waiting}
)}
) : null} {succeeded && !suppressSucceeded ? (
{displayMessage ?? labels.connected}
) : null} {connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
{displayMessage || labels.stopped}
) : null} {error ? (
{error}
) : null}
); }