import { useEffect, useState } from "react" import type { ApiError, WidgetDef } from "@/client" import { useLiveValue } from "@/components/Flow/liveStore" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" // The transmit ring's rule lives with the dashboard's own geometry, and CSS is // chunked per entry — so the sheet is pulled in wherever the pulse is drawn. import "./ui/core/core.css" import { useDataContext } from "./dataContext" import { usePublishMessage } from "./queries" import { useLocked } from "./settings" /** * How long a control shows what it sent before falling back to the engine. * * ponytail: a flat 3 s rather than anything the engine tells us. A publish the * server takes but nothing ever echoes — a message no flow consumes — would * otherwise leave the tile holding a value that is not the truth, forever. */ const HOLD_MS = 3000 /** Whether what came back over the socket is what this control sent. */ function confirms(live: unknown, sent: unknown): boolean { // Readings are scalars nearly always; a dropdown may carry a record and a // colour wheel an array, and comparing those as text is cheaper than a deep // walk for the same answer. return live === sent || JSON.stringify(live) === JSON.stringify(sent) } /** * Publishing, with the value shown as sent until the engine confirms it. * * A control publishes over HTTP and reads the result back over the socket, so * between the two the live value is still the old one — a handle let go of * would snap back to it. The hold ends when the echo matches, when the publish * is refused, or on `HOLD_MS`; success is silent, because the echo is the * confirmation. * * A locked dashboard is gated here and only here: every control publishes * through this hook, so one check covers all of them and a control added later * inherits it. `locked` is handed back so each control can also *read* as * disabled — a dashboard that silently swallows a press looks broken rather * than locked. * * ponytail: this is a read-only surface, not an authorisation boundary. The * server still takes a publish from a panel credential whose dashboard says * locked, because the credential's own allowlist is what bounds it. Making it * a real lock means carrying the flag into `_panel_may`. * * Its own module rather than `widgets.tsx`, which every widget file is * imported *by*: a control drawn in a file of its own can only reach this * without closing that circle if it does not sit there. */ export function usePublish(widget: WidgetDef, dashboard: string) { const cfg = (widget.config ?? {}) as Record const target = cfg.target == null ? "" : String(cfg.target) // A dashboard opened against a run or a replay is a picture of something // that already happened; there is nothing for a control to publish to. const context = useDataContext() const locked = useLocked() || Boolean(context?.readOnly) const publish = usePublishMessage() const live = useLiveValue(target || undefined) const { showErrorToast } = useCustomToast() // Boxed: holding `false` or `null` is not the same as holding nothing. const [held, setHeld] = useState<{ value: unknown } | null>(null) useEffect(() => { if (!held) return const timer = setTimeout(() => setHeld(null), HOLD_MS) return () => clearTimeout(timer) }, [held]) useEffect(() => { if (held && confirms(live?.value, held.value)) setHeld(null) }, [held, live]) return { target, /** What the control draws: what it sent, until the engine answers. */ value: held ? held.value : live?.value, /** Whether this dashboard is read-only; controls draw themselves disabled. */ locked, send: (value: unknown) => { if (!target || locked) return setHeld({ value }) publish.mutate( { name: target, value, dashboard, widget: widget.id, label: widget.title || widget.id, kind: widget.type, }, { onError: (error) => { // Back to the engine's own value, and say which message refused it // — a panel showing several controls cannot tell them apart. setHeld(null) handleError.call( (detail: string) => showErrorToast(`${target}: ${detail}`), error as ApiError, ) }, }, ) }, /** * The in-flight pulse, drawn as a ring around the whole tile. * * A marker rather than the ring itself: the body a control sits in is a * query container, which is a containing block for anything absolute * inside it and clips besides — so the frame draws the ring when it sees * this, and a control need only say that it is publishing. */ pulse: publish.isPending ? ( ) : null, pending: publish.isPending, } }