@statewalker/httpeers-join 0.1.3
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/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/invite-panel.d.ts +50 -0
- package/dist/invite-panel.d.ts.map +1 -0
- package/dist/invite-panel.js +357 -0
- package/dist/invite.d.ts +126 -0
- package/dist/invite.d.ts.map +1 -0
- package/dist/invite.js +192 -0
- package/dist/qr.d.ts +60 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +115 -0
- package/dist/styles.d.ts +19 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +163 -0
- package/dist/widget.d.ts +83 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +375 -0
- package/package.json +48 -0
- package/src/index.ts +56 -0
- package/src/invite-panel.ts +429 -0
- package/src/invite.ts +250 -0
- package/src/qr.ts +136 -0
- package/src/styles.ts +165 -0
- package/src/widget.ts +450 -0
package/dist/widget.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The join widget: one small piece of DOM that renders a `SessionState` and
|
|
3
|
+
* calls the session's four controls.
|
|
4
|
+
*
|
|
5
|
+
* IT HOLDS NO SESSION STATE OF ITS OWN. `PeerSession` already decides every
|
|
6
|
+
* thing worth deciding -- which phase, which message, which controls make
|
|
7
|
+
* sense (`SessionState.controls`) -- and the widget only shows it. The page
|
|
8
|
+
* owns the session and hands each new state to `update()`, because the session
|
|
9
|
+
* reports changes through one `onChange` callback that the page wires up when
|
|
10
|
+
* it creates it; the widget cannot subscribe on its own.
|
|
11
|
+
*
|
|
12
|
+
* What the widget DOES own is what exists only on screen: the text being
|
|
13
|
+
* typed, a camera that is running, a button that is waiting on its call, and
|
|
14
|
+
* the complaint left by a call that rejected.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING HAPPENS AT IMPORT. No `document`, no `window`, no stylesheet until
|
|
17
|
+
* `mountJoinWidget` runs -- `tests/import.test.ts` imports the built entry
|
|
18
|
+
* under plain Node to hold the package to that, because a page's bundle may
|
|
19
|
+
* import this module long before it has anything to mount.
|
|
20
|
+
*/
|
|
21
|
+
import { createInvitePanel } from "./invite-panel.js";
|
|
22
|
+
import { defaultQrScanner } from "./qr.js";
|
|
23
|
+
import { injectJoinWidgetStyles } from "./styles.js";
|
|
24
|
+
/** What "Leave this mesh" asks before it forgets the identity. */
|
|
25
|
+
export const LEAVE_CONFIRMATION = "Leave this mesh and forget this browser's identity?\n\n" +
|
|
26
|
+
"This page becomes a different peer: its current membership is left behind on the hub, and " +
|
|
27
|
+
"joining again needs a new invitation. The page reloads afterwards.";
|
|
28
|
+
/** `12D3KooW…a1b2c3`: enough to tell two peers apart on screen. The whole id goes in a tooltip. */
|
|
29
|
+
export function shortPeerId(peerId) {
|
|
30
|
+
return peerId.length <= 18 ? peerId : `${peerId.slice(0, 10)}…${peerId.slice(-6)}`;
|
|
31
|
+
}
|
|
32
|
+
/** The status line's text for a state, for a page that wants it without the widget. */
|
|
33
|
+
export function describePhase(state) {
|
|
34
|
+
const phase = state?.phase;
|
|
35
|
+
if (phase == null)
|
|
36
|
+
return "Starting…";
|
|
37
|
+
switch (phase.kind) {
|
|
38
|
+
case "checking":
|
|
39
|
+
return "Checking this browser's identity…";
|
|
40
|
+
case "starting":
|
|
41
|
+
return `Joining… (${phase.peerState})`;
|
|
42
|
+
case "live":
|
|
43
|
+
return state?.hubLink == null ? "Connected" : `Connected (${state.hubLink})`;
|
|
44
|
+
case "needs-invitation":
|
|
45
|
+
return "Not joined: an invitation is needed";
|
|
46
|
+
case "disconnected":
|
|
47
|
+
return "Disconnected";
|
|
48
|
+
case "blocked":
|
|
49
|
+
return "Blocked";
|
|
50
|
+
case "failed":
|
|
51
|
+
return "Could not join";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The phase message, and whether it is news or trouble. A first run asking for
|
|
56
|
+
* an invitation is instructions (`status`); a hub that no longer knows this
|
|
57
|
+
* peer, a refused invitation, a duplicate tab or a failure is trouble
|
|
58
|
+
* (`alert`), and an operator's screen reader should say so.
|
|
59
|
+
*/
|
|
60
|
+
function messageOf(state) {
|
|
61
|
+
const phase = state?.phase;
|
|
62
|
+
switch (phase?.kind) {
|
|
63
|
+
case "live":
|
|
64
|
+
return phase.note == null ? null : { text: phase.note, role: "status" };
|
|
65
|
+
case "needs-invitation":
|
|
66
|
+
return {
|
|
67
|
+
text: phase.message,
|
|
68
|
+
role: phase.reason === "no-identity" || phase.reason === "no-mesh" ? "status" : "alert",
|
|
69
|
+
};
|
|
70
|
+
case "disconnected":
|
|
71
|
+
return { text: phase.message, role: "status" };
|
|
72
|
+
case "blocked":
|
|
73
|
+
case "failed":
|
|
74
|
+
return { text: phase.message, role: "alert" };
|
|
75
|
+
default:
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let instances = 0;
|
|
80
|
+
/**
|
|
81
|
+
* Build the widget inside `container` and return its handle.
|
|
82
|
+
*
|
|
83
|
+
* Every id is suffixed per instance, so two widgets on one page -- a full one
|
|
84
|
+
* and a compact one in a header -- never share a label target or a camera
|
|
85
|
+
* host.
|
|
86
|
+
*/
|
|
87
|
+
export function mountJoinWidget(container, options) {
|
|
88
|
+
const doc = container.ownerDocument;
|
|
89
|
+
if (options.injectStyles !== false)
|
|
90
|
+
injectJoinWidgetStyles(doc);
|
|
91
|
+
const { session } = options;
|
|
92
|
+
const scanner = options.scanner === undefined ? defaultQrScanner() : options.scanner;
|
|
93
|
+
const confirmLeave = options.confirm ?? ((message) => doc.defaultView?.confirm(message) ?? false);
|
|
94
|
+
const compact = options.compact === true;
|
|
95
|
+
const n = ++instances;
|
|
96
|
+
const make = (tag, className, text) => {
|
|
97
|
+
const node = doc.createElement(tag);
|
|
98
|
+
node.className = className;
|
|
99
|
+
if (text != null)
|
|
100
|
+
node.textContent = text;
|
|
101
|
+
return node;
|
|
102
|
+
};
|
|
103
|
+
const button = (className, label) => {
|
|
104
|
+
const b = make("button", `hp-join-button ${className}`, label);
|
|
105
|
+
b.type = "button";
|
|
106
|
+
return b;
|
|
107
|
+
};
|
|
108
|
+
// --- structure ----------------------------------------------------------
|
|
109
|
+
const root = make("div", compact ? "hp-join hp-join-compact" : "hp-join");
|
|
110
|
+
const status = make("p", "hp-join-status");
|
|
111
|
+
status.setAttribute("role", "status");
|
|
112
|
+
const phaseText = make("span", "hp-join-phase");
|
|
113
|
+
const peer = make("code", "hp-join-peer");
|
|
114
|
+
// The compact line leaves the peer id to a tooltip: a header has no room for it.
|
|
115
|
+
if (compact)
|
|
116
|
+
status.append(phaseText);
|
|
117
|
+
else
|
|
118
|
+
status.append(phaseText, " ", peer);
|
|
119
|
+
const message = make("p", "hp-join-message");
|
|
120
|
+
const error = make("p", "hp-join-error");
|
|
121
|
+
error.setAttribute("role", "alert");
|
|
122
|
+
error.hidden = true;
|
|
123
|
+
const disconnectButton = button("hp-join-disconnect", "Disconnect");
|
|
124
|
+
const reconnectButton = button("hp-join-reconnect", "Reconnect");
|
|
125
|
+
const resetButton = button("hp-join-reset hp-join-danger", "Leave this mesh…");
|
|
126
|
+
resetButton.title = "Forget this browser's identity. Joining again needs a new invitation.";
|
|
127
|
+
const controls = make("div", "hp-join-controls");
|
|
128
|
+
controls.append(disconnectButton, reconnectButton, resetButton);
|
|
129
|
+
// The join form: only in the full widget.
|
|
130
|
+
const form = make("form", "hp-join-form");
|
|
131
|
+
const input = make("textarea", "hp-join-input");
|
|
132
|
+
input.id = `hp-join-input-${n}`;
|
|
133
|
+
input.rows = 3;
|
|
134
|
+
input.placeholder = "a join link, a join blob, or an invitation id";
|
|
135
|
+
input.autocomplete = "off";
|
|
136
|
+
input.spellcheck = false;
|
|
137
|
+
const label = make("label", "hp-join-label", "Paste an invitation");
|
|
138
|
+
label.htmlFor = input.id;
|
|
139
|
+
const submit = make("button", "hp-join-button hp-join-primary hp-join-submit", "Join");
|
|
140
|
+
submit.type = "submit";
|
|
141
|
+
const cameraButton = button("hp-join-camera", "Scan a QR code");
|
|
142
|
+
const fileLabel = make("label", "hp-join-button hp-join-file", "Scan a QR picture");
|
|
143
|
+
const fileInput = make("input", "hp-join-visually-hidden");
|
|
144
|
+
fileInput.type = "file";
|
|
145
|
+
fileInput.accept = "image/*";
|
|
146
|
+
fileLabel.append(fileInput);
|
|
147
|
+
const actions = make("div", "hp-join-actions");
|
|
148
|
+
actions.append(submit, cameraButton, fileLabel);
|
|
149
|
+
const viewfinder = make("div", "hp-join-viewfinder");
|
|
150
|
+
viewfinder.id = `hp-join-viewfinder-${n}`;
|
|
151
|
+
viewfinder.hidden = true;
|
|
152
|
+
const stopCameraButton = button("hp-join-camera-stop", "Stop camera");
|
|
153
|
+
stopCameraButton.hidden = true;
|
|
154
|
+
form.append(label, input, actions, viewfinder, stopCameraButton);
|
|
155
|
+
const invitePanel = options.invite === false ? null : createInvitePanel(doc, make, n, options.invite ?? {});
|
|
156
|
+
if (compact) {
|
|
157
|
+
const menu = make("details", "hp-join-menu");
|
|
158
|
+
const summary = make("summary", "hp-join-menu-toggle", "Mesh");
|
|
159
|
+
const panel = make("div", "hp-join-menu-panel");
|
|
160
|
+
panel.append(message, error, controls);
|
|
161
|
+
if (invitePanel != null)
|
|
162
|
+
panel.append(invitePanel.element);
|
|
163
|
+
menu.append(summary, panel);
|
|
164
|
+
root.append(status, menu);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
root.append(status, message, error, form, controls);
|
|
168
|
+
if (invitePanel != null)
|
|
169
|
+
root.append(invitePanel.element);
|
|
170
|
+
}
|
|
171
|
+
container.append(root);
|
|
172
|
+
// --- behaviour ----------------------------------------------------------
|
|
173
|
+
let current = null;
|
|
174
|
+
let camera = null;
|
|
175
|
+
/** Set while a camera is being STARTED, so a second click or a stop in between is not lost. */
|
|
176
|
+
let cameraStarting = false;
|
|
177
|
+
let destroyed = false;
|
|
178
|
+
/** The buttons whose call has not settled yet. */
|
|
179
|
+
const pending = new Set();
|
|
180
|
+
const showError = (text) => {
|
|
181
|
+
error.textContent = text ?? "";
|
|
182
|
+
error.hidden = text == null;
|
|
183
|
+
};
|
|
184
|
+
/** Run one session call from a button: disabled until it settles, and a rejection shown rather than lost. */
|
|
185
|
+
const run = async (from, call) => {
|
|
186
|
+
if (pending.has(from))
|
|
187
|
+
return;
|
|
188
|
+
pending.add(from);
|
|
189
|
+
from.disabled = true;
|
|
190
|
+
showError(null);
|
|
191
|
+
try {
|
|
192
|
+
await call();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
showError(err instanceof Error ? err.message : String(err));
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
pending.delete(from);
|
|
199
|
+
if (!destroyed)
|
|
200
|
+
render();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const stopCamera = async () => {
|
|
204
|
+
const running = camera;
|
|
205
|
+
camera = null;
|
|
206
|
+
viewfinder.hidden = true;
|
|
207
|
+
stopCameraButton.hidden = true;
|
|
208
|
+
viewfinder.replaceChildren();
|
|
209
|
+
await running?.stop().catch(() => { });
|
|
210
|
+
};
|
|
211
|
+
const joinWith = (text) => run(submit, async () => {
|
|
212
|
+
await session.join(text);
|
|
213
|
+
});
|
|
214
|
+
const onScanned = (invitation) => {
|
|
215
|
+
void stopCamera();
|
|
216
|
+
if (destroyed)
|
|
217
|
+
return;
|
|
218
|
+
input.value = invitation;
|
|
219
|
+
void joinWith(invitation);
|
|
220
|
+
};
|
|
221
|
+
const render = () => {
|
|
222
|
+
const state = current;
|
|
223
|
+
const phase = state?.phase;
|
|
224
|
+
root.dataset.phase = phase?.kind ?? "none";
|
|
225
|
+
phaseText.textContent = describePhase(state);
|
|
226
|
+
const id = state?.identity ?? null;
|
|
227
|
+
peer.hidden = id == null;
|
|
228
|
+
peer.textContent = id == null ? "" : `peer ${shortPeerId(id)}`;
|
|
229
|
+
for (const node of compact ? [status] : [peer]) {
|
|
230
|
+
if (id == null)
|
|
231
|
+
node.removeAttribute("title");
|
|
232
|
+
else
|
|
233
|
+
node.title = `This page's peer id: ${id}`;
|
|
234
|
+
}
|
|
235
|
+
const said = messageOf(state);
|
|
236
|
+
message.hidden = said == null;
|
|
237
|
+
message.textContent = said?.text ?? "";
|
|
238
|
+
if (said == null)
|
|
239
|
+
message.removeAttribute("role");
|
|
240
|
+
else
|
|
241
|
+
message.setAttribute("role", said.role);
|
|
242
|
+
const allowed = state?.controls ?? {
|
|
243
|
+
join: false,
|
|
244
|
+
disconnect: false,
|
|
245
|
+
reconnect: false,
|
|
246
|
+
reset: false,
|
|
247
|
+
};
|
|
248
|
+
disconnectButton.hidden = !allowed.disconnect;
|
|
249
|
+
reconnectButton.hidden = !allowed.reconnect;
|
|
250
|
+
// The session offers a reset in every settled phase, but with no identity
|
|
251
|
+
// yet -- a first run -- there is nothing to forget, and "Leave this mesh"
|
|
252
|
+
// on a page that never joined one would only confuse.
|
|
253
|
+
const reset = allowed.reset && id != null;
|
|
254
|
+
resetButton.hidden = !reset;
|
|
255
|
+
controls.hidden = !(allowed.disconnect || allowed.reconnect || reset);
|
|
256
|
+
for (const b of [disconnectButton, reconnectButton, resetButton])
|
|
257
|
+
b.disabled = pending.has(b);
|
|
258
|
+
form.hidden = compact || !allowed.join;
|
|
259
|
+
submit.disabled = pending.has(submit) || input.value.trim() === "";
|
|
260
|
+
cameraButton.hidden = scanner == null || !scanner.cameraAvailable() || camera != null;
|
|
261
|
+
fileLabel.hidden = scanner == null;
|
|
262
|
+
// Invite: only while live, and only toward that link's hub.
|
|
263
|
+
invitePanel?.setTarget(phase?.kind === "live" ? (state?.handle ?? null) : null);
|
|
264
|
+
// A camera left running behind a form that has gone -- a join started,
|
|
265
|
+
// or succeeded -- is a light on the operator's laptop for no reason.
|
|
266
|
+
if (form.hidden && camera != null)
|
|
267
|
+
void stopCamera();
|
|
268
|
+
};
|
|
269
|
+
form.addEventListener("submit", (event) => {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const text = input.value.trim();
|
|
272
|
+
if (text !== "")
|
|
273
|
+
void joinWith(text);
|
|
274
|
+
});
|
|
275
|
+
input.addEventListener("input", () => {
|
|
276
|
+
submit.disabled = pending.has(submit) || input.value.trim() === "";
|
|
277
|
+
});
|
|
278
|
+
disconnectButton.addEventListener("click", () => {
|
|
279
|
+
void run(disconnectButton, () => session.disconnect());
|
|
280
|
+
});
|
|
281
|
+
reconnectButton.addEventListener("click", () => {
|
|
282
|
+
void run(reconnectButton, () => session.reconnect());
|
|
283
|
+
});
|
|
284
|
+
resetButton.addEventListener("click", () => {
|
|
285
|
+
void run(resetButton, async () => {
|
|
286
|
+
if (!(await confirmLeave(LEAVE_CONFIRMATION)))
|
|
287
|
+
return;
|
|
288
|
+
await stopCamera();
|
|
289
|
+
await session.resetIdentity();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
cameraButton.addEventListener("click", () => {
|
|
293
|
+
if (scanner == null || camera != null || cameraStarting)
|
|
294
|
+
return;
|
|
295
|
+
cameraStarting = true;
|
|
296
|
+
showError(null);
|
|
297
|
+
viewfinder.hidden = false;
|
|
298
|
+
stopCameraButton.hidden = false;
|
|
299
|
+
cameraButton.hidden = true;
|
|
300
|
+
scanner.scanCamera(viewfinder, onScanned).then((started) => {
|
|
301
|
+
cameraStarting = false;
|
|
302
|
+
// Stopped, destroyed or already scanned while the camera was starting.
|
|
303
|
+
if (destroyed || viewfinder.hidden) {
|
|
304
|
+
void started.stop().catch(() => { });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
camera = started;
|
|
308
|
+
render();
|
|
309
|
+
}, (err) => {
|
|
310
|
+
cameraStarting = false;
|
|
311
|
+
viewfinder.hidden = true;
|
|
312
|
+
stopCameraButton.hidden = true;
|
|
313
|
+
if (destroyed)
|
|
314
|
+
return;
|
|
315
|
+
showError(`Could not start the camera (${err instanceof Error ? err.message : String(err)}). ` +
|
|
316
|
+
"Choose a picture of the QR code instead, or paste the invitation.");
|
|
317
|
+
render();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
stopCameraButton.addEventListener("click", () => {
|
|
321
|
+
void stopCamera().then(() => {
|
|
322
|
+
if (!destroyed)
|
|
323
|
+
render();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
fileInput.addEventListener("change", () => {
|
|
327
|
+
const file = fileInput.files?.[0];
|
|
328
|
+
// Reset, so choosing the SAME picture again fires `change` a second time.
|
|
329
|
+
fileInput.value = "";
|
|
330
|
+
if (file == null || scanner == null)
|
|
331
|
+
return;
|
|
332
|
+
showError(null);
|
|
333
|
+
void scanner.scanFile(file).then((found) => {
|
|
334
|
+
if (destroyed)
|
|
335
|
+
return;
|
|
336
|
+
if (found.ok) {
|
|
337
|
+
input.value = found.invitation;
|
|
338
|
+
void joinWith(found.invitation);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
showError(found.reason === "no-qr"
|
|
342
|
+
? "No QR code found in that picture. Try a sharper or closer one."
|
|
343
|
+
: "That QR code is not an invitation.");
|
|
344
|
+
}
|
|
345
|
+
}, (err) => {
|
|
346
|
+
if (!destroyed)
|
|
347
|
+
showError(`Could not read that picture: ${String(err)}`);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
const update = (state) => {
|
|
351
|
+
if (destroyed)
|
|
352
|
+
return;
|
|
353
|
+
// A join that went through leaves nothing to rejoin with: clear the field
|
|
354
|
+
// so a later failure does not offer the spent invitation back.
|
|
355
|
+
if (state?.phase.kind === "live" && current?.phase.kind !== "live")
|
|
356
|
+
input.value = "";
|
|
357
|
+
if (state?.phase.kind === "live")
|
|
358
|
+
showError(null);
|
|
359
|
+
current = state;
|
|
360
|
+
render();
|
|
361
|
+
};
|
|
362
|
+
update(options.state ?? null);
|
|
363
|
+
return {
|
|
364
|
+
element: root,
|
|
365
|
+
update,
|
|
366
|
+
destroy() {
|
|
367
|
+
if (destroyed)
|
|
368
|
+
return;
|
|
369
|
+
destroyed = true;
|
|
370
|
+
void stopCamera();
|
|
371
|
+
invitePanel?.destroy();
|
|
372
|
+
root.remove();
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/httpeers-join",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The join-the-mesh widget: paste or scan an invitation, see the link to the hub, disconnect, reconnect or leave. Plain DOM, no framework.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+ssh://git@github.com/statewalker/httpeers.git",
|
|
10
|
+
"directory": "packages/httpeers-join"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"html5-qrcode": "^2.3.8",
|
|
28
|
+
"@statewalker/httpeers-member": "^0.1.4",
|
|
29
|
+
"@statewalker/httpeers-qr": "^0.1.2"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.5.15",
|
|
33
|
+
"@types/node": "^26.6.4",
|
|
34
|
+
"happy-dom": "^20.14.5",
|
|
35
|
+
"typescript": "^7.0.2",
|
|
36
|
+
"vitest": "^5.0.3"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"test": "pnpm run build && pnpm run typecheck && vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"lint": "biome check src tests",
|
|
46
|
+
"lint:check": "biome check src tests"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The join-the-mesh widget: paste or scan an invitation, see the link to the
|
|
3
|
+
* hub, disconnect, reconnect or leave -- and, for a mesh admin, invite
|
|
4
|
+
* others as members or admins.
|
|
5
|
+
*
|
|
6
|
+
* Plain DOM and no framework. A page mounts it, feeds it every `SessionState`
|
|
7
|
+
* its `PeerSession` reports, and destroys it when done:
|
|
8
|
+
*
|
|
9
|
+
* const widget = mountJoinWidget(el, { session, state: session.state() });
|
|
10
|
+
* // in the session's onChange:
|
|
11
|
+
* widget.update(state);
|
|
12
|
+
*
|
|
13
|
+
* See the README for the design and for how React pages wrap it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
ADMIN_INVITATION_WARNING,
|
|
18
|
+
adminHint,
|
|
19
|
+
type CreatedInvitation,
|
|
20
|
+
createHubAdminClient,
|
|
21
|
+
DEFAULT_INVITE_EXPIRY,
|
|
22
|
+
DEFAULT_INVITE_ROLE,
|
|
23
|
+
describeExpiry,
|
|
24
|
+
expiryMs,
|
|
25
|
+
type HubAdminClient,
|
|
26
|
+
HubAdminError,
|
|
27
|
+
type HubTarget,
|
|
28
|
+
hubAdminBase,
|
|
29
|
+
INVITE_EXPIRIES,
|
|
30
|
+
INVITE_ROLES,
|
|
31
|
+
type InviteExpiryId,
|
|
32
|
+
type InviteRole,
|
|
33
|
+
invitationRequestBody,
|
|
34
|
+
isFinalRefusal,
|
|
35
|
+
type MeshViewHint,
|
|
36
|
+
offeredRoles,
|
|
37
|
+
type PendingInvitation,
|
|
38
|
+
} from "./invite.js";
|
|
39
|
+
export { createInvitePanel, type InvitePanel, type InvitePanelOptions } from "./invite-panel.js";
|
|
40
|
+
export {
|
|
41
|
+
defaultQrScanner,
|
|
42
|
+
type QrCameraScan,
|
|
43
|
+
type QrFileScan,
|
|
44
|
+
type QrScanner,
|
|
45
|
+
} from "./qr.js";
|
|
46
|
+
export { injectJoinWidgetStyles, JOIN_WIDGET_CSS, JOIN_WIDGET_STYLE_ID } from "./styles.js";
|
|
47
|
+
export {
|
|
48
|
+
describePhase,
|
|
49
|
+
type JoinWidget,
|
|
50
|
+
type JoinWidgetOptions,
|
|
51
|
+
type JoinWidgetSession,
|
|
52
|
+
type JoinWidgetState,
|
|
53
|
+
LEAVE_CONFIRMATION,
|
|
54
|
+
mountJoinWidget,
|
|
55
|
+
shortPeerId,
|
|
56
|
+
} from "./widget.js";
|