@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/src/widget.ts ADDED
@@ -0,0 +1,450 @@
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
+
22
+ import type { PeerSession, SessionState } from "@statewalker/httpeers-member";
23
+ import type { HubTarget } from "./invite.js";
24
+ import { createInvitePanel, type InvitePanel, type InvitePanelOptions } from "./invite-panel.js";
25
+ import { defaultQrScanner, type QrScanner } from "./qr.js";
26
+ import { injectJoinWidgetStyles } from "./styles.js";
27
+
28
+ /** The four calls the widget makes. `PeerSession` satisfies it; a test passes four spies. */
29
+ export type JoinWidgetSession = Pick<
30
+ PeerSession,
31
+ "join" | "disconnect" | "reconnect" | "resetIdentity"
32
+ >;
33
+
34
+ /**
35
+ * The part of `SessionState` the widget reads. A full `SessionState` is one;
36
+ * so is a hand-built test state with no handle. The handle is read only for
37
+ * the Invite panel: its hub peer id and edge base URL.
38
+ */
39
+ export type JoinWidgetState = Pick<SessionState, "phase" | "identity" | "hubLink" | "controls"> & {
40
+ handle?: HubTarget | null;
41
+ };
42
+
43
+ export interface JoinWidgetOptions {
44
+ session: JoinWidgetSession;
45
+ /** The state to show at once. Usually `session.state()`; omitted, the widget says it is starting until `update()`. */
46
+ state?: JoinWidgetState | null;
47
+ /**
48
+ * A status line and a small menu with Disconnect and Leave, and no join
49
+ * form -- for a header that stays on screen while the page does its real
50
+ * work. The full widget is the default.
51
+ */
52
+ compact?: boolean;
53
+ /**
54
+ * How QR codes are read. Defaults to `defaultQrScanner()` (html5-qrcode,
55
+ * loaded on the first scan). `null` hides both QR buttons.
56
+ */
57
+ scanner?: QrScanner | null;
58
+ /** Asked before "Leave this mesh". Defaults to `window.confirm`. May be async, for a page with its own dialog. */
59
+ confirm?: (message: string) => boolean | Promise<boolean>;
60
+ /** `false` leaves styling to the page; the class names are the API then. Default `true`. */
61
+ injectStyles?: boolean;
62
+ /**
63
+ * The Invite panel, shown to a mesh admin while live (see `./invite-panel.ts`).
64
+ * On by default: it asks the hub once per link and stays hidden for anyone
65
+ * the hub refuses. `false` leaves it out altogether; an object configures it.
66
+ */
67
+ invite?: InvitePanelOptions | false;
68
+ }
69
+
70
+ export interface JoinWidget {
71
+ /** The widget's root element, already inside the container. */
72
+ readonly element: HTMLElement;
73
+ /** Show a new state. Call it from the session's `onChange`, and on any timer the page already re-reads the state on. */
74
+ update(state: JoinWidgetState | null): void;
75
+ /** Stop the camera if it runs and remove the widget. The stylesheet stays: another instance may be using it. */
76
+ destroy(): void;
77
+ }
78
+
79
+ /** What "Leave this mesh" asks before it forgets the identity. */
80
+ export const LEAVE_CONFIRMATION =
81
+ "Leave this mesh and forget this browser's identity?\n\n" +
82
+ "This page becomes a different peer: its current membership is left behind on the hub, and " +
83
+ "joining again needs a new invitation. The page reloads afterwards.";
84
+
85
+ /** `12D3KooW…a1b2c3`: enough to tell two peers apart on screen. The whole id goes in a tooltip. */
86
+ export function shortPeerId(peerId: string): string {
87
+ return peerId.length <= 18 ? peerId : `${peerId.slice(0, 10)}…${peerId.slice(-6)}`;
88
+ }
89
+
90
+ /** The status line's text for a state, for a page that wants it without the widget. */
91
+ export function describePhase(state: JoinWidgetState | null): string {
92
+ const phase = state?.phase;
93
+ if (phase == null) return "Starting…";
94
+ switch (phase.kind) {
95
+ case "checking":
96
+ return "Checking this browser's identity…";
97
+ case "starting":
98
+ return `Joining… (${phase.peerState})`;
99
+ case "live":
100
+ return state?.hubLink == null ? "Connected" : `Connected (${state.hubLink})`;
101
+ case "needs-invitation":
102
+ return "Not joined: an invitation is needed";
103
+ case "disconnected":
104
+ return "Disconnected";
105
+ case "blocked":
106
+ return "Blocked";
107
+ case "failed":
108
+ return "Could not join";
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The phase message, and whether it is news or trouble. A first run asking for
114
+ * an invitation is instructions (`status`); a hub that no longer knows this
115
+ * peer, a refused invitation, a duplicate tab or a failure is trouble
116
+ * (`alert`), and an operator's screen reader should say so.
117
+ */
118
+ function messageOf(
119
+ state: JoinWidgetState | null,
120
+ ): { text: string; role: "status" | "alert" } | null {
121
+ const phase = state?.phase;
122
+ switch (phase?.kind) {
123
+ case "live":
124
+ return phase.note == null ? null : { text: phase.note, role: "status" };
125
+ case "needs-invitation":
126
+ return {
127
+ text: phase.message,
128
+ role: phase.reason === "no-identity" || phase.reason === "no-mesh" ? "status" : "alert",
129
+ };
130
+ case "disconnected":
131
+ return { text: phase.message, role: "status" };
132
+ case "blocked":
133
+ case "failed":
134
+ return { text: phase.message, role: "alert" };
135
+ default:
136
+ return null;
137
+ }
138
+ }
139
+
140
+ let instances = 0;
141
+
142
+ /**
143
+ * Build the widget inside `container` and return its handle.
144
+ *
145
+ * Every id is suffixed per instance, so two widgets on one page -- a full one
146
+ * and a compact one in a header -- never share a label target or a camera
147
+ * host.
148
+ */
149
+ export function mountJoinWidget(container: HTMLElement, options: JoinWidgetOptions): JoinWidget {
150
+ const doc = container.ownerDocument;
151
+ if (options.injectStyles !== false) injectJoinWidgetStyles(doc);
152
+ const { session } = options;
153
+ const scanner = options.scanner === undefined ? defaultQrScanner() : options.scanner;
154
+ const confirmLeave =
155
+ options.confirm ?? ((message: string) => doc.defaultView?.confirm(message) ?? false);
156
+ const compact = options.compact === true;
157
+ const n = ++instances;
158
+
159
+ const make = <K extends keyof HTMLElementTagNameMap>(
160
+ tag: K,
161
+ className: string,
162
+ text?: string,
163
+ ): HTMLElementTagNameMap[K] => {
164
+ const node = doc.createElement(tag);
165
+ node.className = className;
166
+ if (text != null) node.textContent = text;
167
+ return node;
168
+ };
169
+ const button = (className: string, label: string): HTMLButtonElement => {
170
+ const b = make("button", `hp-join-button ${className}`, label);
171
+ b.type = "button";
172
+ return b;
173
+ };
174
+
175
+ // --- structure ----------------------------------------------------------
176
+ const root = make("div", compact ? "hp-join hp-join-compact" : "hp-join");
177
+
178
+ const status = make("p", "hp-join-status");
179
+ status.setAttribute("role", "status");
180
+ const phaseText = make("span", "hp-join-phase");
181
+ const peer = make("code", "hp-join-peer");
182
+ // The compact line leaves the peer id to a tooltip: a header has no room for it.
183
+ if (compact) status.append(phaseText);
184
+ else status.append(phaseText, " ", peer);
185
+
186
+ const message = make("p", "hp-join-message");
187
+ const error = make("p", "hp-join-error");
188
+ error.setAttribute("role", "alert");
189
+ error.hidden = true;
190
+
191
+ const disconnectButton = button("hp-join-disconnect", "Disconnect");
192
+ const reconnectButton = button("hp-join-reconnect", "Reconnect");
193
+ const resetButton = button("hp-join-reset hp-join-danger", "Leave this mesh…");
194
+ resetButton.title = "Forget this browser's identity. Joining again needs a new invitation.";
195
+ const controls = make("div", "hp-join-controls");
196
+ controls.append(disconnectButton, reconnectButton, resetButton);
197
+
198
+ // The join form: only in the full widget.
199
+ const form = make("form", "hp-join-form");
200
+ const input = make("textarea", "hp-join-input");
201
+ input.id = `hp-join-input-${n}`;
202
+ input.rows = 3;
203
+ input.placeholder = "a join link, a join blob, or an invitation id";
204
+ input.autocomplete = "off";
205
+ input.spellcheck = false;
206
+ const label = make("label", "hp-join-label", "Paste an invitation");
207
+ label.htmlFor = input.id;
208
+ const submit = make("button", "hp-join-button hp-join-primary hp-join-submit", "Join");
209
+ submit.type = "submit";
210
+ const cameraButton = button("hp-join-camera", "Scan a QR code");
211
+ const fileLabel = make("label", "hp-join-button hp-join-file", "Scan a QR picture");
212
+ const fileInput = make("input", "hp-join-visually-hidden");
213
+ fileInput.type = "file";
214
+ fileInput.accept = "image/*";
215
+ fileLabel.append(fileInput);
216
+ const actions = make("div", "hp-join-actions");
217
+ actions.append(submit, cameraButton, fileLabel);
218
+ const viewfinder = make("div", "hp-join-viewfinder");
219
+ viewfinder.id = `hp-join-viewfinder-${n}`;
220
+ viewfinder.hidden = true;
221
+ const stopCameraButton = button("hp-join-camera-stop", "Stop camera");
222
+ stopCameraButton.hidden = true;
223
+ form.append(label, input, actions, viewfinder, stopCameraButton);
224
+
225
+ const invitePanel: InvitePanel | null =
226
+ options.invite === false ? null : createInvitePanel(doc, make, n, options.invite ?? {});
227
+
228
+ if (compact) {
229
+ const menu = make("details", "hp-join-menu");
230
+ const summary = make("summary", "hp-join-menu-toggle", "Mesh");
231
+ const panel = make("div", "hp-join-menu-panel");
232
+ panel.append(message, error, controls);
233
+ if (invitePanel != null) panel.append(invitePanel.element);
234
+ menu.append(summary, panel);
235
+ root.append(status, menu);
236
+ } else {
237
+ root.append(status, message, error, form, controls);
238
+ if (invitePanel != null) root.append(invitePanel.element);
239
+ }
240
+ container.append(root);
241
+
242
+ // --- behaviour ----------------------------------------------------------
243
+ let current: JoinWidgetState | null = null;
244
+ let camera: { stop(): Promise<void> } | null = null;
245
+ /** Set while a camera is being STARTED, so a second click or a stop in between is not lost. */
246
+ let cameraStarting = false;
247
+ let destroyed = false;
248
+ /** The buttons whose call has not settled yet. */
249
+ const pending = new Set<HTMLButtonElement>();
250
+
251
+ const showError = (text: string | null): void => {
252
+ error.textContent = text ?? "";
253
+ error.hidden = text == null;
254
+ };
255
+
256
+ /** Run one session call from a button: disabled until it settles, and a rejection shown rather than lost. */
257
+ const run = async (from: HTMLButtonElement, call: () => Promise<unknown>): Promise<void> => {
258
+ if (pending.has(from)) return;
259
+ pending.add(from);
260
+ from.disabled = true;
261
+ showError(null);
262
+ try {
263
+ await call();
264
+ } catch (err) {
265
+ showError(err instanceof Error ? err.message : String(err));
266
+ } finally {
267
+ pending.delete(from);
268
+ if (!destroyed) render();
269
+ }
270
+ };
271
+
272
+ const stopCamera = async (): Promise<void> => {
273
+ const running = camera;
274
+ camera = null;
275
+ viewfinder.hidden = true;
276
+ stopCameraButton.hidden = true;
277
+ viewfinder.replaceChildren();
278
+ await running?.stop().catch(() => {});
279
+ };
280
+
281
+ const joinWith = (text: string): Promise<void> =>
282
+ run(submit, async () => {
283
+ await session.join(text);
284
+ });
285
+
286
+ const onScanned = (invitation: string): void => {
287
+ void stopCamera();
288
+ if (destroyed) return;
289
+ input.value = invitation;
290
+ void joinWith(invitation);
291
+ };
292
+
293
+ const render = (): void => {
294
+ const state = current;
295
+ const phase = state?.phase;
296
+ root.dataset.phase = phase?.kind ?? "none";
297
+
298
+ phaseText.textContent = describePhase(state);
299
+ const id = state?.identity ?? null;
300
+ peer.hidden = id == null;
301
+ peer.textContent = id == null ? "" : `peer ${shortPeerId(id)}`;
302
+ for (const node of compact ? [status] : [peer]) {
303
+ if (id == null) node.removeAttribute("title");
304
+ else node.title = `This page's peer id: ${id}`;
305
+ }
306
+
307
+ const said = messageOf(state);
308
+ message.hidden = said == null;
309
+ message.textContent = said?.text ?? "";
310
+ if (said == null) message.removeAttribute("role");
311
+ else message.setAttribute("role", said.role);
312
+
313
+ const allowed = state?.controls ?? {
314
+ join: false,
315
+ disconnect: false,
316
+ reconnect: false,
317
+ reset: false,
318
+ };
319
+ disconnectButton.hidden = !allowed.disconnect;
320
+ reconnectButton.hidden = !allowed.reconnect;
321
+ // The session offers a reset in every settled phase, but with no identity
322
+ // yet -- a first run -- there is nothing to forget, and "Leave this mesh"
323
+ // on a page that never joined one would only confuse.
324
+ const reset = allowed.reset && id != null;
325
+ resetButton.hidden = !reset;
326
+ controls.hidden = !(allowed.disconnect || allowed.reconnect || reset);
327
+ for (const b of [disconnectButton, reconnectButton, resetButton]) b.disabled = pending.has(b);
328
+
329
+ form.hidden = compact || !allowed.join;
330
+ submit.disabled = pending.has(submit) || input.value.trim() === "";
331
+ cameraButton.hidden = scanner == null || !scanner.cameraAvailable() || camera != null;
332
+ fileLabel.hidden = scanner == null;
333
+
334
+ // Invite: only while live, and only toward that link's hub.
335
+ invitePanel?.setTarget(phase?.kind === "live" ? (state?.handle ?? null) : null);
336
+
337
+ // A camera left running behind a form that has gone -- a join started,
338
+ // or succeeded -- is a light on the operator's laptop for no reason.
339
+ if (form.hidden && camera != null) void stopCamera();
340
+ };
341
+
342
+ form.addEventListener("submit", (event) => {
343
+ event.preventDefault();
344
+ const text = input.value.trim();
345
+ if (text !== "") void joinWith(text);
346
+ });
347
+ input.addEventListener("input", () => {
348
+ submit.disabled = pending.has(submit) || input.value.trim() === "";
349
+ });
350
+ disconnectButton.addEventListener("click", () => {
351
+ void run(disconnectButton, () => session.disconnect());
352
+ });
353
+ reconnectButton.addEventListener("click", () => {
354
+ void run(reconnectButton, () => session.reconnect());
355
+ });
356
+ resetButton.addEventListener("click", () => {
357
+ void run(resetButton, async () => {
358
+ if (!(await confirmLeave(LEAVE_CONFIRMATION))) return;
359
+ await stopCamera();
360
+ await session.resetIdentity();
361
+ });
362
+ });
363
+
364
+ cameraButton.addEventListener("click", () => {
365
+ if (scanner == null || camera != null || cameraStarting) return;
366
+ cameraStarting = true;
367
+ showError(null);
368
+ viewfinder.hidden = false;
369
+ stopCameraButton.hidden = false;
370
+ cameraButton.hidden = true;
371
+ scanner.scanCamera(viewfinder, onScanned).then(
372
+ (started) => {
373
+ cameraStarting = false;
374
+ // Stopped, destroyed or already scanned while the camera was starting.
375
+ if (destroyed || viewfinder.hidden) {
376
+ void started.stop().catch(() => {});
377
+ return;
378
+ }
379
+ camera = started;
380
+ render();
381
+ },
382
+ (err: unknown) => {
383
+ cameraStarting = false;
384
+ viewfinder.hidden = true;
385
+ stopCameraButton.hidden = true;
386
+ if (destroyed) return;
387
+ showError(
388
+ `Could not start the camera (${err instanceof Error ? err.message : String(err)}). ` +
389
+ "Choose a picture of the QR code instead, or paste the invitation.",
390
+ );
391
+ render();
392
+ },
393
+ );
394
+ });
395
+ stopCameraButton.addEventListener("click", () => {
396
+ void stopCamera().then(() => {
397
+ if (!destroyed) render();
398
+ });
399
+ });
400
+
401
+ fileInput.addEventListener("change", () => {
402
+ const file = fileInput.files?.[0];
403
+ // Reset, so choosing the SAME picture again fires `change` a second time.
404
+ fileInput.value = "";
405
+ if (file == null || scanner == null) return;
406
+ showError(null);
407
+ void scanner.scanFile(file).then(
408
+ (found) => {
409
+ if (destroyed) return;
410
+ if (found.ok) {
411
+ input.value = found.invitation;
412
+ void joinWith(found.invitation);
413
+ } else {
414
+ showError(
415
+ found.reason === "no-qr"
416
+ ? "No QR code found in that picture. Try a sharper or closer one."
417
+ : "That QR code is not an invitation.",
418
+ );
419
+ }
420
+ },
421
+ (err: unknown) => {
422
+ if (!destroyed) showError(`Could not read that picture: ${String(err)}`);
423
+ },
424
+ );
425
+ });
426
+
427
+ const update = (state: JoinWidgetState | null): void => {
428
+ if (destroyed) return;
429
+ // A join that went through leaves nothing to rejoin with: clear the field
430
+ // so a later failure does not offer the spent invitation back.
431
+ if (state?.phase.kind === "live" && current?.phase.kind !== "live") input.value = "";
432
+ if (state?.phase.kind === "live") showError(null);
433
+ current = state;
434
+ render();
435
+ };
436
+
437
+ update(options.state ?? null);
438
+
439
+ return {
440
+ element: root,
441
+ update,
442
+ destroy() {
443
+ if (destroyed) return;
444
+ destroyed = true;
445
+ void stopCamera();
446
+ invitePanel?.destroy();
447
+ root.remove();
448
+ },
449
+ };
450
+ }