Feature: Digital pet
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
const PLUGIN = "digital-pet";
|
||||
const apiBase = "/api/plugins/digital-pet";
|
||||
|
||||
function getSdk() { return window.__HERMES_PLUGIN_SDK__ || {}; }
|
||||
function fetchJson(path, options) {
|
||||
const sdk = getSdk();
|
||||
const url = apiBase + path;
|
||||
const opts = Object.assign({ headers: { "Content-Type": "application/json" } }, options || {});
|
||||
const doFetch = sdk.authedFetch || fetch;
|
||||
return doFetch(url, opts).then(function (res) {
|
||||
if (!res.ok) throw new Error("digital pet request failed: " + res.status);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function DigitalPetOverlay() {
|
||||
const sdk = getSdk();
|
||||
const React = sdk.React || window.React;
|
||||
const hooks = sdk.hooks || React;
|
||||
const useEffect = hooks.useEffect;
|
||||
const useRef = hooks.useRef;
|
||||
const useState = hooks.useState;
|
||||
const h = React.createElement;
|
||||
const [state, setState] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const mounted = useRef(false);
|
||||
const hoverTimer = useRef(null);
|
||||
const transitionTimer = useRef(null);
|
||||
|
||||
function clearHoverTimer() {
|
||||
if (hoverTimer.current) window.clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
function clearTransitionTimer() {
|
||||
if (transitionTimer.current) window.clearTimeout(transitionTimer.current);
|
||||
transitionTimer.current = null;
|
||||
}
|
||||
function applyRemoteState(data) {
|
||||
if (!mounted.current) return null;
|
||||
setError(null);
|
||||
setState(data.state);
|
||||
return data.state;
|
||||
}
|
||||
function applyRemoteError(err) {
|
||||
if (!mounted.current) return undefined;
|
||||
setError(err.message || String(err));
|
||||
return undefined;
|
||||
}
|
||||
function refresh() {
|
||||
return fetchJson("/state")
|
||||
.then(applyRemoteState)
|
||||
.catch(applyRemoteError);
|
||||
}
|
||||
function send(type, metadata) {
|
||||
return fetchJson("/event", { method: "POST", body: JSON.stringify({ type: type, metadata: metadata || {} }) })
|
||||
.then(applyRemoteState)
|
||||
.catch(applyRemoteError);
|
||||
}
|
||||
|
||||
useEffect(function () {
|
||||
mounted.current = true;
|
||||
return function () {
|
||||
mounted.current = false;
|
||||
clearHoverTimer();
|
||||
clearTransitionTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
refresh();
|
||||
const id = window.setInterval(refresh, 30000);
|
||||
return function () { window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
useEffect(function () {
|
||||
if (!state) return undefined;
|
||||
clearTransitionTimer();
|
||||
const completion = state.mode === "reacting" ? "reaction_complete"
|
||||
: state.mode === "prompting" ? "prompt_complete"
|
||||
: state.mode === "waking" ? "wake_complete"
|
||||
: null;
|
||||
if (!completion) return undefined;
|
||||
transitionTimer.current = window.setTimeout(function () {
|
||||
transitionTimer.current = null;
|
||||
send(completion);
|
||||
}, 2400);
|
||||
return clearTransitionTimer;
|
||||
}, [state && state.mode, state && state.revision]);
|
||||
|
||||
function onClick() {
|
||||
if (!state || state.mode === "hidden") send("summon");
|
||||
else if (state.mode === "sleeping") send("wake");
|
||||
else send("click");
|
||||
}
|
||||
function startHover() {
|
||||
clearHoverTimer();
|
||||
if (!state || state.mode === "hidden" || state.mode === "sleeping") return;
|
||||
hoverTimer.current = window.setTimeout(function () {
|
||||
hoverTimer.current = null;
|
||||
send("hover_hold");
|
||||
}, 1000);
|
||||
}
|
||||
function stopHover() {
|
||||
clearHoverTimer();
|
||||
}
|
||||
|
||||
if (!state) return h("div", { className: "digital-pet-root digital-pet-loading", "aria-live": "polite" }, error || "");
|
||||
if (state.mode === "hidden" || state.enabled === false) {
|
||||
return h("button", { type: "button", className: "digital-pet-summon", onClick: onClick, title: "Summon station companion", "aria-label": "Summon station companion" }, "◆");
|
||||
}
|
||||
const mood = state.mood || "calm";
|
||||
const mode = state.mode || "idle";
|
||||
const face = mode === "sleeping" ? "– –" : mood === "pleased" ? "•‿•" : mood === "curious" ? "•?•" : mood === "tired" ? "-_-" : "•_•";
|
||||
return h("div", { className: "digital-pet-root", "data-mode": mode, "data-mood": mood },
|
||||
state.message ? h("div", { className: "digital-pet-bubble", role: "status", "aria-live": "polite" }, state.message) : null,
|
||||
h("button", { type: "button", className: "digital-pet-avatar", onClick: onClick, onMouseEnter: startHover, onMouseLeave: stopHover, onFocus: stopHover, title: mode === "sleeping" ? "Click to wake Signal" : "Click Signal; hover to sleep", "aria-label": mode === "sleeping" ? "Wake digital pet" : "Interact with digital pet" },
|
||||
h("span", { className: "digital-pet-antenna", "aria-hidden": "true" }, "⌁"),
|
||||
h("span", { className: "digital-pet-face", "aria-hidden": "true" }, face),
|
||||
h("span", { className: "digital-pet-name" }, state.name || "Signal")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function registerWhenReady() {
|
||||
if (!window.__HERMES_PLUGINS__ || !window.__HERMES_PLUGIN_SDK__ || !window.__HERMES_PLUGIN_SDK__.React) {
|
||||
window.setTimeout(registerWhenReady, 50);
|
||||
return;
|
||||
}
|
||||
window.__HERMES_PLUGINS__.registerSlot(PLUGIN, "overlay", DigitalPetOverlay);
|
||||
}
|
||||
registerWhenReady();
|
||||
})();
|
||||
Reference in New Issue
Block a user