@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.
- package/dist/carrier-node.d.ts +4 -0
- package/dist/carrier-node.js +11 -0
- package/dist/chat-components.d.ts +67 -0
- package/dist/chat-components.js +208 -0
- package/dist/desktop/app.js +193 -4
- package/dist/desktop/index.html +3 -0
- package/dist/desktop/vendor/chat-components.js +275 -0
- package/dist/embedded-host.js +10 -0
- package/dist/ipc.d.ts +7 -0
- package/dist/server.js +11 -5
- package/package.json +2 -1
package/dist/carrier-node.d.ts
CHANGED
|
@@ -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
|
package/dist/carrier-node.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
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
|
|
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:
|
|
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),
|
package/dist/desktop/index.html
CHANGED
|
@@ -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
|
+
})();
|
package/dist/embedded-host.js
CHANGED
|
@@ -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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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 =
|
|
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.
|
|
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",
|