diff --git a/changelog.d/107-face-to-face-avatars.changed.md b/changelog.d/107-face-to-face-avatars.changed.md new file mode 100644 index 000000000..ffffceaf2 --- /dev/null +++ b/changelog.d/107-face-to-face-avatars.changed.md @@ -0,0 +1 @@ +**Face-to-face pick and wait lists use each Bot's own avatar in a wrapping tile grid.** The "choose which of your Bots to bring" tiles and the live waiting roster use the same host `BotAvatar` as the sidebar / session list / chat header (configured image, character, or color; a friend's Bot uses its chat record or handle-seeded look, plus any avatar the member payload carries). Tiles wrap several per row (avatar on top, name centered below with ellipsis); yours is a tiny accent dot — no owner subtitle and no **mine** badge. The same grid is the Bot picker (tap a tile; a check overlay marks the ones you bring). **Leave** and **Enter chat** share one footer row while a long grid scrolls under the countdown. ([#107](https://github.com/ThinkFlowLab/FlyBot/pull/107)) diff --git a/core/client.js b/core/client.js index 975f63898..c16404854 100644 --- a/core/client.js +++ b/core/client.js @@ -1413,149 +1413,6 @@ function pluginItems(structure, bot, actions) { }]); } -// src/client/flybot-slots.js -var import_react = require("react"); -var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); -function registry2() { - const slots = registry(); - globalThis.__flybot__.call ??= call2; - return slots; -} -var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); -function useSlot(name) { - const slots = registry2(); - const [, setVersion] = (0, import_react.useState)(0); - (0, import_react.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); - return slots.entries(name); -} -var SlotBoundary = class extends import_react.Component { - constructor(props) { - super(props); - this.state = { failed: false }; - } - static getDerivedStateFromError() { - return { failed: true }; - } - componentDidCatch(error) { - console.error(`flybot: ${this.props.id} failed`, error); - } - render() { - return this.state.failed ? null : this.props.children; - } -}; -function Safe({ entry, props }) { - return (0, import_react.createElement)(SlotBoundary, { id: entry.id }, (0, import_react.createElement)(entry.component, props)); -} -function useSidebarStructure() { - return useSlot("flybot.sidebar.structure")[0]; -} -function FlyBotSections({ roster, actions, renderBot }) { - const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); - const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); - const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); - return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( - "div", - { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, - (0, import_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) - ))); -} -function FlyBotSessionDock({ sessionId, useRoster, actions }) { - const entries2 = useSlot("flybot.session.dock"); - const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; - if (entries2.length === 0) return null; - const room2 = roster?.roomsById?.[sessionId]; - const bot = room2 ? void 0 : roster?.byId?.[sessionId]; - return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( - "div", - { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, - (0, import_react.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } }) - ))); -} -function FlyBotRoomOverview({ room: room2, roster, actions }) { - const entries2 = useSlot("flybot.room.overview"); - if (entries2.length === 0) return null; - return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( - "div", - { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, - (0, import_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) - ))); -} -function FlyBotDetails({ bot, roster }) { - const entries2 = useSlot("flybot.bot.details"); - if (entries2.length === 0) return null; - return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( - "div", - { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, - (0, import_react.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) - ))); -} -var POLL_MS = 5e3; -function FlyBotApprovals() { - const entries2 = useSlot("flybot.approvals"); - const [pending, setPending] = (0, import_react.useState)([]); - const [busy, setBusy] = (0, import_react.useState)(""); - const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => { - }); - (0, import_react.useEffect)(() => { - void refresh(); - const timer = setInterval(refresh, POLL_MS); - return () => clearInterval(timer); - }, []); - if (pending.length === 0) return null; - const [error, setError] = (0, import_react.useState)(""); - const decide = async (id, decision) => { - setBusy(id); - setError(""); - try { - await call2("decide", { id, decision }); - } catch (failure) { - setError(failure?.message ?? String(failure)); - } finally { - setBusy(""); - await refresh(); - } - }; - return (0, import_react.createElement)( - "div", - { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") }, - pending.map((approval) => { - const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by); - if (custom) return (0, import_react.createElement)("div", { key: approval.id }, (0, import_react.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); - const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to; - return (0, import_react.createElement)( - "div", - { key: approval.id, className: "bt-flybot-approval" }, - (0, import_react.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), - (0, import_react.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), - approval.request.attachments?.length ? (0, import_react.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, - (0, import_react.createElement)( - "div", - { className: "bt-flybot-approval-actions" }, - approval.status === "failed" ? (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, - approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")), - approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) - ) - ); - }), - error ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); -} -var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; -var FLYBOT_CSS = ` -.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } -.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } -.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } - -.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } -.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } -.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } -.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } -.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } -.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } -.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } -.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } -`; - // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], @@ -1657,52 +1514,357 @@ for (const [id, character] of Object.entries(CHARACTERS)) { ].join(""); SHAPES[id] = { label: character.label ?? id[0].toUpperCase() + id.slice(1), size: 100, path: character.body, layers, eyes: character.eyes }; } -var hasShape = (id) => typeof id === "string" && Object.hasOwn(SHAPES, id); -var shapeId = (id) => hasShape(id) ? id : FALLBACK_SHAPE; -var shapeOf = (id) => SHAPES[shapeId(id)]; -function bodyOf(def, paint, flat = false) { - if (def.path) return `${flat ? "" : def.layers}`; - try { - return def.body(paint); - } catch { - return ""; - } +var hasShape = (id) => typeof id === "string" && Object.hasOwn(SHAPES, id); +var shapeId = (id) => hasShape(id) ? id : FALLBACK_SHAPE; +var shapeOf = (id) => SHAPES[shapeId(id)]; +function bodyOf(def, paint, flat = false) { + if (def.path) return `${flat ? "" : def.layers}`; + try { + return def.body(paint); + } catch { + return ""; + } +} +var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``; +var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``; +var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; +var holes = (def, eyes) => `${eyes}`; +var MASK_ID = "%MASK%"; +var markSerial = 0; +var nextMarkSerial = () => markSerial += 1; +var markupCache = /* @__PURE__ */ new Map(); +function characterMarkup(shape, color) { + const key = `${shape}|${color}`; + let markup = markupCache.get(key); + if (markup === void 0) { + const def = shapeOf(shape); + const eyes = `${def.eyes.map(eyeMarkup).join("")}${def.eyes.map(happyEyeMarkup).join("")}`; + const stops = GRADIENTS[color]; + const gradient = stops ? `${stops.map(([offset, value]) => ``).join("")}` : ""; + const body = `${bodyOf(def, stops ? `url(#${MASK_ID}g)` : inkFill(color))}`; + markup = `${gradient}${holes(def, eyes)}${def.wrap ? `${body}` : body}`; + markupCache.set(key, markup); + } + return markup; +} +var svgUrl = (inner, size) => `url("data:image/svg+xml,${encodeURIComponent(`${inner}`)}")`; +var maskCache = /* @__PURE__ */ new Map(); +function maskOf(shape) { + let mask = maskCache.get(shape); + if (mask === void 0) { + const def = shapeOf(shape); + const body = `${bodyOf(def, "#000", true)}`; + mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size); + maskCache.set(shape, mask); + } + return mask; +} +var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); + +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +function useGaze(ref, enabled) { + (0, import_react.useEffect)(() => { + if (!enabled) return void 0; + let frame = 0; + let point = null; + const place = () => { + frame = 0; + const node = ref.current; + if (!node || !point) return; + const box = node.getBoundingClientRect(); + const clamp = (value) => Math.max(-1, Math.min(1, value)); + node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); + node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); + }; + const onMove = (event) => { + point = { x: event.clientX, y: event.clientY }; + if (!frame) frame = requestAnimationFrame(place); + }; + const onLeave = () => { + point = null; + ref.current?.style.setProperty("--bt-gx", "0"); + ref.current?.style.setProperty("--bt-gy", "0"); + }; + window.addEventListener("pointermove", onMove, { passive: true }); + document.documentElement.addEventListener("pointerleave", onLeave); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("pointermove", onMove); + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); +} +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? lookOf(bot); + const ref = (0, import_react.useRef)(null); + const maskId = (0, import_react.useRef)(""); + if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; + const [poked, setPoked] = (0, import_react.useState)(false); + useGaze(ref, gaze); + const common = { + ref, + className: poked ? "bt-mark bt-mark-poke" : "bt-mark", + "data-shape": look.image ? "image" : shapeId(look.shape), + "data-state": STATES.includes(state) ? state : "idle", + "data-live": live ? "" : void 0, + "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, + "data-gaze": gaze ? "" : void 0, + "data-poke": pokeable ? "" : void 0, + style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, + role: label ? "img" : void 0, + "aria-label": label, + "aria-hidden": label ? void 0 : true, + onClick: pokeable ? () => setPoked(true) : void 0, + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 + }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +} +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); +} +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); +} +function MainStar() { + return (0, import_react.createElement)( + "svg", + { viewBox: "0 0 20 20", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) + ); +} +function useSettled(state) { + const prev = (0, import_react.useRef)(state); + const [settled, setSettled] = (0, import_react.useState)(false); + (0, import_react.useEffect)(() => { + const was = prev.current; + prev.current = state; + if (state !== "idle") { + setSettled(false); + return void 0; + } + if (was === "idle" || was === "alert") return void 0; + setSettled(true); + const timer = setTimeout(() => setSettled(false), 1800); + return () => clearTimeout(timer); + }, [state]); + return settled; +} +function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { + const settled = useSettled(state); + if (bot === void 0) return (0, import_react.createElement)(BotMark, { size }); + const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; + return (0, import_react.createElement)( + "span", + { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, + (0, import_react.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), + badgeKind === "working" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, + badgeKind === "alert" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, + badgeKind === "admin" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react.createElement)(StarIcon)) : null, + badgeKind === "main" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react.createElement)(MainStar)) : null + ); +} +function clusterSeats(count, frame) { + if (count === 1) return [[0, 0, frame]]; + if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; + if (count === 3) { + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; + } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); + const people = members.length === 1 ? [members[0], null] : members; + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); + if (people.length === 0) return (0, import_react.createElement)(BotMark, { size }); + if (size <= 20 && people.length > 1) { + return (0, import_react.createElement)( + "span", + { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, + people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null + ); + } + const seats = clusterSeats(Math.min(people.length, 4), size); + const overflow = people.length > 4 ? people.length - 3 : 0; + const gap = size / 14; + const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); + return (0, import_react.createElement)( + "span", + { className: "bt-cluster", style: { width: size, height: size } }, + seats.map(([x, y, s], index) => { + const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); + const style = { left: x, top: y, width: s, height: s }; + if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); + if (overflow > 0 && index === 3) { + return (0, import_react.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); + } + const bot = people[index]; + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); +} + +// src/client/flybot-slots.js +var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); +function registry2() { + const slots = registry(); + globalThis.__flybot__.call ??= call2; + globalThis.__flybot__.BotMark ??= BotMark; + globalThis.__flybot__.BotAvatar ??= BotAvatar; + globalThis.__flybot__.lookOf ??= lookOf; + return slots; +} +var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); +function useSlot(name) { + const slots = registry2(); + const [, setVersion] = (0, import_react2.useState)(0); + (0, import_react2.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); + return slots.entries(name); +} +var SlotBoundary = class extends import_react2.Component { + constructor(props) { + super(props); + this.state = { failed: false }; + } + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch(error) { + console.error(`flybot: ${this.props.id} failed`, error); + } + render() { + return this.state.failed ? null : this.props.children; + } +}; +function Safe({ entry, props }) { + return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); +} +function useSidebarStructure() { + return useSlot("flybot.sidebar.structure")[0]; +} +function FlyBotSections({ roster, actions, renderBot }) { + const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); + const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); + const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) + ))); +} +function FlyBotSessionDock({ sessionId, useRoster, actions }) { + const entries2 = useSlot("flybot.session.dock"); + const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; + if (entries2.length === 0) return null; + const room2 = roster?.roomsById?.[sessionId]; + const bot = room2 ? void 0 : roster?.byId?.[sessionId]; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } }) + ))); +} +function FlyBotRoomOverview({ room: room2, roster, actions }) { + const entries2 = useSlot("flybot.room.overview"); + if (entries2.length === 0) return null; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) + ))); } -var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``; -var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``; -var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; -var holes = (def, eyes) => `${eyes}`; -var MASK_ID = "%MASK%"; -var markSerial = 0; -var nextMarkSerial = () => markSerial += 1; -var markupCache = /* @__PURE__ */ new Map(); -function characterMarkup(shape, color) { - const key = `${shape}|${color}`; - let markup = markupCache.get(key); - if (markup === void 0) { - const def = shapeOf(shape); - const eyes = `${def.eyes.map(eyeMarkup).join("")}${def.eyes.map(happyEyeMarkup).join("")}`; - const stops = GRADIENTS[color]; - const gradient = stops ? `${stops.map(([offset, value]) => ``).join("")}` : ""; - const body = `${bodyOf(def, stops ? `url(#${MASK_ID}g)` : inkFill(color))}`; - markup = `${gradient}${holes(def, eyes)}${def.wrap ? `${body}` : body}`; - markupCache.set(key, markup); - } - return markup; +function FlyBotDetails({ bot, roster }) { + const entries2 = useSlot("flybot.bot.details"); + if (entries2.length === 0) return null; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) + ))); } -var svgUrl = (inner, size) => `url("data:image/svg+xml,${encodeURIComponent(`${inner}`)}")`; -var maskCache = /* @__PURE__ */ new Map(); -function maskOf(shape) { - let mask = maskCache.get(shape); - if (mask === void 0) { - const def = shapeOf(shape); - const body = `${bodyOf(def, "#000", true)}`; - mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size); - maskCache.set(shape, mask); - } - return mask; +var POLL_MS = 5e3; +function FlyBotApprovals() { + const entries2 = useSlot("flybot.approvals"); + const [pending, setPending] = (0, import_react2.useState)([]); + const [busy, setBusy] = (0, import_react2.useState)(""); + const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => { + }); + (0, import_react2.useEffect)(() => { + void refresh(); + const timer = setInterval(refresh, POLL_MS); + return () => clearInterval(timer); + }, []); + if (pending.length === 0) return null; + const [error, setError] = (0, import_react2.useState)(""); + const decide = async (id, decision) => { + setBusy(id); + setError(""); + try { + await call2("decide", { id, decision }); + } catch (failure) { + setError(failure?.message ?? String(failure)); + } finally { + setBusy(""); + await refresh(); + } + }; + return (0, import_react2.createElement)( + "div", + { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") }, + pending.map((approval) => { + const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by); + if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); + const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to; + return (0, import_react2.createElement)( + "div", + { key: approval.id, className: "bt-flybot-approval" }, + (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), + (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), + approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, + (0, import_react2.createElement)( + "div", + { className: "bt-flybot-approval-actions" }, + approval.status === "failed" ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, + approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")), + approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) + ) + ); + }), + error ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); } -var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +var FLYBOT_CSS = ` +.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } +.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } +.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } + +.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } +.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } +.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } +.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } +.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } +.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } +.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } +.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } +`; // src/client/sources.js function createSource(initial) { @@ -2108,7 +2270,7 @@ function usageOf(list, ids) { } // src/client/motion.js -var import_react2 = require("react"); +var import_react3 = require("react"); var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); @@ -2181,10 +2343,10 @@ function liveSprings(apply2) { } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { - const ownBox = (0, import_react2.useRef)(null); + const ownBox = (0, import_react3.useRef)(null); const box = given ?? ownBox; - const thumb = (0, import_react2.useRef)(null); - const own = (0, import_react2.useRef)(null); + const thumb = (0, import_react3.useRef)(null); + const own = (0, import_react3.useRef)(null); if (own.current === null) { own.current = { key: void 0, @@ -2218,7 +2380,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; - (0, import_react2.useLayoutEffect)(() => { + (0, import_react3.useLayoutEffect)(() => { const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; @@ -2232,7 +2394,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } place(fresh); }); - (0, import_react2.useEffect)(() => () => { + (0, import_react3.useEffect)(() => () => { own.current.observer?.disconnect(); own.current.springs.stop(); }, []); @@ -2240,16 +2402,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } function Segmented({ label, value, className, children }) { const glide = useGlide(value); - return (0, import_react2.createElement)( + return (0, import_react3.createElement)( "div", { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, - (0, import_react2.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + (0, import_react3.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), children ); } function useFlip(list, key, selector, id = (node) => node.dataset.flip) { - const tops = (0, import_react2.useRef)(null); - (0, import_react2.useLayoutEffect)(() => { + const tops = (0, import_react3.useRef)(null); + (0, import_react3.useLayoutEffect)(() => { const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); @@ -2270,8 +2432,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { }, [key]); } function useMorph(box, key) { - const last = (0, import_react2.useRef)({ key, height: null }); - (0, import_react2.useLayoutEffect)(() => { + const last = (0, import_react3.useRef)({ key, height: null }); + (0, import_react3.useLayoutEffect)(() => { const node = box.current; const state = last.current; if (!node) { @@ -2299,8 +2461,8 @@ function useMorph(box, key) { }); } function useLinger(value, ms) { - const [kept, setKept] = (0, import_react2.useState)(value); - (0, import_react2.useEffect)(() => { + const [kept, setKept] = (0, import_react3.useState)(value); + (0, import_react3.useEffect)(() => { if (value) { setKept(value); return void 0; @@ -3345,165 +3507,6 @@ function installCompiledStyles(text, tag) { }; } -// src/client/mark.js -var import_react3 = require("react"); -var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; -var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; -var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; -function useGaze(ref, enabled) { - (0, import_react3.useEffect)(() => { - if (!enabled) return void 0; - let frame = 0; - let point = null; - const place = () => { - frame = 0; - const node = ref.current; - if (!node || !point) return; - const box = node.getBoundingClientRect(); - const clamp = (value) => Math.max(-1, Math.min(1, value)); - node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); - node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); - }; - const onMove = (event) => { - point = { x: event.clientX, y: event.clientY }; - if (!frame) frame = requestAnimationFrame(place); - }; - const onLeave = () => { - point = null; - ref.current?.style.setProperty("--bt-gx", "0"); - ref.current?.style.setProperty("--bt-gy", "0"); - }; - window.addEventListener("pointermove", onMove, { passive: true }); - document.documentElement.addEventListener("pointerleave", onLeave); - return () => { - cancelAnimationFrame(frame); - window.removeEventListener("pointermove", onMove); - document.documentElement.removeEventListener("pointerleave", onLeave); - }; - }, [enabled]); -} -var FIDGETS = ["hop", "sway", "stretch", "look"]; -function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot); - const ref = (0, import_react3.useRef)(null); - const maskId = (0, import_react3.useRef)(""); - if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; - const [poked, setPoked] = (0, import_react3.useState)(false); - useGaze(ref, gaze); - const common = { - ref, - className: poked ? "bt-mark bt-mark-poke" : "bt-mark", - "data-shape": look.image ? "image" : shapeId(look.shape), - "data-state": STATES.includes(state) ? state : "idle", - "data-live": live ? "" : void 0, - "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, - "data-gaze": gaze ? "" : void 0, - "data-poke": pokeable ? "" : void 0, - style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, - role: label ? "img" : void 0, - "aria-label": label, - "aria-hidden": label ? void 0 : true, - onClick: pokeable ? () => setPoked(true) : void 0, - onAnimationEnd: poked ? (event) => { - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 - }; - if (look.image) return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); - const box = shapeOf(look.shape).size; - return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -} -function StarIcon({ size = 8 }) { - return (0, import_react3.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, - (0, import_react3.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) - ); -} -function AdminStar() { - return (0, import_react3.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react3.createElement)(StarIcon, { size: 7 })); -} -function MainStar() { - return (0, import_react3.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react3.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -} -function useSettled(state) { - const prev = (0, import_react3.useRef)(state); - const [settled, setSettled] = (0, import_react3.useState)(false); - (0, import_react3.useEffect)(() => { - const was = prev.current; - prev.current = state; - if (state !== "idle") { - setSettled(false); - return void 0; - } - if (was === "idle" || was === "alert") return void 0; - setSettled(true); - const timer = setTimeout(() => setSettled(false), 1800); - return () => clearTimeout(timer); - }, [state]); - return settled; -} -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); - if (bot === void 0) return (0, import_react3.createElement)(BotMark, { size }); - const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; - return (0, import_react3.createElement)( - "span", - { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, - (0, import_react3.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), - badgeKind === "working" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, - badgeKind === "alert" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, - badgeKind === "admin" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react3.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react3.createElement)(MainStar)) : null - ); -} -function clusterSeats(count, frame) { - if (count === 1) return [[0, 0, frame]]; - if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; - if (count === 3) { - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -} -function RoomAvatar({ room: room2, roster, size = 36 }) { - const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const people = members.length === 1 ? [members[0], null] : members; - const seat = (bot, seatSize, key) => bot ? (0, import_react3.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react3.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); - if (people.length === 0) return (0, import_react3.createElement)(BotMark, { size }); - if (size <= 20 && people.length > 1) { - return (0, import_react3.createElement)( - "span", - { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, - people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), - people.length > 3 ? (0, import_react3.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null - ); - } - const seats = clusterSeats(Math.min(people.length, 4), size); - const overflow = people.length > 4 ? people.length - 3 : 0; - const gap = size / 14; - const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); - return (0, import_react3.createElement)( - "span", - { className: "bt-cluster", style: { width: size, height: size } }, - seats.map(([x, y, s], index) => { - const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); - const style = { left: x, top: y, width: s, height: s }; - if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); - if (overflow > 0 && index === 3) { - return (0, import_react3.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); - } - const bot = people[index]; - return (0, import_react3.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); -} - // src/client/icons.js var import_react4 = require("react"); function PlusIcon() { diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 8a7c962f0..70534b5ac 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -15,11 +15,15 @@ // flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer // flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat) // +// The same registry object also carries the host Bot look so a plugin can draw a Bot +// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()). +// // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' import { isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' +import { BotAvatar, BotMark, lookOf } from './mark.js' import { t } from './i18n.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -32,6 +36,11 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa export function registry() { const slots = baseRegistry() globalThis.__flybot__.call ??= call + // Classic-script plugins cannot import mark.js; they render the same Bot look + // the sidebar / session list / chat header use via these host components. + globalThis.__flybot__.BotMark ??= BotMark + globalThis.__flybot__.BotAvatar ??= BotAvatar + globalThis.__flybot__.lookOf ??= lookOf return slots } diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 2f85e4fc5..e951ca898 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -301,7 +301,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * How a client plugin gets it: a DSH web client bundle (`window.__ModuleLoader__.load({ id, * inject: ['flybot'], ... })`, see plugins/tree/client.js) receives the browser service as * `ctx.flybot` (a {@link FlyBotClient}). The same registry is also on `globalThis.__flybot__` - * for code without a Cordis context. Slots below are FlyBot's own registry: register them + * for code without a Cordis context (`BotAvatar` / `BotMark` / `lookOf` are the host Bot + * look, set when the client registry() runs). Slots below are FlyBot's own registry: register them * with `ctx.flybot.slots.register(name, entry)`, NOT the shell's `ctx.slots` (which throws * `slot "flybot.…" is not declared`). Every `register` returns a disposer; call it from * `ctx.effect` so a reload or uninstall removes the entry. Entries are sorted by `order` @@ -367,9 +368,23 @@ export interface FlyBotClientRegistry { subscribe(listener: () => void): () => void } +/** Host Bot look: same component the sidebar / session list / chat header render. */ +export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any +export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any +export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } + declare global { // eslint-disable-next-line no-var - var __flybot__: { slots: FlyBotClientRegistry; call?: FlyBotCall; api?: FlyBotClient } | undefined + var __flybot__: { + slots: FlyBotClientRegistry + call?: FlyBotCall + api?: FlyBotClient + /** Same character mark as the sidebar / chat header. Set by the host client registry(). */ + BotMark?: FlyBotBotMark + /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ + BotAvatar?: FlyBotBotAvatar + lookOf?: FlyBotLookOf + } | undefined } declare module 'cordis' { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 48f0efecc..5da4b83e7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -35,6 +35,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - `client.js` matches `core/src/client/`; `npm run build` + `--check` (also invariant 3 of [ds-bot-core](ds-bot-core.md)). +### Host Bot look in the bundle + +- The committed bundle includes `flybot-slots.js` `registry()`, which exposes `BotAvatar` / `BotMark` / `lookOf` on `globalThis.__flybot__` for classic-script plugins. Behavior is specified on [plugin-runtime](plugin-runtime.md). + ### Remote Bot display - A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index d324aa809..9a395f960 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -52,6 +52,10 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Slot `flybot.new-chat.action` adds a row to the sidebar **+** new-chat menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. +### Host Bot look for classic-script plugins + +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. + ## Tests `core/test/flybot.test.js`, `core/test/flybot-client.test.js` via `npm test`. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index cbd9da9ec..412876ada 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot, no owner subtitle); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -30,7 +30,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, invites). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client, error mapping, event rendering, peer-string sanitization, GET/PATCH `/v1/me` auto-approve (#83). | -| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker, 6-digit join, waiting room, Enter chat), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | +| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker and waiting room as a wrapping tile grid of each Bot's own avatar, 6-digit join, Enter chat + Leave footer), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | ## Entry points @@ -43,7 +43,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. 4. Access is a per-Bot **grant** (#90): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted to me). Accepting **Invite to this Bot** is the grant; `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, MCP then REST) let the owner see where the Bot is in use and revoke one person without touching other grants. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. 5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. -6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); the client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots as mine; **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. +6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. ## Tests diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d9f0f94e8..e4c9aea37 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -112,7 +112,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1097,6 +1097,412 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1097,269 @@ var call = async (endpoint, payload, signal) => { return result.value; }; @@ -379,22 +379,219 @@ diff --git a/client.js b/client.js + }]); +} + -+// src/client/flybot-slots.js + // src/client/inks.js + var INKS = { + deepseek: ["#4D6BFE", "#6A84FF"], +@@ -1177,11 +1177,14 @@ function defaultShape(id) { + value = Math.imul(value ^ value >>> 13, 3266489909); + return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; + } +-var colorOf = (bot) => isColor(bot?.color) ? bot.color : bot?.id ? defaultColor(String(bot.id)) : "black"; ++var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; ++var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0; ++var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; + function lookOf(bot) { + const avatar = bot?.avatar ?? {}; ++ const seed = lookSeed(bot); + return { +- shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE, ++ shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE, + image: avatar.image, + color: colorOf(bot) + }; +@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { + return ""; + } + } +-var eyeMarkup = ([cx, cy, w, h34, tilt]) => ``; +-var happyEyeMarkup = ([cx, cy, w, h34, tilt]) => ``; ++var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``; ++var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``; + var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; + var holes = (def, eyes) => `${eyes}`; + var MASK_ID = "%MASK%"; +@@ -1242,6 +1242,311 @@ function maskOf(shape) { + } + var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); + ++// src/client/mark.js +var import_react = require("react"); ++var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; ++var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; ++var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; ++function useGaze(ref, enabled) { ++ (0, import_react.useEffect)(() => { ++ if (!enabled) return void 0; ++ let frame = 0; ++ let point = null; ++ const place = () => { ++ frame = 0; ++ const node = ref.current; ++ if (!node || !point) return; ++ const box = node.getBoundingClientRect(); ++ const clamp = (value) => Math.max(-1, Math.min(1, value)); ++ node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); ++ node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); ++ }; ++ const onMove = (event) => { ++ point = { x: event.clientX, y: event.clientY }; ++ if (!frame) frame = requestAnimationFrame(place); ++ }; ++ const onLeave = () => { ++ point = null; ++ ref.current?.style.setProperty("--bt-gx", "0"); ++ ref.current?.style.setProperty("--bt-gy", "0"); ++ }; ++ window.addEventListener("pointermove", onMove, { passive: true }); ++ document.documentElement.addEventListener("pointerleave", onLeave); ++ return () => { ++ cancelAnimationFrame(frame); ++ window.removeEventListener("pointermove", onMove); ++ document.documentElement.removeEventListener("pointerleave", onLeave); ++ }; ++ }, [enabled]); ++} ++var FIDGETS = ["hop", "sway", "stretch", "look"]; ++function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { ++ const look = given ?? lookOf(bot); ++ const ref = (0, import_react.useRef)(null); ++ const maskId = (0, import_react.useRef)(""); ++ if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; ++ const [poked, setPoked] = (0, import_react.useState)(false); ++ useGaze(ref, gaze); ++ const common = { ++ ref, ++ className: poked ? "bt-mark bt-mark-poke" : "bt-mark", ++ "data-shape": look.image ? "image" : shapeId(look.shape), ++ "data-state": STATES.includes(state) ? state : "idle", ++ "data-live": live ? "" : void 0, ++ "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, ++ "data-gaze": gaze ? "" : void 0, ++ "data-poke": pokeable ? "" : void 0, ++ style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, ++ role: label ? "img" : void 0, ++ "aria-label": label, ++ "aria-hidden": label ? void 0 : true, ++ onClick: pokeable ? () => setPoked(true) : void 0, ++ onAnimationEnd: poked ? (event) => { ++ if (/^bt-poke/.test(event.animationName)) setPoked(false); ++ } : void 0 ++ }; ++ if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); ++ const box = shapeOf(look.shape).size; ++ return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); ++} ++function StarIcon({ size = 8 }) { ++ return (0, import_react.createElement)( ++ "svg", ++ { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, ++ (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++ ); ++} ++function AdminStar() { ++ return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); ++} ++function MainStar() { ++ return (0, import_react.createElement)( ++ "svg", ++ { viewBox: "0 0 20 20", "aria-hidden": true }, ++ (0, import_react.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) ++ ); ++} ++function useSettled(state) { ++ const prev = (0, import_react.useRef)(state); ++ const [settled, setSettled] = (0, import_react.useState)(false); ++ (0, import_react.useEffect)(() => { ++ const was = prev.current; ++ prev.current = state; ++ if (state !== "idle") { ++ setSettled(false); ++ return void 0; ++ } ++ if (was === "idle" || was === "alert") return void 0; ++ setSettled(true); ++ const timer = setTimeout(() => setSettled(false), 1800); ++ return () => clearTimeout(timer); ++ }, [state]); ++ return settled; ++} ++function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { ++ const settled = useSettled(state); ++ if (bot === void 0) return (0, import_react.createElement)(BotMark, { size }); ++ const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, ++ (0, import_react.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), ++ badgeKind === "working" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, ++ badgeKind === "alert" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, ++ badgeKind === "admin" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react.createElement)(StarIcon)) : null, ++ badgeKind === "main" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react.createElement)(MainStar)) : null ++ ); ++} ++function clusterSeats(count, frame) { ++ if (count === 1) return [[0, 0, frame]]; ++ if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; ++ if (count === 3) { ++ const size = frame * 5 / 9; ++ const rest = frame - size; ++ return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++ } ++ const half = frame / 2; ++ return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++} ++function RoomAvatar({ room: room2, roster, size = 36 }) { ++ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); ++ const people = members.length === 1 ? [members[0], null] : members; ++ const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); ++ if (people.length === 0) return (0, import_react.createElement)(BotMark, { size }); ++ if (size <= 20 && people.length > 1) { ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, ++ people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), ++ people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null ++ ); ++ } ++ const seats = clusterSeats(Math.min(people.length, 4), size); ++ const overflow = people.length > 4 ? people.length - 3 : 0; ++ const gap = size / 14; ++ const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-cluster", style: { width: size, height: size } }, ++ seats.map(([x, y, s], index) => { ++ const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); ++ const style = { left: x, top: y, width: s, height: s }; ++ if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); ++ if (overflow > 0 && index === 3) { ++ return (0, import_react.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++ } ++ const bot = people[index]; ++ return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ }) ++ ); ++} ++ ++// src/client/flybot-slots.js ++var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); +function registry2() { + const slots = registry(); + globalThis.__flybot__.call ??= call2; ++ globalThis.__flybot__.BotMark ??= BotMark; ++ globalThis.__flybot__.BotAvatar ??= BotAvatar; ++ globalThis.__flybot__.lookOf ??= lookOf; + return slots; +} +var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); +function useSlot(name) { + const slots = registry2(); -+ const [, setVersion] = (0, import_react.useState)(0); -+ (0, import_react.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); ++ const [, setVersion] = (0, import_react2.useState)(0); ++ (0, import_react2.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); + return slots.entries(name); +} -+var SlotBoundary = class extends import_react.Component { ++var SlotBoundary = class extends import_react2.Component { + constructor(props) { + super(props); + this.state = { failed: false }; @@ -410,7 +607,7 @@ diff --git a/client.js b/client.js + } +}; +function Safe({ entry, props }) { -+ return (0, import_react.createElement)(SlotBoundary, { id: entry.id }, (0, import_react.createElement)(entry.component, props)); ++ return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); +} +function useSidebarStructure() { + return useSlot("flybot.sidebar.structure")[0]; @@ -419,10 +616,10 @@ diff --git a/client.js b/client.js + const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); + const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); + const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); -+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, -+ (0, import_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) + ))); +} +function FlyBotSessionDock({ sessionId, useRoster, actions }) { @@ -431,44 +628,44 @@ diff --git a/client.js b/client.js + if (entries2.length === 0) return null; + const room2 = roster?.roomsById?.[sessionId]; + const bot = room2 ? void 0 : roster?.byId?.[sessionId]; -+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, -+ (0, import_react.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } }) + ))); +} +function FlyBotRoomOverview({ room: room2, roster, actions }) { + const entries2 = useSlot("flybot.room.overview"); + if (entries2.length === 0) return null; -+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, -+ (0, import_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) + ))); +} +function FlyBotDetails({ bot, roster }) { + const entries2 = useSlot("flybot.bot.details"); + if (entries2.length === 0) return null; -+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, -+ (0, import_react.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) + ))); +} +var POLL_MS = 5e3; +function FlyBotApprovals() { + const entries2 = useSlot("flybot.approvals"); -+ const [pending, setPending] = (0, import_react.useState)([]); -+ const [busy, setBusy] = (0, import_react.useState)(""); ++ const [pending, setPending] = (0, import_react2.useState)([]); ++ const [busy, setBusy] = (0, import_react2.useState)(""); + const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => { + }); -+ (0, import_react.useEffect)(() => { ++ (0, import_react2.useEffect)(() => { + void refresh(); + const timer = setInterval(refresh, POLL_MS); + return () => clearInterval(timer); + }, []); + if (pending.length === 0) return null; -+ const [error, setError] = (0, import_react.useState)(""); ++ const [error, setError] = (0, import_react2.useState)(""); + const decide = async (id, decision) => { + setBusy(id); + setError(""); @@ -481,29 +678,29 @@ diff --git a/client.js b/client.js + await refresh(); + } + }; -+ return (0, import_react.createElement)( ++ return (0, import_react2.createElement)( + "div", + { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") }, + pending.map((approval) => { + const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by); -+ if (custom) return (0, import_react.createElement)("div", { key: approval.id }, (0, import_react.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); ++ if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); + const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to; -+ return (0, import_react.createElement)( ++ return (0, import_react2.createElement)( + "div", + { key: approval.id, className: "bt-flybot-approval" }, -+ (0, import_react.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), -+ (0, import_react.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), -+ approval.request.attachments?.length ? (0, import_react.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, -+ (0, import_react.createElement)( ++ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), ++ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), ++ approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, ++ (0, import_react2.createElement)( + "div", + { className: "bt-flybot-approval-actions" }, -+ approval.status === "failed" ? (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, -+ approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")), -+ approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) ++ approval.status === "failed" ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, ++ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")), ++ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) + ) + ); + }), -+ error ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ++ error ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); +} +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; @@ -522,37 +719,9 @@ diff --git a/client.js b/client.js +.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } +`; + - // src/client/inks.js - var INKS = { - deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,11 +1177,14 @@ function defaultShape(id) { - value = Math.imul(value ^ value >>> 13, 3266489909); - return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; - } --var colorOf = (bot) => isColor(bot?.color) ? bot.color : bot?.id ? defaultColor(String(bot.id)) : "black"; -+var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; -+var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0; -+var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; - function lookOf(bot) { - const avatar = bot?.avatar ?? {}; -+ const seed = lookSeed(bot); - return { -- shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE, -+ shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE, - image: avatar.image, - color: colorOf(bot) - }; -@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { - return ""; - } - } --var eyeMarkup = ([cx, cy, w, h34, tilt]) => ``; --var happyEyeMarkup = ([cx, cy, w, h34, tilt]) => ``; -+var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``; -+var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``; - var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; - var holes = (def, eyes) => `${eyes}`; - var MASK_ID = "%MASK%"; + // src/client/sources.js + function createSource(initial) { + let value = initial; @@ -1262,7 +1262,7 @@ function createSource(initial) { } }; @@ -642,7 +811,7 @@ diff --git a/client.js b/client.js // src/client/motion.js -var import_react = require("react"); -+var import_react2 = require("react"); ++var import_react3 = require("react"); var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); @@ -667,12 +836,12 @@ diff --git a/client.js b/client.js var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { - const ownBox = (0, import_react.useRef)(null); -+ const ownBox = (0, import_react2.useRef)(null); ++ const ownBox = (0, import_react3.useRef)(null); const box = given ?? ownBox; - const thumb = (0, import_react.useRef)(null); - const own = (0, import_react.useRef)(null); -+ const thumb = (0, import_react2.useRef)(null); -+ const own = (0, import_react2.useRef)(null); ++ const thumb = (0, import_react3.useRef)(null); ++ const own = (0, import_react3.useRef)(null); if (own.current === null) { own.current = { key: void 0, @@ -681,7 +850,7 @@ diff --git a/client.js b/client.js container.dataset.glide = ""; }; - (0, import_react.useLayoutEffect)(() => { -+ (0, import_react2.useLayoutEffect)(() => { ++ (0, import_react3.useLayoutEffect)(() => { const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; @@ -690,7 +859,7 @@ diff --git a/client.js b/client.js place(fresh); }); - (0, import_react.useEffect)(() => () => { -+ (0, import_react2.useEffect)(() => () => { ++ (0, import_react3.useEffect)(() => () => { own.current.observer?.disconnect(); own.current.springs.stop(); }, []); @@ -699,19 +868,19 @@ diff --git a/client.js b/client.js function Segmented({ label, value, className, children }) { const glide = useGlide(value); - return (0, import_react.createElement)( -+ return (0, import_react2.createElement)( ++ return (0, import_react3.createElement)( "div", { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, - (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ (0, import_react2.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ (0, import_react3.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), children ); } function useFlip(list, key, selector, id = (node) => node.dataset.flip) { - const tops = (0, import_react.useRef)(null); - (0, import_react.useLayoutEffect)(() => { -+ const tops = (0, import_react2.useRef)(null); -+ (0, import_react2.useLayoutEffect)(() => { ++ const tops = (0, import_react3.useRef)(null); ++ (0, import_react3.useLayoutEffect)(() => { const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); @@ -721,8 +890,8 @@ diff --git a/client.js b/client.js function useMorph(box, key) { - const last = (0, import_react.useRef)({ key, height: null }); - (0, import_react.useLayoutEffect)(() => { -+ const last = (0, import_react2.useRef)({ key, height: null }); -+ (0, import_react2.useLayoutEffect)(() => { ++ const last = (0, import_react3.useRef)({ key, height: null }); ++ (0, import_react3.useLayoutEffect)(() => { const node = box.current; const state = last.current; if (!node) { @@ -732,8 +901,8 @@ diff --git a/client.js b/client.js function useLinger(value, ms) { - const [kept, setKept] = (0, import_react.useState)(value); - (0, import_react.useEffect)(() => { -+ const [kept, setKept] = (0, import_react2.useState)(value); -+ (0, import_react2.useEffect)(() => { ++ const [kept, setKept] = (0, import_react3.useState)(value); ++ (0, import_react3.useEffect)(() => { if (value) { setKept(value); return void 0; @@ -785,140 +954,271 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2856,12 +2856,12 @@ function installCompiledStyles(text, tag) { - } - - // src/client/mark.js +@@ -2801,280 +2801,121 @@ function sheetClassNames(sheet) { + if (rule.cssRules) visit(rule.cssRules); + } + }; +- try { +- visit(sheet.cssRules); +- } catch { +- } +- return names; +-} +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); +- } +- return compiled.get(fragment); +- }); +-} +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); +- } +- for (const name of list) names.add(name); +- } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; +-} +- +-// src/client/mark.js -var import_react2 = require("react"); -+var import_react3 = require("react"); - var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; - var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; - var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; - function useGaze(ref, enabled) { +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { - (0, import_react2.useEffect)(() => { -+ (0, import_react3.useEffect)(() => { - if (!enabled) return void 0; - let frame = 0; - let point = null; -@@ -2895,10 +2895,10 @@ function useGaze(ref, enabled) { - var FIDGETS = ["hop", "sway", "stretch", "look"]; - function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot); +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +- }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); - const ref = (0, import_react2.useRef)(null); - const maskId = (0, import_react2.useRef)(""); -+ const ref = (0, import_react3.useRef)(null); -+ const maskId = (0, import_react3.useRef)(""); - if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; - const [poked, setPoked] = (0, import_react2.useState)(false); -+ const [poked, setPoked] = (0, import_react3.useState)(false); - useGaze(ref, gaze); - const common = { - ref, -@@ -2918,31 +2918,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 - }; +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +- }; - if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -+ if (look.image) return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); - const box = shapeOf(look.shape).size; +- const box = shapeOf(look.shape).size; - return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+ return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); - } - function StarIcon({ size = 8 }) { +-} +-function StarIcon({ size = 8 }) { - return (0, import_react2.createElement)( -+ return (0, import_react3.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -+ (0, import_react3.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) - ); - } - function AdminStar() { +- ); +-} +-function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+ return (0, import_react3.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react3.createElement)(StarIcon, { size: 7 })); - } - function MainStar() { +-} +-function MainStar() { - return (0, import_react2.createElement)( -+ return (0, import_react3.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -+ (0, import_react3.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); - } - function useSettled(state) { +- ); +-} +-function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); - (0, import_react2.useEffect)(() => { -+ const prev = (0, import_react3.useRef)(state); -+ const [settled, setSettled] = (0, import_react3.useState)(false); -+ (0, import_react3.useEffect)(() => { - const was = prev.current; - prev.current = state; - if (state !== "idle") { -@@ -2958,16 +2958,16 @@ function useSettled(state) { - } - function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +-} +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); - if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -+ if (bot === void 0) return (0, import_react3.createElement)(BotMark, { size }); - const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; - return (0, import_react2.createElement)( -+ return (0, import_react3.createElement)( - "span", - { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, - (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), - badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, - badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, - badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -+ (0, import_react3.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -+ badgeKind === "working" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -+ badgeKind === "alert" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -+ badgeKind === "admin" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react3.createElement)(StarIcon)) : null, -+ badgeKind === "main" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react3.createElement)(MainStar)) : null - ); - } - function clusterSeats(count, frame) { -@@ -2984,21 +2984,21 @@ function clusterSeats(count, frame) { - function RoomAvatar({ room: room2, roster, size = 36 }) { - const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const people = members.length === 1 ? [members[0], null] : members; +- ); +-} +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +-} +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; - const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); - if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -+ const seat = (bot, seatSize, key) => bot ? (0, import_react3.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react3.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -+ if (people.length === 0) return (0, import_react3.createElement)(BotMark, { size }); - if (size <= 20 && people.length > 1) { +- if (size <= 20 && people.length > 1) { - return (0, import_react2.createElement)( -+ return (0, import_react3.createElement)( - "span", - { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, - people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), - people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -+ people.length > 3 ? (0, import_react3.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null - ); +- ); ++ try { ++ visit(sheet.cssRules); ++ } catch { } - const seats = clusterSeats(Math.min(people.length, 4), size); - const overflow = people.length > 4 ? people.length - 3 : 0; - const gap = size / 14; - const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); - return (0, import_react2.createElement)( -+ return (0, import_react3.createElement)( - "span", - { className: "bt-cluster", style: { width: size, height: size } }, - seats.map(([x, y, s], index) => { -@@ -3006,75 +3006,75 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { - const style = { left: x, top: y, width: s, height: s }; - if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); - if (overflow > 0 && index === 3) { +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { - return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -+ return (0, import_react3.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++ return names; ++} ++function compileFragmentSelectors(text, names) { ++ const compiled = /* @__PURE__ */ new Map(); ++ return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { ++ if (!compiled.has(fragment)) { ++ const exact = []; ++ for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); ++ compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); ++ } ++ return compiled.get(fragment); ++ }); ++} ++function installCompiledStyles(text, tag) { ++ const style = installStyles("", tag); ++ const cache = /* @__PURE__ */ new WeakMap(); ++ const render = () => { ++ const names = /* @__PURE__ */ new Set(); ++ for (const sheet of document.styleSheets) { ++ if (sheet.ownerNode?.dataset?.dshBot) continue; ++ let list = cache.get(sheet); ++ if (!list) { ++ list = sheetClassNames(sheet); ++ cache.set(sheet, list); } - const bot = people[index]; +- const bot = people[index]; - return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -+ return (0, import_react3.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); +- }) +- ); ++ for (const name of list) names.add(name); ++ } ++ const next = compileFragmentSelectors(text, names); ++ if (style.textContent !== next) style.textContent = next; ++ }; ++ render(); ++ let timer; ++ const schedule = () => { ++ clearTimeout(timer); ++ timer = setTimeout(render, 120); ++ }; ++ const onLoad = (event) => { ++ if (event.target instanceof HTMLLinkElement) schedule(); ++ }; ++ const observer = new MutationObserver(schedule); ++ observer.observe(document.head, { childList: true }); ++ document.addEventListener("load", onLoad, true); ++ return () => { ++ observer.disconnect(); ++ document.removeEventListener("load", onLoad, true); ++ clearTimeout(timer); ++ style.remove(); ++ }; } // src/client/icons.js @@ -6380,7 +6680,10 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)(BotMark, { bot, size: 22 }), + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, bot.name)), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add")) -+ )), + )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( + ...outsideRemotes.map((bot) => (0, import_react24.createElement)( + "button", + { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -6389,10 +6692,7 @@ diff --git a/client.js b/client.js + } }, + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) - )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( ++ )), + (0, import_react24.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) + ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react24.createElement)(PlusIcon), t("Add member")) : null, + (0, import_react24.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react24.createElement)( diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 8a7c962f0..70534b5ac 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -15,11 +15,15 @@ // flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer // flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat) // +// The same registry object also carries the host Bot look so a plugin can draw a Bot +// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()). +// // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' import { isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' +import { BotAvatar, BotMark, lookOf } from './mark.js' import { t } from './i18n.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -32,6 +36,11 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa export function registry() { const slots = baseRegistry() globalThis.__flybot__.call ??= call + // Classic-script plugins cannot import mark.js; they render the same Bot look + // the sidebar / session list / chat header use via these host components. + globalThis.__flybot__.BotMark ??= BotMark + globalThis.__flybot__.BotAvatar ??= BotAvatar + globalThis.__flybot__.lookOf ??= lookOf return slots } diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 2f85e4fc5..e951ca898 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -301,7 +301,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * How a client plugin gets it: a DSH web client bundle (`window.__ModuleLoader__.load({ id, * inject: ['flybot'], ... })`, see plugins/tree/client.js) receives the browser service as * `ctx.flybot` (a {@link FlyBotClient}). The same registry is also on `globalThis.__flybot__` - * for code without a Cordis context. Slots below are FlyBot's own registry: register them + * for code without a Cordis context (`BotAvatar` / `BotMark` / `lookOf` are the host Bot + * look, set when the client registry() runs). Slots below are FlyBot's own registry: register them * with `ctx.flybot.slots.register(name, entry)`, NOT the shell's `ctx.slots` (which throws * `slot "flybot.…" is not declared`). Every `register` returns a disposer; call it from * `ctx.effect` so a reload or uninstall removes the entry. Entries are sorted by `order` @@ -367,9 +368,23 @@ export interface FlyBotClientRegistry { subscribe(listener: () => void): () => void } +/** Host Bot look: same component the sidebar / session list / chat header render. */ +export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any +export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any +export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } + declare global { // eslint-disable-next-line no-var - var __flybot__: { slots: FlyBotClientRegistry; call?: FlyBotCall; api?: FlyBotClient } | undefined + var __flybot__: { + slots: FlyBotClientRegistry + call?: FlyBotCall + api?: FlyBotClient + /** Same character mark as the sidebar / chat header. Set by the host client registry(). */ + BotMark?: FlyBotBotMark + /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ + BotAvatar?: FlyBotBotAvatar + lookOf?: FlyBotLookOf + } | undefined } declare module 'cordis' { diff --git a/plugins/social/README.md b/plugins/social/README.md index 85323a520..be2b4f8e0 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -58,8 +58,8 @@ Tests (`test/social.test.js`) use a mocked server. People in the same place (or on a call) put their Bots in one room by each typing the same 6-digit number. There is no host and no friend request. 1. Click **+** at the top of the sidebar (next to Search) and choose **Face-to-face group**, or open **Friends' Bots** and **+ Add** → **Face-to-face group**. -2. Choose which of your Bots to bring (multi-select; default: the Main Bot). Type the same six digits as the people next to you. Click **Join**. -3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room lists who is in (each Bot and its owner; yours marked **mine**) and how much time is left. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke. +2. Choose which of your Bots to bring (tap tiles; default: the Main Bot). Each Bot shows the same avatar as in the sidebar. Type the same six digits as the people next to you. Click **Join**. +3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room is a wrapping grid of who is in (each Bot's avatar on top and name below; yours with a tiny accent dot). The countdown stays at the top; **Leave** and **Enter chat** share the footer. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke. 4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. The matching window is 10 minutes; when it ends or the room starts, the number is invalid. 5. **Leave** removes your Bots in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. diff --git a/plugins/social/client.js b/plugins/social/client.js index 90bfe5908..6bed73a62 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -58,7 +58,10 @@ window.__ModuleLoader__.load({ .bt-social-rows{display:flex;flex-direction:column} .fs-row{display:flex;align-items:center;gap:8px;min-width:0;padding:5px 8px 5px 10px;border-radius:8px;transition:background-color .12s ease} .fs-row:hover{background:var(--bt-hover,rgba(119,119,119,.09))} -.fs-avatar{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;font-weight:600} +.fs-avatar{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;font-weight:600;overflow:hidden} +.fs-avatar img{width:100%;height:100%;object-fit:cover;display:block} +.fs-face-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:var(--fs-face-size,24px);height:var(--fs-face-size,24px)} +.fs-face-mark .bt-mark,.fs-face-mark .fs-avatar{width:100%;height:100%;font-size:calc(var(--fs-face-size,24px)*.5)} .fs-text{display:flex;flex-direction:column;flex:1 1 auto;min-width:0} .fs-name{font-size:14px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fs-sub{font-size:12px;line-height:16px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -106,15 +109,27 @@ window.__ModuleLoader__.load({ .fs-menu button:hover{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-chat-approval{margin:4px 0 8px} .fs-dialog-back{position:fixed;inset:0;z-index:40;background:rgba(20,20,20,.4);display:flex;align-items:center;justify-content:center;padding:12px} -.fs-dialog{width:min(420px,100%);max-height:min(640px,92vh);overflow:auto;margin:0;padding:14px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:14px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 40px rgba(0,0,0,.18)} -.fs-dialog-head{display:flex;align-items:center;gap:8px} +.fs-dialog{width:min(420px,100%);max-height:min(640px,92vh);overflow:hidden;margin:0;padding:14px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:14px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 40px rgba(0,0,0,.18)} +.fs-dialog-head{display:flex;align-items:center;gap:8px;flex:none} .fs-dialog-head .fs-name{flex:1 1 auto} -.fs-pick{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px;max-height:220px;overflow:auto} -.fs-pick li{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px} -.fs-pick li:hover{background:var(--bt-hover,rgba(119,119,119,.09))} -.fs-pick label{display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:0;cursor:pointer;font:inherit} -.fs-wait-roster{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto} -.fs-wait-roster li{display:flex;align-items:center;gap:8px;padding:6px 4px;border-radius:8px} +.fs-dialog > form{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px} +.fs-tiles{margin:0;padding:4px 0 0;list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:10px 6px;align-content:start} +.fs-pick{max-height:220px;overflow:auto} +.fs-tile{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;padding:6px 2px;border-radius:10px} +.fs-tile-hit{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;width:100%;margin:0;cursor:pointer;font:inherit;position:relative} +.fs-tile-hit:hover,.fs-tile-on{background:var(--bt-hover,rgba(119,119,119,.09))} +.fs-tile-mark{position:relative;flex:none;display:inline-flex} +.fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink,#141414)} +.fs-tile-mine{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;background:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px var(--bt-card,#fff);pointer-events:none} +.fs-tile-on .fs-face-mark{border-radius:50%;box-shadow:0 0 0 2px var(--bt-accent,#4d6bfe)} +.fs-tile-check{position:absolute;right:-2px;bottom:-2px;width:16px;height:16px;border-radius:50%;background:var(--bt-accent,#4d6bfe);color:#fff;font-size:11px;line-height:16px;text-align:center;pointer-events:none;box-shadow:0 0 0 2px var(--bt-card,#fff)} +.fs-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.fs-wait{display:flex;flex-direction:column;gap:8px;flex:1 1 auto;min-height:0;overflow:hidden} +.fs-wait-count{flex:none;padding:0} +.fs-wait-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px} +.fs-wait-foot{display:flex;align-items:stretch;gap:8px;flex:none} +.fs-wait-foot .fs-btn{height:36px} +.fs-wait-foot .fs-btn-primary{flex:1 1 auto;width:auto;min-width:0} .fs-fail{padding:0} ` function useStyles() { @@ -128,6 +143,126 @@ window.__ModuleLoader__.load({ } const initial = name => (String(name ?? '?').replace(/^@/, '').trim()[0] ?? '?').toUpperCase() + // Same FNV-1a as core lookOf, so a missing host BotMark still paints a stable per-Bot + // color (never the shared gray letter badge) and two ids do not collide. + const FACE_INKS = ['#e5484d', '#e54666', '#f76b15', '#ffc53d', '#46a758', '#12a594', '#0090ff', '#3e63dd', '#5b5bd6', '#8e4ec6'] + function inkOf(seed) { + const text = String(seed ?? '') + let value = 2166136261 + for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619) + return FACE_INKS[(value >>> 0) % FACE_INKS.length] + } + function faceSeed(bot) { + const handle = typeof bot?.remote?.handle === 'string' && bot.remote.handle.trim() + ? bot.remote.handle + : typeof bot?.handle === 'string' && bot.handle.trim() && bot.remote + ? bot.handle + : '' + if (handle) return `remote:${handle.trim().replace(/^@+/, '').toLowerCase()}` + if (bot?.id !== undefined) return String(bot.id) + if (typeof bot?.handle === 'string' && bot.handle.trim()) return `remote:${bot.handle.trim().replace(/^@+/, '').toLowerCase()}` + return String(bot?.name ?? '') + } + function rosterBot(roster, id) { + if (!id) return undefined + return roster?.byId?.[id] ?? (roster?.bots ?? []).find(bot => bot.id === id) + } + function chatByHandle(remoteChats, handle) { + if (!handle) return undefined + return (remoteChats ?? []).find(chat => chat.remote?.handle === handle) + } + // Local picker rows use the team roster record (configured image / shape / color). + // Waiting-room "mine" matches the same local Bot; a friend's Bot uses its chat + // record or a handle-seeded remote look, plus any avatar the member payload carries. + function faceLook({ roster, remoteChats, bots, local, member } = {}) { + if (local) { + const full = rosterBot(roster, local.id) + return full ? { ...local, ...full } : local + } + if (!member) return undefined + const mine = (bots ?? []).find(bot => bot.handle && bot.handle === member.handle) + if (mine) return faceLook({ roster, bots, local: mine }) + const extra = {} + if (member.color) extra.color = member.color + if (member.avatar) extra.avatar = member.avatar + const chat = chatByHandle(remoteChats, member.handle) + if (chat) { + return { + ...chat, + name: member.displayName || chat.name, + remote: chat.remote ?? { handle: member.handle }, + ...extra, + } + } + return { + id: member.botId || member.handle, + name: member.displayName || member.label || member.handle, + handle: member.handle, + remote: member.handle ? { handle: member.handle } : undefined, + ...extra, + } + } + function FaceAvatar({ bot, size = 24 } = {}) { + const Avatar = globalThis.__flybot__?.BotAvatar + const Mark = globalThis.__flybot__?.BotMark + const seed = faceSeed(bot) + const color = typeof bot?.color === 'string' && bot.color.startsWith('#') ? bot.color : inkOf(seed) + let inner + if (typeof Avatar === 'function' && bot) inner = h(Avatar, { bot, size, badge: false, live: false }) + else if (typeof Mark === 'function' && bot) inner = h(Mark, { bot, size }) + else { + const image = bot?.avatar?.image + const emoji = bot?.avatar?.emoji + inner = h('span', { + className: 'fs-avatar', + 'data-color': color, + style: image ? undefined : { background: color, color: '#fff' }, + }, image ? h('img', { src: image, alt: '' }) : (emoji || initial(bot?.name))) + } + return h('span', { + className: 'fs-face-mark', + 'data-flybot': 'face-avatar', + 'data-bot-id': bot?.id, + 'data-handle': bot?.remote?.handle || bot?.handle, + 'data-color': color, + 'data-size': size, + 'aria-hidden': true, + style: { width: size, height: size, '--fs-face-size': `${size}px` }, + }, inner) + } + + function FaceTile({ bot, name, handle, mine, pick, selected, onToggle } = {}) { + const mark = h('span', { className: 'fs-tile-mark' }, + h(FaceAvatar, { bot, size: 48 }), + mine + ? h('span', { className: 'fs-tile-mine', 'data-flybot': 'face-mine', title: 'mine', 'aria-label': 'mine' }) + : null, + pick && selected + ? h('span', { className: 'fs-tile-check', 'data-flybot': 'face-pick-check', 'aria-hidden': true }, '✓') + : null) + const caption = h('span', { className: 'fs-tile-name', title: name }, name) + if (pick) { + return h('li', { className: selected ? 'fs-tile fs-tile-on' : 'fs-tile' }, + h('label', { className: 'fs-tile-hit' }, + h('input', { + type: 'checkbox', + className: 'fs-sr', + checked: !!selected, + 'aria-label': name, + onChange: onToggle, + }), + mark, + caption)) + } + return h('li', { + className: 'fs-tile', + 'data-flybot': 'face-member', + 'data-mine': mine || undefined, + 'data-handle': handle, + title: name, + }, mark, caption) + } + const AUTO_SEND_NOTE = 'When this is on, your Bots post in face-to-face rooms without asking each time.' function FaceAutoSend({ on, busy, onChange, error } = {}) { @@ -235,7 +370,7 @@ window.__ModuleLoader__.load({ } // Separate Face-to-face group screen (#92): pick Bots, join by number, live waiting room. - function FaceToFaceGroup({ actions, onClose } = {}) { + function FaceToFaceGroup({ actions, onClose, roster, remoteChats } = {}) { const [step, setStep] = useState('setup') const [bots, setBots] = useState([]) const [selected, setSelected] = useState([]) @@ -373,43 +508,39 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-dialog-head' }, h('div', { className: 'fs-name' }, 'Face-to-face group'), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: () => onClose?.() }, 'Close')), - waiting ? h('div', { 'data-flybot': 'face-wait' }, - h('div', { className: 'fs-hint', 'data-tick': tick }, candidate + waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, + h('div', { className: 'fs-hint fs-wait-count', 'data-flybot': 'face-wait-count', 'data-tick': tick }, candidate ? (room.expiresAt ? countdown(room.expiresAt) : 'Matching window open. Anyone with the number can still join.') : 'The room is open. The number is no longer valid.'), - h('ul', { className: 'fs-wait-roster', 'aria-label': 'Who has joined' }, - members.map(member => { - const mine = memberIsMine(member, bots, room) - return h('li', { key: member.handle || member.id, 'data-flybot': 'face-member', 'data-mine': mine || undefined, 'data-handle': member.handle }, - h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(member.displayName || member.label || member.handle)), - h('span', { className: 'fs-text' }, - h('span', { className: 'fs-name' }, member.displayName || member.label || member.handle), - member.ownerName ? h('span', { className: 'fs-sub' }, member.ownerName) : null), - mine ? h('span', { className: 'fs-tag', 'data-flybot': 'face-mine' }, 'mine') : null) - })), - members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null, - failures.length ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' }, - failures.map(item => `${item.name}: ${item.text}`).join(' ')) : null, - status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null, - h('div', { className: 'fs-face-actions' }, + h('div', { className: 'fs-wait-body' }, + h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, + members.map(member => h(FaceTile, { + key: member.handle || member.id, + bot: faceLook({ roster, remoteChats, bots, member }), + name: member.displayName || member.label || member.handle, + handle: member.handle, + mine: memberIsMine(member, bots, room), + }))), + members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null, + failures.length ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' }, + failures.map(item => `${item.name}: ${item.text}`).join(' ')) : null, + status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null), + h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-wait-foot' }, + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-leave', disabled: !!busy, onClick: leave }, 'Leave'), h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', disabled: !!busy || !room.roomId, onClick: enter, - }, 'Enter chat'), - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-leave', disabled: !!busy, onClick: leave }, 'Leave'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' }, + }, 'Enter chat'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' }, h('div', { className: 'fs-hint' }, 'Choose which of your Bots to bring, then type the same 6-digit number as the people next to you. You join at once — there is no preview.'), - h('ul', { className: 'fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' }, - bots.map(bot => h('li', { key: bot.id }, - h('label', null, - h('input', { - type: 'checkbox', checked: selected.includes(bot.id), - 'aria-label': bot.name, - onChange: () => toggle(bot.id), - }), - h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(bot.name)), - h('span', { className: 'fs-text' }, - h('span', { className: 'fs-name' }, bot.name), - bot.isMain ? h('span', { className: 'fs-sub' }, 'Main Bot') : null))))), + h('ul', { className: 'fs-tiles fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' }, + bots.map(bot => h(FaceTile, { + key: bot.id, + bot: faceLook({ roster, local: bot }), + name: bot.name, + pick: true, + selected: selected.includes(bot.id), + onToggle: () => toggle(bot.id), + }))), bots.length === 0 ? h('div', { className: 'fs-hint' }, 'Loading your Bots…') : null, h('div', { className: 'fs-hint', 'data-flybot': 'face-join-room' }, 'Available to this room only. Friends cannot see these Bots unless you invite them.'), @@ -682,7 +813,7 @@ window.__ModuleLoader__.load({ return () => { if (faceOpenHandler === fn) faceOpenHandler = null } } - function FriendsBots({ renderBot, remoteChats, actions } = {}) { + function FriendsBots({ renderBot, remoteChats, actions, roster } = {}) { useStyles() const [data, setData] = useState(() => readCachedRoster()) const [error, setError] = useState('') @@ -785,7 +916,7 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-group-open', onClick: () => setFaceOpen(true) }, 'Face-to-face group'), h('div', { className: 'fs-hint' }, 'Meet the people next to you. Pick your Bots and type the same 6-digit number.')) : null, adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings) }) : null, - faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, onClose: () => setFaceOpen(false) }) : null, + faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null, unavailable ? h('div', { className: 'fs-empty', 'data-flybot': 'social-unavailable' }, error) : error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, homes.map(entry => { const shut = closed[entry.home] === true @@ -1012,6 +1143,7 @@ window.__ModuleLoader__.load({ ChatApprovalDock, FriendBotRow, FaceToFaceGroup, + FaceAvatar, FaceRoomOverview, FaceAutoSend, apply(ctx) { diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 478b0e433..9c6e08dd3 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -211,6 +211,25 @@ export function platformStartedText(reason) { return 'The room is now open. The number is no longer valid.' } +// Peer look is optional: only pass through a stored color / avatar when the payload +// actually has one. Image URLs stay one line, no markup; a missing look is fine — the +// client then uses the host BotMark seeded by handle. +export function faceMemberLook(member) { + if (!member || typeof member !== 'object') return {} + const out = {} + const color = typeof member.color === 'string' ? member.color.trim().slice(0, 32) : '' + if (color && !/[\n<>]/.test(color)) out.color = color + const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {} + const avatar = {} + if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape + const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '') + if (image && image.length < 2048 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + const emoji = typeof src.emoji === 'string' ? src.emoji.trim() : (typeof member.emoji === 'string' ? member.emoji.trim() : '') + if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 8) + if (Object.keys(avatar).length) out.avatar = avatar + return out +} + export function viewFaceMember(member) { if (!member || typeof member !== 'object') return undefined const handle = normHandle(member.handle) @@ -221,6 +240,7 @@ export function viewFaceMember(member) { ownerName: cleanFaceName(member.owner_name || member.ownerName, ''), roomOnly: member.roomOnly === true || member.published !== true, label: cleanFaceName(member.label, '') || faceMemberLabel(member), + ...faceMemberLook(member), } } diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index be7e8d3e7..9d5c3a5cf 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -10,6 +10,8 @@ import { faceUntrustedText, viewFaceEvent, viewFaceRoom, + viewFaceMember, + faceMemberLook, viewMe, renderFaceEvent, rememberEvent, @@ -288,6 +290,20 @@ test('viewFaceRoom ignores a missing id', () => { assert.equal(viewFaceRoom({ room_id: 'frm_1', status: 'candidate', members: [] }).roomId, 'frm_1') }) +test('viewFaceMember keeps a friend Bot avatar when the payload has one', () => { + const plain = viewFaceMember({ handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob' }) + assert.equal(plain.avatar, undefined) + assert.equal(plain.color, undefined) + const pictured = viewFaceMember({ + handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob', + avatar: { image: 'https://example.test/coder.png', shape: 'star' }, color: 'green', + }) + assert.deepEqual(pictured.avatar, { image: 'https://example.test/coder.png', shape: 'star' }) + assert.equal(pictured.color, 'green') + assert.equal(faceMemberLook({ image: 'https://example.test/x.png' }).avatar.image, 'https://example.test/x.png') + assert.equal(faceMemberLook({ avatar: { image: 'javascript:alert(1)' } }).avatar, undefined) +}) + test('GET/PATCH /v1/me auto_approve_face_rooms, including in-place confirmation', async () => { const server = faceServer() const api = createFaceRoomApi({ server: 'https://s', token: 'otk_test', fetch: server.fetch }) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 3ed0aa4f0..88856edc7 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1247,6 +1247,19 @@ function loadSocialClient(callImpl) { } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) + const stubMark = (kind) => ({ bot, size } = {}) => h('span', { + className: 'bt-mark', + 'data-flybot': kind, + 'data-bot-id': bot?.id, + 'data-handle': bot?.remote?.handle || bot?.handle, + 'data-shape': bot?.avatar?.shape, + 'data-image': bot?.avatar?.image, + 'data-color': bot?.color, + 'data-emoji': bot?.avatar?.emoji, + 'data-size': size, + }) + sandbox.__flybot__.BotAvatar = stubMark('bot-avatar') + sandbox.__flybot__.BotMark = stubMark('bot-mark') const walk = (node, all = []) => { if (!node || typeof node !== 'object') return all if (typeof node.type === 'function') return walk(node.type(node.props), all) @@ -1264,7 +1277,7 @@ function loadSocialClient(callImpl) { useMemo: f => f(), })) } - return { registered, entries, walk, pluginOf, h } + return { registered, entries, walk, pluginOf, h, sandbox } } test('social HTTP keep-alive reuses one Agent', () => { @@ -1540,18 +1553,49 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch return {} }) const bots = [{ id: 'a', name: 'Chief', isMain: true, handle: '@a.me.home' }, { id: 'b', name: 'Writer', handle: '@b.me.home' }] - const setup = (states) => walk(pluginOf(states).FaceToFaceGroup({ actions: { openSession: id => opened.push(id) } })) + const roster = { + byId: { + a: { id: 'a', name: 'Chief', isMain: true, color: 'blue', avatar: { shape: 'whale' } }, + b: { id: 'b', name: 'Writer', color: 'orange', avatar: { shape: 'star' } }, + }, + bots: [ + { id: 'a', name: 'Chief', isMain: true, color: 'blue', avatar: { shape: 'whale' } }, + { id: 'b', name: 'Writer', color: 'orange', avatar: { shape: 'star' } }, + ], + } + const remoteChats = [{ + id: 'r-coder', name: 'Coder', remote: { handle: '@coder.bob.home' }, + color: 'green', avatar: { image: 'https://example.test/coder.png' }, + }] + const faceProps = { actions: { openSession: id => opened.push(id) }, roster, remoteChats } + const setup = (states) => walk(pluginOf(states).FaceToFaceGroup(faceProps)) const picker = setup(['setup', bots, ['a'], '', false, null, [], null, 0]) assert.ok(picker.some(n => n.props['data-flybot'] === 'face-group')) - assert.ok(picker.some(n => n.props['data-flybot'] === 'face-pick')) + assert.ok(picker.some(n => n.props['data-flybot'] === 'face-pick' && /\bfs-tiles\b/.test(n.props.className))) assert.ok(picker.some(n => n.props['data-flybot'] === 'face-join')) + const pickAvatars = picker.filter(n => n.props['data-flybot'] === 'face-avatar') + assert.equal(pickAvatars.length, 2) + const chiefMark = picker.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a') + const writerMark = picker.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'b') + assert.ok(chiefMark, 'Chief uses the host BotAvatar') + assert.ok(writerMark, 'Writer uses the host BotAvatar') + assert.equal(chiefMark.props['data-shape'], 'whale') + assert.equal(writerMark.props['data-shape'], 'star') + assert.notEqual(chiefMark.props['data-shape'], writerMark.props['data-shape']) + assert.equal(chiefMark.props['data-size'], 48) + assert.equal(writerMark.props['data-size'], 48) + assert.equal(picker.filter(n => n.props.className === 'fs-avatar' && n.props['data-flybot'] === undefined).length, 0) + assert.ok(picker.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) + assert.ok(picker.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer'))) + assert.equal(picker.some(n => n.props.className === 'fs-sub' && n.children.includes('Main Bot')), false) const hint = picker.find(n => n.props['data-flybot'] === 'face-join-room') assert.ok(hint) assert.match(hint.children.join(''), /Available to this room only/) assert.equal(picker.some(n => /published/i.test(String(n.children ?? ''))), false) assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Chief' && n.props.checked === true)) assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Writer' && n.props.checked === false)) + assert.equal(picker.filter(n => n.props['data-flybot'] === 'face-pick-check').length, 1) const short = setup(['setup', bots, ['a'], '12ab', false, null, [], null, 0]) await short.find(n => n.type === 'form' && n.props['data-flybot'] === 'face-join').props.onSubmit({ preventDefault() {} }) @@ -1583,13 +1627,40 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch } const wait = setup(['waiting', bots, ['a'], '123456', false, null, [{ botId: 'b', name: 'Writer', text: 'This Bot cannot join that room.' }], room, 0]) assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait')) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-roster' && /\bfs-tiles\b/.test(n.props.className))) const mine = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@a.me.home') const peer = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@coder.bob.home') assert.equal(mine.props['data-mine'], true) - assert.ok(wait.some(n => n.props['data-flybot'] === 'face-mine' && n.children.includes('mine'))) + const mineDot = wait.find(n => n.props['data-flybot'] === 'face-mine') + assert.ok(mineDot) + assert.equal(mineDot.props.className, 'fs-tile-mine') + assert.equal(mineDot.props['aria-label'], 'mine') + assert.equal(mineDot.props.title, 'mine') + assert.equal(mineDot.children.includes('mine'), false) assert.equal(peer.props['data-mine'], undefined) - assert.ok(wait.some(n => n.children.includes('Me'))) - assert.ok(wait.some(n => n.children.includes('Bob'))) + const mineMark = wait.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a') + const peerMark = wait.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-handle'] === '@coder.bob.home') + assert.ok(mineMark, 'waiting roster uses the same Chief avatar as the picker') + assert.equal(mineMark.props['data-shape'], 'whale') + assert.equal(mineMark.props['data-size'], 48) + assert.ok(peerMark, "a friend's Bot uses its own look when the chat record has one") + assert.equal(peerMark.props['data-image'], 'https://example.test/coder.png') + assert.equal(peerMark.props['data-size'], 48) + assert.notEqual(mineMark.props['data-bot-id'], peerMark.props['data-bot-id']) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder'))) + assert.equal(mine.children.some(n => n.props?.className === 'fs-sub' || (typeof n.props?.className === 'string' && n.props.className.includes('fs-tag'))), false) + assert.equal(peer.children.some(n => n.props?.className === 'fs-sub'), false) + assert.equal(wait.some(n => n.props['data-flybot'] === 'face-owner'), false) + assert.equal(wait.some(n => n.props.className === 'fs-wait-hint' || n.props.className === 'fs-wait-name'), false) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-count')) + const foot = wait.find(n => n.props['data-flybot'] === 'face-wait-foot') + assert.ok(foot) + const footBtns = (foot.children ?? []).filter(n => n && typeof n === 'object') + assert.equal(footBtns[0].props['data-flybot'], 'face-leave') + assert.equal(footBtns[1].props['data-flybot'], 'face-enter') + assert.equal(footBtns[0].props.className, 'fs-btn') + assert.match(footBtns[1].props.className, /fs-btn-primary/) assert.match(wait.find(n => n.props['data-flybot'] === 'face-join-fail').children.join(''), /Writer:/) assert.equal(wait.some(n => /published/i.test(String(n.children ?? ''))), false) const enter = wait.find(n => n.props['data-flybot'] === 'face-enter') @@ -1636,7 +1707,9 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch cursor = 0 const liveNodes = liveClient.walk(livePlugin.FaceToFaceGroup({ actions: {} })) assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@writer.alice.home')) - assert.ok(liveNodes.some(n => n.children.includes('Alice'))) + assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer'))) + assert.equal(liveNodes.some(n => n.props['data-flybot'] === 'face-owner'), false) + assert.equal(liveNodes.some(n => Array.isArray(n.children) && n.children.includes('Alice')), false) const autoOpened = [] const autoPlugin = pluginOf(['waiting', bots, ['a'], '123456', false, null, [], { ...room, status: 'open' }, 0], true) @@ -1644,6 +1717,21 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.deepEqual(autoOpened, ['g1', 'close']) }) +test('Face-to-face pick list still paints a distinct per-Bot color without host BotAvatar', () => { + const { walk, pluginOf, sandbox } = loadSocialClient(async () => ({ bots: [] })) + delete sandbox.__flybot__.BotAvatar + delete sandbox.__flybot__.BotMark + const bots = [{ id: 'chief-id', name: 'Chief', isMain: true }, { id: 'writer-id', name: 'Writer' }] + const tree = walk(pluginOf(['setup', bots, ['chief-id'], '', false, null, [], null, 0]).FaceToFaceGroup({})) + const avatars = tree.filter(n => n.props['data-flybot'] === 'face-avatar') + assert.equal(avatars.length, 2) + assert.equal(avatars[0].props['data-bot-id'], 'chief-id') + assert.equal(avatars[1].props['data-bot-id'], 'writer-id') + assert.notEqual(avatars[0].props['data-color'], avatars[1].props['data-color']) + const letters = tree.filter(n => n.props.className === 'fs-avatar').map(n => n.children.join('')) + assert.deepEqual(letters.sort(), ['C', 'W']) +}) + test('face-to-face: several local Bots join the same number and leave together', async (t) => { const { ctx, call, stop } = await team() t.after(stop)