@decentnetwork/beagle 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,10 @@ export declare class CarrierNode extends EventEmitter {
99
99
  cancelSend(userid: string, fileId: string): boolean;
100
100
  acceptFile(userid: string, fileNumber: number): void;
101
101
  sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
102
+ /** Send on an application packet id. Used for the components channel (165);
103
+ * unavailable on an SDK build without custom packets, which simply means
104
+ * this peer can't offer forms — never that chat breaks. */
105
+ sendCustomPacket(userid: string, id: number, data: Uint8Array): Promise<void>;
102
106
  sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
103
107
  /** Native (iOS/Android/C) friends can't see toxcore file transfer; they only
104
108
  * receive the inline FileModel envelope. decentlan detects this from the DHT
@@ -102,6 +102,8 @@ export class CarrierNode extends EventEmitter {
102
102
  this.emit("message", m.pubkey, m.text, m.via);
103
103
  });
104
104
  peer.onFriendRequest((r) => this.emit("friend-request", r));
105
+ // Brief 30 components channel. Forwarded raw; the host validates.
106
+ peer.onCustomPacket?.((evt) => this.emit("custom-packet", evt));
105
107
  peer.onFriendConnection?.((e) => this.emit("friend-connection", e));
106
108
  peer.onInlineFile?.((f) => this.emit("inline-file", f));
107
109
  peer.onInvite?.((evt) => {
@@ -189,6 +191,15 @@ export class CarrierNode extends EventEmitter {
189
191
  throw new Error("This peer SDK build has no inline-file support");
190
192
  await peer.sendInlineFile(userid, { name, data });
191
193
  }
194
+ /** Send on an application packet id. Used for the components channel (165);
195
+ * unavailable on an SDK build without custom packets, which simply means
196
+ * this peer can't offer forms — never that chat breaks. */
197
+ async sendCustomPacket(userid, id, data) {
198
+ const peer = this.#require();
199
+ if (!peer.sendCustomPacket)
200
+ throw new Error("This peer SDK build has no custom-packet channel");
201
+ await peer.sendCustomPacket(userid, id, data);
202
+ }
192
203
  async sendCallSignal(userid, data) {
193
204
  const peer = this.#require();
194
205
  if (!peer.sendInvite)
@@ -0,0 +1,67 @@
1
+ /** Custom packet id for the components channel.
2
+ *
3
+ * 160-191 is toxcore's LOSSLESS range — a form must not be silently dropped.
4
+ * decentlan owns 161 (session), 162 (dora), 163 (IP), 164 (rate); 165 is the
5
+ * first free slot. Clients that don't implement this id never see the packet,
6
+ * which is exactly the backward-compatibility story we want. */
7
+ export declare const PACKET_ID_CHAT_COMPONENTS = 165;
8
+ /** Payload version. Receivers ignore payloads they don't know how to read
9
+ * rather than best-effort parsing a future shape. */
10
+ export declare const COMPONENTS_WIRE_VERSION = 1;
11
+ export declare const MAX_COMPONENTS = 12;
12
+ export declare const MAX_OPTIONS = 40;
13
+ export declare const MAX_LABEL_LEN = 120;
14
+ export declare const MAX_ID_LEN = 64;
15
+ export declare const MAX_VALUE_LEN = 512;
16
+ export declare const MAX_CUSTOM_ID_LEN = 128;
17
+ /** Hard ceiling on a decoded payload before we even try to parse it. */
18
+ export declare const MAX_PAYLOAD_BYTES: number;
19
+ export type ComponentType = "select" | "submit" | "text" | "button" | "attachment";
20
+ export interface SelectOption {
21
+ label: string;
22
+ value: string;
23
+ }
24
+ export interface ChatComponent {
25
+ type: ComponentType;
26
+ id: string;
27
+ label?: string;
28
+ required?: boolean;
29
+ default?: string;
30
+ placeholder?: string;
31
+ options?: SelectOption[];
32
+ }
33
+ export interface ComponentsMessage {
34
+ v: number;
35
+ type: "components";
36
+ /** Chosen by the agent; echoed back so it can route the reply. */
37
+ custom_id: string;
38
+ components: ChatComponent[];
39
+ }
40
+ export interface InteractionMessage {
41
+ v: number;
42
+ type: "interaction";
43
+ custom_id: string;
44
+ /** id of the submit/button that fired. */
45
+ component: string;
46
+ values: Record<string, string | {
47
+ fileId: string;
48
+ name: string;
49
+ size: number;
50
+ }>;
51
+ }
52
+ /**
53
+ * Validate a remote components payload. Returns null when nothing renderable
54
+ * survives — the caller then shows only the text message, which is the correct
55
+ * degraded state, never an error.
56
+ */
57
+ export declare function parseComponentsMessage(raw: unknown): ComponentsMessage | null;
58
+ /** Build the interaction reply. Values are the user's own input, but they are
59
+ * bounded here too so a pathological local state can't emit an oversized
60
+ * packet at the peer. */
61
+ export declare function buildInteraction(customId: string, component: string, values: Record<string, string>): InteractionMessage;
62
+ /** Decode a received packet. Returns null for anything malformed or oversized —
63
+ * a peer must not be able to make us throw on the receive path. */
64
+ export declare function decodePacket(data: Uint8Array): ComponentsMessage | InteractionMessage | null;
65
+ /** Validate an inbound interaction (the agent side of the channel). */
66
+ export declare function parseInteractionMessage(raw: unknown): InteractionMessage | null;
67
+ export declare function encodePacket(msg: ComponentsMessage | InteractionMessage): Uint8Array;
@@ -0,0 +1,208 @@
1
+ // Brief 30 — interactive components in chat.
2
+ //
3
+ // An agent declares a form as DATA; this client renders it with its own trusted
4
+ // widgets. No HTML crosses the wire, no URL is fetched, no script runs. The UI's
5
+ // escape-everything policy (dkHtmlEscape) is untouched — components are a
6
+ // separate, whitelisted channel, not an escape hatch from it.
7
+ //
8
+ // Wire shape: a sibling packet, not a field on the chat message. Answering the
9
+ // brief's first open question that way makes "old clients still see the
10
+ // message" a property of the protocol rather than a rule senders must remember:
11
+ // the human-readable text goes out as an ordinary chat message that every
12
+ // existing client already renders, and the component payload rides a custom
13
+ // packet id that any client not built for it never even receives.
14
+ //
15
+ // EVERY value here arrives from a remote peer. The validator is the trust
16
+ // boundary: it whitelists types, caps every count and length, and drops what it
17
+ // does not understand instead of guessing.
18
+ /** Custom packet id for the components channel.
19
+ *
20
+ * 160-191 is toxcore's LOSSLESS range — a form must not be silently dropped.
21
+ * decentlan owns 161 (session), 162 (dora), 163 (IP), 164 (rate); 165 is the
22
+ * first free slot. Clients that don't implement this id never see the packet,
23
+ * which is exactly the backward-compatibility story we want. */
24
+ export const PACKET_ID_CHAT_COMPONENTS = 165;
25
+ /** Payload version. Receivers ignore payloads they don't know how to read
26
+ * rather than best-effort parsing a future shape. */
27
+ export const COMPONENTS_WIRE_VERSION = 1;
28
+ // --- caps -------------------------------------------------------------------
29
+ // "The client caps sizes/counts itself — a peer must not be able to freeze
30
+ // another peer's UI." (Brief 30, Security). These are deliberately small: a
31
+ // legitimate form is a handful of fields, and anything larger is either a bug
32
+ // or an attempt to hang the renderer.
33
+ export const MAX_COMPONENTS = 12;
34
+ export const MAX_OPTIONS = 40;
35
+ export const MAX_LABEL_LEN = 120;
36
+ export const MAX_ID_LEN = 64;
37
+ export const MAX_VALUE_LEN = 512;
38
+ export const MAX_CUSTOM_ID_LEN = 128;
39
+ /** Hard ceiling on a decoded payload before we even try to parse it. */
40
+ export const MAX_PAYLOAD_BYTES = 64 * 1024;
41
+ /** Types this build actually renders. Anything else is dropped — forward
42
+ * compatibility per the brief: an older client must ignore, not break. */
43
+ const RENDERABLE = new Set(["select", "submit", "text"]);
44
+ const str = (v, max) => {
45
+ if (typeof v !== "string")
46
+ return undefined;
47
+ const s = v.trim();
48
+ if (!s || s.length > max)
49
+ return undefined;
50
+ return s;
51
+ };
52
+ /**
53
+ * Validate a remote components payload. Returns null when nothing renderable
54
+ * survives — the caller then shows only the text message, which is the correct
55
+ * degraded state, never an error.
56
+ */
57
+ export function parseComponentsMessage(raw) {
58
+ if (!raw || typeof raw !== "object")
59
+ return null;
60
+ const o = raw;
61
+ if (o.type !== "components")
62
+ return null;
63
+ if (o.v !== COMPONENTS_WIRE_VERSION)
64
+ return null;
65
+ const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
66
+ if (!customId)
67
+ return null;
68
+ if (!Array.isArray(o.components))
69
+ return null;
70
+ const seen = new Set();
71
+ const out = [];
72
+ for (const entry of o.components.slice(0, MAX_COMPONENTS)) {
73
+ if (!entry || typeof entry !== "object")
74
+ continue;
75
+ const c = entry;
76
+ const type = typeof c.type === "string" ? c.type : "";
77
+ if (!RENDERABLE.has(type))
78
+ continue; // unknown/unsupported: drop, don't guess
79
+ const id = str(c.id, MAX_ID_LEN);
80
+ if (!id || seen.has(id))
81
+ continue; // duplicate ids would alias each other's values
82
+ seen.add(id);
83
+ const component = { type: type, id };
84
+ const label = str(c.label, MAX_LABEL_LEN);
85
+ if (label)
86
+ component.label = label;
87
+ if (c.required === true)
88
+ component.required = true;
89
+ const placeholder = str(c.placeholder, MAX_LABEL_LEN);
90
+ if (placeholder)
91
+ component.placeholder = placeholder;
92
+ if (type === "select") {
93
+ if (!Array.isArray(c.options))
94
+ continue; // a select with no options is unusable
95
+ const options = [];
96
+ const seenValues = new Set();
97
+ for (const raw of c.options.slice(0, MAX_OPTIONS)) {
98
+ if (!raw || typeof raw !== "object")
99
+ continue;
100
+ const opt = raw;
101
+ const value = str(opt.value, MAX_VALUE_LEN);
102
+ if (!value || seenValues.has(value))
103
+ continue;
104
+ seenValues.add(value);
105
+ options.push({ label: str(opt.label, MAX_LABEL_LEN) ?? value, value });
106
+ }
107
+ if (!options.length)
108
+ continue;
109
+ component.options = options;
110
+ // A default that isn't one of the options would submit a value the agent
111
+ // never offered; ignore it rather than pass it through.
112
+ const def = str(c.default, MAX_VALUE_LEN);
113
+ if (def && options.some((o2) => o2.value === def))
114
+ component.default = def;
115
+ }
116
+ if (type === "text") {
117
+ const def = str(c.default, MAX_VALUE_LEN);
118
+ if (def)
119
+ component.default = def;
120
+ }
121
+ out.push(component);
122
+ }
123
+ // A form with no way to submit can never be completed — treat it as noise.
124
+ if (!out.some((c) => c.type === "submit"))
125
+ return null;
126
+ // ...and a submit button with nothing to fill in is equally pointless.
127
+ if (!out.some((c) => c.type === "select" || c.type === "text"))
128
+ return null;
129
+ return { v: COMPONENTS_WIRE_VERSION, type: "components", custom_id: customId, components: out };
130
+ }
131
+ /** Build the interaction reply. Values are the user's own input, but they are
132
+ * bounded here too so a pathological local state can't emit an oversized
133
+ * packet at the peer. */
134
+ export function buildInteraction(customId, component, values) {
135
+ const clean = {};
136
+ for (const [k, v] of Object.entries(values)) {
137
+ const key = str(k, MAX_ID_LEN);
138
+ if (!key)
139
+ continue;
140
+ clean[key] = typeof v === "string" ? v.slice(0, MAX_VALUE_LEN) : "";
141
+ }
142
+ return {
143
+ v: COMPONENTS_WIRE_VERSION,
144
+ type: "interaction",
145
+ custom_id: customId.slice(0, MAX_CUSTOM_ID_LEN),
146
+ component: component.slice(0, MAX_ID_LEN),
147
+ values: clean,
148
+ };
149
+ }
150
+ /** Decode a received packet. Returns null for anything malformed or oversized —
151
+ * a peer must not be able to make us throw on the receive path. */
152
+ export function decodePacket(data) {
153
+ if (!data?.length || data.length > MAX_PAYLOAD_BYTES)
154
+ return null;
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(Buffer.from(data).toString("utf-8"));
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ const o = parsed;
163
+ if (!o || typeof o !== "object")
164
+ return null;
165
+ if (o.type === "components")
166
+ return parseComponentsMessage(o);
167
+ if (o.type === "interaction")
168
+ return parseInteractionMessage(o);
169
+ return null;
170
+ }
171
+ /** Validate an inbound interaction (the agent side of the channel). */
172
+ export function parseInteractionMessage(raw) {
173
+ if (!raw || typeof raw !== "object")
174
+ return null;
175
+ const o = raw;
176
+ if (o.type !== "interaction" || o.v !== COMPONENTS_WIRE_VERSION)
177
+ return null;
178
+ const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
179
+ const component = str(o.component, MAX_ID_LEN);
180
+ if (!customId || !component)
181
+ return null;
182
+ const values = {};
183
+ if (o.values && typeof o.values === "object") {
184
+ for (const [k, v] of Object.entries(o.values).slice(0, MAX_COMPONENTS)) {
185
+ const key = str(k, MAX_ID_LEN);
186
+ if (!key)
187
+ continue;
188
+ if (typeof v === "string") {
189
+ values[key] = v.slice(0, MAX_VALUE_LEN);
190
+ continue;
191
+ }
192
+ // File values are {fileId, name, size} — fileId is the content sha256,
193
+ // which is what makes the reference exact (Brief 30 §3).
194
+ if (v && typeof v === "object") {
195
+ const f = v;
196
+ const fileId = str(f.fileId, 128);
197
+ const name = str(f.name, MAX_LABEL_LEN);
198
+ const size = typeof f.size === "number" && Number.isFinite(f.size) ? f.size : undefined;
199
+ if (fileId && name && size !== undefined)
200
+ values[key] = { fileId, name, size };
201
+ }
202
+ }
203
+ }
204
+ return { v: COMPONENTS_WIRE_VERSION, type: "interaction", custom_id: customId, component, values };
205
+ }
206
+ export function encodePacket(msg) {
207
+ return new Uint8Array(Buffer.from(JSON.stringify(msg), "utf-8"));
208
+ }
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.0";
1
+ window.__DK_UI_VERSION="0.1.1";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -354,7 +354,11 @@ function useDaemonData() {
354
354
  status: m.dir === "out" ? m.status === "queued" ? "queued" : "read" : void 0,
355
355
  // Delivery path: "online" = live session, "offline" = express relay.
356
356
  // Incoming messages carry this; the bubble colors them differently.
357
- via: m.via
357
+ via: m.via,
358
+ // Brief 30: a validated component form, already whitelisted host-side.
359
+ // Passed through untouched — the renderer treats every string as text,
360
+ // never markup.
361
+ components: m.components
358
362
  }));
359
363
  const withDay = msgs.length ? [{ day: dkDayLabel(arr[0].ts) }].concat(msgs) : [];
360
364
  setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
@@ -1389,8 +1393,184 @@ function dkMarkdownHtml(src) {
1389
1393
  function MarkdownText({ text }) {
1390
1394
  return /* @__PURE__ */ React.createElement("div", { className: "dk-md", dangerouslySetInnerHTML: { __html: dkMarkdownHtml(text) } });
1391
1395
  }
1392
- function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onCall, selMode, selected, onToggleSel, busy }) {
1396
+ function DkChatForm({ form, submitted, peer, onSubmit }) {
1397
+ const items = form && form.components || [];
1398
+ const [vals, setVals] = React.useState(() => {
1399
+ const init = {};
1400
+ for (const c of items)
1401
+ if (c.type === "select" || c.type === "text")
1402
+ init[c.id] = c.default || "";
1403
+ return init;
1404
+ });
1405
+ const [files, setFiles] = React.useState({});
1406
+ const [busy, setBusy] = React.useState(false);
1407
+ const [step, setStep] = React.useState("");
1408
+ const [err, setErr] = React.useState("");
1409
+ if (submitted) {
1410
+ return /* @__PURE__ */ React.createElement("div", { style: {
1411
+ display: "flex",
1412
+ flexDirection: "column",
1413
+ gap: 6,
1414
+ padding: "10px 12px",
1415
+ borderRadius: 12,
1416
+ border: "1px solid var(--line)",
1417
+ background: "var(--bub-them)",
1418
+ opacity: 0.85,
1419
+ minWidth: 240
1420
+ } }, items.filter((c) => window.ChatComponents.INPUT_TYPES.has(c.type)).map((c) => {
1421
+ const raw = submitted.values && submitted.values[c.id] || "";
1422
+ const shown = raw && typeof raw === "object" ? raw.name : ((c.options || []).find((o) => o.value === raw) || {}).label || raw;
1423
+ return /* @__PURE__ */ React.createElement("div", { key: c.id, style: { display: "flex", gap: 8, fontFamily: "var(--mono)", fontSize: 12.5 } }, /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)" } }, c.label || c.id), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--text)", fontWeight: 600 } }, shown));
1424
+ }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 6, marginTop: 2, color: "var(--faint)", fontSize: 11.5, fontFamily: "var(--mono)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 3, color: "var(--ok, #46d17f)" }), /* @__PURE__ */ React.createElement("span", null, "submitted")));
1425
+ }
1426
+ const attachments = items.filter((c) => c.type === "attachment");
1427
+ const missing = items.filter((c) => c.required && window.ChatComponents.INPUT_TYPES.has(c.type) && (c.type === "attachment" ? !files[c.id] : !vals[c.id]));
1428
+ const send = async (componentId) => {
1429
+ if (missing.length) {
1430
+ setErr("Fill in: " + missing.map((c) => c.label || c.id).join(", "));
1431
+ return;
1432
+ }
1433
+ setBusy(true);
1434
+ setErr("");
1435
+ try {
1436
+ const CC = window.ChatComponents;
1437
+ const answers = { ...vals };
1438
+ for (const c of attachments) {
1439
+ const f = files[c.id];
1440
+ if (!f)
1441
+ continue;
1442
+ setStep("Sending " + f.name + "\u2026");
1443
+ const up = await DK.sendFile(peer.userId || peer.id, f);
1444
+ if (!up || !up.ok) {
1445
+ setErr(up && up.error || "file send failed");
1446
+ setBusy(false);
1447
+ setStep("");
1448
+ return;
1449
+ }
1450
+ if (!up.fileId) {
1451
+ setErr("This peer received the file inline, which carries no file id \u2014 send it as a normal message instead.");
1452
+ setBusy(false);
1453
+ setStep("");
1454
+ return;
1455
+ }
1456
+ answers[c.id] = { fileId: up.fileId, name: up.name || f.name, size: up.size != null ? up.size : f.size };
1457
+ }
1458
+ setStep("");
1459
+ const text = CC.encodeInteraction(CC.buildInteraction(form.custom_id, componentId, answers));
1460
+ const r = await fetch("/api/chat-send", {
1461
+ method: "POST",
1462
+ headers: { "content-type": "application/json" },
1463
+ body: JSON.stringify({ userid: peer.userId || peer.id, text })
1464
+ }).then((x) => x.json());
1465
+ if (!r.ok) {
1466
+ setErr(r.error || "submit failed");
1467
+ setBusy(false);
1468
+ return;
1469
+ }
1470
+ if (onSubmit)
1471
+ onSubmit();
1472
+ } catch (e) {
1473
+ setErr(String(e && e.message || e));
1474
+ setBusy(false);
1475
+ }
1476
+ };
1477
+ return /* @__PURE__ */ React.createElement("div", { style: {
1478
+ display: "flex",
1479
+ flexDirection: "column",
1480
+ gap: 10,
1481
+ padding: "12px 13px",
1482
+ borderRadius: 12,
1483
+ border: "1px solid var(--line)",
1484
+ background: "var(--bub-them)",
1485
+ minWidth: 260
1486
+ } }, items.map((c) => {
1487
+ if (c.type === "select") {
1488
+ return /* @__PURE__ */ React.createElement("label", { key: c.id, style: { display: "flex", flexDirection: "column", gap: 4 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, c.label || c.id, c.required ? " *" : ""), /* @__PURE__ */ React.createElement(
1489
+ "select",
1490
+ {
1491
+ value: vals[c.id] || "",
1492
+ disabled: busy,
1493
+ onChange: (e) => setVals((v) => ({ ...v, [c.id]: e.target.value })),
1494
+ style: {
1495
+ fontFamily: "var(--mono)",
1496
+ fontSize: 13,
1497
+ padding: "7px 8px",
1498
+ borderRadius: 8,
1499
+ border: "1px solid var(--line)",
1500
+ background: "var(--panel)",
1501
+ color: "var(--text)"
1502
+ }
1503
+ },
1504
+ /* @__PURE__ */ React.createElement("option", { value: "" }, c.placeholder || "\u2014"),
1505
+ (c.options || []).map((o) => /* @__PURE__ */ React.createElement("option", { key: o.value, value: o.value }, o.label))
1506
+ ));
1507
+ }
1508
+ if (c.type === "text") {
1509
+ return /* @__PURE__ */ React.createElement("label", { key: c.id, style: { display: "flex", flexDirection: "column", gap: 4 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, c.label || c.id, c.required ? " *" : ""), /* @__PURE__ */ React.createElement(
1510
+ "input",
1511
+ {
1512
+ type: "text",
1513
+ value: vals[c.id] || "",
1514
+ disabled: busy,
1515
+ placeholder: c.placeholder || "",
1516
+ onChange: (e) => setVals((v) => ({ ...v, [c.id]: e.target.value })),
1517
+ style: {
1518
+ fontFamily: "var(--mono)",
1519
+ fontSize: 13,
1520
+ padding: "7px 8px",
1521
+ borderRadius: 8,
1522
+ border: "1px solid var(--line)",
1523
+ background: "var(--panel)",
1524
+ color: "var(--text)"
1525
+ }
1526
+ }
1527
+ ));
1528
+ }
1529
+ if (c.type === "attachment") {
1530
+ const picked = files[c.id];
1531
+ const tooBig = picked && c.max_bytes && picked.size > c.max_bytes;
1532
+ return /* @__PURE__ */ React.createElement("label", { key: c.id, style: { display: "flex", flexDirection: "column", gap: 4 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, c.label || c.id, c.required ? " *" : ""), /* @__PURE__ */ React.createElement(
1533
+ "input",
1534
+ {
1535
+ type: "file",
1536
+ disabled: busy,
1537
+ accept: (c.accept || []).join(",") || void 0,
1538
+ onChange: (e) => setFiles((f) => ({ ...f, [c.id]: e.target.files && e.target.files[0] })),
1539
+ style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--text)" }
1540
+ }
1541
+ ), picked && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: tooBig ? "#f59e0b" : "var(--faint)" } }, dkFileSize(picked.size), tooBig ? " \u2014 larger than this form asked for" : ""));
1542
+ }
1543
+ if (c.type === "submit") {
1544
+ return /* @__PURE__ */ React.createElement(
1545
+ "button",
1546
+ {
1547
+ key: c.id,
1548
+ onClick: () => send(c.id),
1549
+ disabled: busy,
1550
+ style: {
1551
+ marginTop: 2,
1552
+ padding: "8px 14px",
1553
+ borderRadius: 10,
1554
+ border: "none",
1555
+ cursor: busy ? "default" : "pointer",
1556
+ background: "var(--accent)",
1557
+ color: "#fff",
1558
+ fontFamily: "var(--mono)",
1559
+ fontSize: 13,
1560
+ fontWeight: 700,
1561
+ opacity: busy ? 0.6 : 1
1562
+ }
1563
+ },
1564
+ busy ? step || "\u2026" : c.label || "Submit"
1565
+ );
1566
+ }
1567
+ return null;
1568
+ }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
1569
+ }
1570
+ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onCall, onFormSubmit, answered, selMode, selected, onToggleSel, busy }) {
1393
1571
  const mine = m.from === "me";
1572
+ const read = window.ChatComponents ? window.ChatComponents.readMessage(m.text || "") : { text: m.text, form: null, interaction: null };
1573
+ const submitted = read.form && answered ? answered.get(read.form.custom_id) : null;
1394
1574
  const rtcFile = !mine && !m.file ? dkRtcFileFromText(peer.userId || peer.id, m.text) : null;
1395
1575
  const callRec = !m.file ? dkCallFromText(m.text) : null;
1396
1576
  return /* @__PURE__ */ React.createElement(
@@ -1636,7 +1816,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onC
1636
1816
  lineHeight: 1.4,
1637
1817
  letterSpacing: -0.1,
1638
1818
  wordBreak: "break-word"
1639
- } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: m.text })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), !mine && m.via && /* @__PURE__ */ React.createElement(
1819
+ } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text })), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), !mine && m.via && /* @__PURE__ */ React.createElement(
1640
1820
  "span",
1641
1821
  {
1642
1822
  title: m.via === "offline" ? "delivered via express relay (offline)" : "delivered over a live session (online)",
@@ -1714,6 +1894,13 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1714
1894
  return { ...m, file: { ...m.file, ...filePatch[m.id] } };
1715
1895
  });
1716
1896
  }, [baseThread, filePatch]);
1897
+ const answered = React.useMemo(() => {
1898
+ if (!window.ChatComponents)
1899
+ return /* @__PURE__ */ new Map();
1900
+ return window.ChatComponents.answers(
1901
+ thread.filter((m) => m.from === "me").map((m) => m.text || "")
1902
+ );
1903
+ }, [thread]);
1717
1904
  const scrollToBottom = () => {
1718
1905
  const el = scrollRef.current;
1719
1906
  if (el)
@@ -1949,6 +2136,8 @@ ${peer.address}`
1949
2136
  onRetry: doRetry,
1950
2137
  onReveal: doReveal,
1951
2138
  onCall,
2139
+ onFormSubmit: () => onReloadThread && onReloadThread(),
2140
+ answered,
1952
2141
  busy: m.id ? fileBusy[m.id] : void 0,
1953
2142
  selMode,
1954
2143
  selected: sel.has(m.id),
@@ -49,6 +49,9 @@
49
49
  <script src="vendor/qrcode.js"></script>
50
50
  <!-- peer-webrtc call engine (bundled IIFE) — exposes window.PeerWebRTC. -->
51
51
  <script src="vendor/peer-webrtc.js"></script>
52
+ <!-- chat form schema (bundled IIFE) — exposes window.ChatComponents. The only
53
+ place the format is interpreted; the backend just carries the message. -->
54
+ <script src="vendor/chat-components.js"></script>
52
55
  <!-- The whole desktop app, esbuild-transpiled from src/ui/desktop/*.jsx. -->
53
56
  <script src="app.js"></script>
54
57
  </body>
@@ -0,0 +1,275 @@
1
+ var ChatComponents = (() => {
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // node_modules/@decentnetwork/chat-components/dist/index.js
21
+ var dist_exports = {};
22
+ __export(dist_exports, {
23
+ FORM_FENCE: () => FORM_FENCE,
24
+ INPUT_TYPES: () => INPUT_TYPES,
25
+ INTERACTION_FENCE: () => INTERACTION_FENCE,
26
+ MAX_ACCEPT: () => MAX_ACCEPT,
27
+ MAX_COMPONENTS: () => MAX_COMPONENTS,
28
+ MAX_CUSTOM_ID_LEN: () => MAX_CUSTOM_ID_LEN,
29
+ MAX_ID_LEN: () => MAX_ID_LEN,
30
+ MAX_LABEL_LEN: () => MAX_LABEL_LEN,
31
+ MAX_OPTIONS: () => MAX_OPTIONS,
32
+ MAX_PAYLOAD_CHARS: () => MAX_PAYLOAD_CHARS,
33
+ MAX_VALUE_LEN: () => MAX_VALUE_LEN,
34
+ WIRE_VERSION: () => WIRE_VERSION,
35
+ answers: () => answers,
36
+ buildInteraction: () => buildInteraction,
37
+ encodeForm: () => encodeForm,
38
+ encodeInteraction: () => encodeInteraction,
39
+ parseForm: () => parseForm,
40
+ parseInteraction: () => parseInteraction,
41
+ readMessage: () => readMessage
42
+ });
43
+ var WIRE_VERSION = 1;
44
+ var FORM_FENCE = "decent-form";
45
+ var INTERACTION_FENCE = "decent-interaction";
46
+ var MAX_COMPONENTS = 12;
47
+ var MAX_OPTIONS = 40;
48
+ var MAX_LABEL_LEN = 120;
49
+ var MAX_ID_LEN = 64;
50
+ var MAX_VALUE_LEN = 512;
51
+ var MAX_CUSTOM_ID_LEN = 128;
52
+ var MAX_PAYLOAD_CHARS = 64 * 1024;
53
+ var RENDERABLE = /* @__PURE__ */ new Set([
54
+ "select",
55
+ "submit",
56
+ "text",
57
+ "attachment"
58
+ ]);
59
+ var MAX_ACCEPT = 12;
60
+ var INPUT_TYPES = /* @__PURE__ */ new Set([
61
+ "select",
62
+ "text",
63
+ "attachment"
64
+ ]);
65
+ var str = (v, max) => {
66
+ if (typeof v !== "string")
67
+ return void 0;
68
+ const s = v.trim();
69
+ if (!s || s.length > max)
70
+ return void 0;
71
+ return s;
72
+ };
73
+ function parseForm(raw) {
74
+ var _a;
75
+ if (!raw || typeof raw !== "object")
76
+ return null;
77
+ const o = raw;
78
+ if (o.v !== WIRE_VERSION)
79
+ return null;
80
+ const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
81
+ if (!customId)
82
+ return null;
83
+ if (!Array.isArray(o.components))
84
+ return null;
85
+ const seen = /* @__PURE__ */ new Set();
86
+ const out = [];
87
+ for (const entry of o.components.slice(0, MAX_COMPONENTS)) {
88
+ if (!entry || typeof entry !== "object")
89
+ continue;
90
+ const c = entry;
91
+ const type = typeof c.type === "string" ? c.type : "";
92
+ if (!RENDERABLE.has(type))
93
+ continue;
94
+ const id = str(c.id, MAX_ID_LEN);
95
+ if (!id || seen.has(id))
96
+ continue;
97
+ seen.add(id);
98
+ const component = { type, id };
99
+ const label = str(c.label, MAX_LABEL_LEN);
100
+ if (label)
101
+ component.label = label;
102
+ if (c.required === true)
103
+ component.required = true;
104
+ const placeholder = str(c.placeholder, MAX_LABEL_LEN);
105
+ if (placeholder)
106
+ component.placeholder = placeholder;
107
+ if (type === "select") {
108
+ if (!Array.isArray(c.options))
109
+ continue;
110
+ const options = [];
111
+ const seenValues = /* @__PURE__ */ new Set();
112
+ for (const rawOpt of c.options.slice(0, MAX_OPTIONS)) {
113
+ if (!rawOpt || typeof rawOpt !== "object")
114
+ continue;
115
+ const opt = rawOpt;
116
+ const value = str(opt.value, MAX_VALUE_LEN);
117
+ if (!value || seenValues.has(value))
118
+ continue;
119
+ seenValues.add(value);
120
+ options.push({ label: (_a = str(opt.label, MAX_LABEL_LEN)) != null ? _a : value, value });
121
+ }
122
+ if (!options.length)
123
+ continue;
124
+ component.options = options;
125
+ const def = str(c.default, MAX_VALUE_LEN);
126
+ if (def && options.some((o2) => o2.value === def))
127
+ component.default = def;
128
+ }
129
+ if (type === "text") {
130
+ const def = str(c.default, MAX_VALUE_LEN);
131
+ if (def)
132
+ component.default = def;
133
+ }
134
+ if (type === "attachment") {
135
+ if (Array.isArray(c.accept)) {
136
+ const accept = c.accept.slice(0, MAX_ACCEPT).map((a) => str(a, MAX_LABEL_LEN)).filter((a) => a !== void 0);
137
+ if (accept.length)
138
+ component.accept = accept;
139
+ }
140
+ if (typeof c.max_bytes === "number" && Number.isFinite(c.max_bytes) && c.max_bytes > 0) {
141
+ component.max_bytes = Math.floor(c.max_bytes);
142
+ }
143
+ }
144
+ out.push(component);
145
+ }
146
+ if (!out.some((c) => c.type === "submit"))
147
+ return null;
148
+ if (!out.some((c) => INPUT_TYPES.has(c.type)))
149
+ return null;
150
+ return { custom_id: customId, components: out };
151
+ }
152
+ function parseInteraction(raw) {
153
+ if (!raw || typeof raw !== "object")
154
+ return null;
155
+ const o = raw;
156
+ if (o.v !== WIRE_VERSION)
157
+ return null;
158
+ const customId = str(o.custom_id, MAX_CUSTOM_ID_LEN);
159
+ const component = str(o.component, MAX_ID_LEN);
160
+ if (!customId || !component)
161
+ return null;
162
+ const values = {};
163
+ if (o.values && typeof o.values === "object") {
164
+ for (const [k, v] of Object.entries(o.values).slice(0, MAX_COMPONENTS)) {
165
+ const key = str(k, MAX_ID_LEN);
166
+ if (!key)
167
+ continue;
168
+ if (typeof v === "string") {
169
+ values[key] = v.slice(0, MAX_VALUE_LEN);
170
+ continue;
171
+ }
172
+ if (v && typeof v === "object") {
173
+ const f = v;
174
+ const fileId = str(f.fileId, 128);
175
+ const name = str(f.name, MAX_LABEL_LEN);
176
+ const size = typeof f.size === "number" && Number.isFinite(f.size) ? f.size : void 0;
177
+ if (fileId && name && size !== void 0)
178
+ values[key] = { fileId, name, size };
179
+ }
180
+ }
181
+ }
182
+ return { custom_id: customId, component, values };
183
+ }
184
+ function buildInteraction(customId, component, values) {
185
+ const clean = {};
186
+ for (const [k, v] of Object.entries(values)) {
187
+ const key = str(k, MAX_ID_LEN);
188
+ if (!key)
189
+ continue;
190
+ if (typeof v === "string") {
191
+ clean[key] = v.slice(0, MAX_VALUE_LEN);
192
+ continue;
193
+ }
194
+ const fileId = str(v == null ? void 0 : v.fileId, 128);
195
+ const name = str(v == null ? void 0 : v.name, MAX_LABEL_LEN);
196
+ if (fileId && name && typeof (v == null ? void 0 : v.size) === "number" && Number.isFinite(v.size)) {
197
+ clean[key] = { fileId, name, size: v.size };
198
+ }
199
+ }
200
+ return { custom_id: customId.slice(0, MAX_CUSTOM_ID_LEN), component: component.slice(0, MAX_ID_LEN), values: clean };
201
+ }
202
+ function fence(kind, payload) {
203
+ return `\`\`\`${kind}
204
+ ${JSON.stringify(payload)}
205
+ \`\`\``;
206
+ }
207
+ function encodeForm(text, form) {
208
+ if (!text.trim())
209
+ throw new Error("a form message needs human-readable text: it is the fallback");
210
+ return `${text.trim()}
211
+
212
+ ${fence(FORM_FENCE, { v: WIRE_VERSION, ...form })}`;
213
+ }
214
+ function encodeInteraction(interaction, summary) {
215
+ const head = (summary != null ? summary : defaultSummary(interaction)).trim();
216
+ return `${head}
217
+
218
+ ${fence(INTERACTION_FENCE, { v: WIRE_VERSION, ...interaction })}`;
219
+ }
220
+ function defaultSummary(interaction) {
221
+ const parts = Object.entries(interaction.values).map(([k, v]) => typeof v === "string" ? `${k}: ${v}` : `${k}: ${v.name}`);
222
+ return parts.length ? parts.join("\uFF0C") : "(\u5DF2\u63D0\u4EA4)";
223
+ }
224
+ function extract(text, kind) {
225
+ const open = `\`\`\`${kind}`;
226
+ const start = text.indexOf(open);
227
+ if (start < 0)
228
+ return null;
229
+ const bodyStart = text.indexOf("\n", start + open.length);
230
+ if (bodyStart < 0)
231
+ return null;
232
+ const end = text.indexOf("```", bodyStart);
233
+ if (end < 0)
234
+ return null;
235
+ const json = text.slice(bodyStart + 1, end);
236
+ if (json.length > MAX_PAYLOAD_CHARS)
237
+ return null;
238
+ const rest = (text.slice(0, start) + text.slice(end + 3)).trim();
239
+ return { json, rest };
240
+ }
241
+ function parseJson(json) {
242
+ try {
243
+ return JSON.parse(json);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+ function readMessage(text) {
249
+ const raw = typeof text === "string" ? text : "";
250
+ const formBlock = extract(raw, FORM_FENCE);
251
+ if (formBlock) {
252
+ const form = parseForm(parseJson(formBlock.json));
253
+ return { text: formBlock.rest, form, interaction: null };
254
+ }
255
+ const interactionBlock = extract(raw, INTERACTION_FENCE);
256
+ if (interactionBlock) {
257
+ return {
258
+ text: interactionBlock.rest,
259
+ form: null,
260
+ interaction: parseInteraction(parseJson(interactionBlock.json))
261
+ };
262
+ }
263
+ return { text: raw, form: null, interaction: null };
264
+ }
265
+ function answers(texts) {
266
+ const out = /* @__PURE__ */ new Map();
267
+ for (const t of texts) {
268
+ const it = readMessage(t).interaction;
269
+ if (it && !out.has(it.custom_id))
270
+ out.set(it.custom_id, it);
271
+ }
272
+ return out;
273
+ }
274
+ return __toCommonJS(dist_exports);
275
+ })();
@@ -209,6 +209,16 @@ export class EmbeddedHost {
209
209
  }
210
210
  }
211
211
  }
212
+ /** Newest inbound text message from a peer, used to anchor an arriving form. */
213
+ #latestInbound(peer) {
214
+ const thread = this.#messages.history(peer)[peer] ?? [];
215
+ for (let i = thread.length - 1; i >= 0; i--) {
216
+ const m = thread[i];
217
+ if (m.dir === "in" && !m.file)
218
+ return m;
219
+ }
220
+ return undefined;
221
+ }
212
222
  subscribe(emit) {
213
223
  const fn = (e) => emit(e);
214
224
  this.#events.on("event", fn);
package/dist/ipc.d.ts CHANGED
@@ -19,6 +19,13 @@ export interface IpcRequest {
19
19
  origin?: string;
20
20
  nonce?: string;
21
21
  signal?: unknown;
22
+ custom_id?: string;
23
+ component?: string;
24
+ values?: Record<string, string>;
25
+ size?: number;
26
+ dir?: "in" | "out";
27
+ peer?: string;
28
+ before?: number;
22
29
  [key: string]: unknown;
23
30
  }
24
31
  export interface IpcResponseOk {
package/dist/server.js CHANGED
@@ -274,12 +274,16 @@ export function startBeagleServer(opts) {
274
274
  const file = join(DESKTOP_DIR, rel);
275
275
  // Guard against path traversal: resolved file must stay under DESKTOP_DIR.
276
276
  if (existsSync(file) && file.startsWith(DESKTOP_DIR)) {
277
- // app.js AND vendor/peer-webrtc.js change on every rebuild / SDK bump
278
- // → never cache them, or the browser keeps running stale call/UI code
279
- // (this silently defeated every peer-webrtc fix). Only the truly stable
280
- // vendored libs (React UMD) may cache.
277
+ // Anything WE build changes on every rebuild / SDK bump → never cache
278
+ // it, or the browser keeps running stale code. This silently defeated
279
+ // every peer-webrtc fix once, and then caught vendor/chat-components
280
+ // the same way, because the rule was an opt-IN list of volatile files.
281
+ // Inverted: the allowlist now names the genuinely immutable
282
+ // third-party libs, so adding a new bundled module can't quietly opt
283
+ // into a year of caching.
284
+ const IMMUTABLE = ["react", "qrcode"];
281
285
  const headers = { "content-type": "application/javascript; charset=utf-8" };
282
- const volatile = rel === "app.js" || rel.includes("peer-webrtc");
286
+ const volatile = !IMMUTABLE.some((lib) => rel.includes(lib));
283
287
  headers["cache-control"] = volatile ? "no-store" : "public, max-age=31536000";
284
288
  res.writeHead(200, headers);
285
289
  res.end(readFileSync(file));
@@ -792,6 +796,8 @@ export function startBeagleServer(opts) {
792
796
  sendJson(res, 200, r.ok ? (r.data ?? { chats: {} }) : { chats: {} });
793
797
  return;
794
798
  }
799
+ // Brief 30: submit a rendered form. The values are the user's own input;
800
+ // the host validates and bounds them before they hit the wire.
795
801
  if (req.method === "POST" && url === "/api/chat-send") {
796
802
  const { userid, text } = await readBody(req);
797
803
  const r = await opts.call({ op: "chat-send", userid, text });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,6 +25,7 @@
25
25
  "start": "node dist/cli.js"
26
26
  },
27
27
  "dependencies": {
28
+ "@decentnetwork/chat-components": "^0.1.3",
28
29
  "@decentnetwork/lan": "^0.1.256",
29
30
  "@decentnetwork/peer": "^0.1.123",
30
31
  "@decentnetwork/peer-webrtc": "^0.2.10",