@panphora/clayjs 1.9.1 → 1.10.0

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.
@@ -25,11 +25,11 @@ const editing = () => (window.clay && "isEditMode" in window.clay ? !!window.cla
25
25
  import { bevelDialog, dismissOf, keepFocusIn } from "../ui/bevel-dialog.js";
26
26
  import { bevelBox, bevelButton, bevelInput, bevelText } from "../ui/bevel-controls.js";
27
27
  import { resolveTheme } from "../ui/theme.js";
28
+ import { colorIndexOf, initialsOf } from "../lib/person-face.js";
28
29
 
29
30
  const ID = /^[A-Za-z0-9_-]{8,64}$/;
30
31
  const STORE = "clay:people:me";
31
32
  const BOOT_WAIT_MS = 2000;
32
- const COLORS = 8;
33
33
  const MAX_NAME = 120;
34
34
  const EMAIL = /\S+@\S+\.\S+/;
35
35
 
@@ -50,19 +50,8 @@ function clean(person) {
50
50
  return { id, name };
51
51
  }
52
52
 
53
- function initialsOf(name) {
54
- const letters = name.split(" ").filter(Boolean).slice(0, 2).map((word) => Array.from(word)[0]);
55
- return letters.join("").toUpperCase() || "?";
56
- }
57
-
58
- function colorOf(id) {
59
- let hash = 0;
60
- for (const ch of String(id)) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
61
- return hash % COLORS;
62
- }
63
-
64
53
  function record({ id, name }) {
65
- return Object.freeze({ id, name, initials: initialsOf(name), color: colorOf(id) });
54
+ return Object.freeze({ id, name, initials: initialsOf(name), color: colorIndexOf(id) });
66
55
  }
67
56
 
68
57
  function emit() {
@@ -156,7 +145,7 @@ function get(id) {
156
145
  if (live) return record(live);
157
146
  const mine = readLocal();
158
147
  if (mine?.id === key) return record(mine);
159
- return saved().get(key) || Object.freeze({ id: key, name: "Unknown person", initials: "?", color: colorOf(key) });
148
+ return saved().get(key) || Object.freeze({ id: key, name: "Unknown person", initials: "?", color: colorIndexOf(key) });
160
149
  }
161
150
 
162
151
  function list() {
@@ -29,7 +29,9 @@ const RUNTIME_ATTRS = ['draggable', 'style'];
29
29
  const pressed = new Set();
30
30
 
31
31
  function recordRuntimeAttrs(item) {
32
- return [item, ...item.querySelectorAll('*')].map(el => [el, RUNTIME_ATTRS.map(name => el.getAttribute(name))]);
32
+ return [item, ...item.querySelectorAll('*')]
33
+ .filter(el => !el.closest(EDITOR_UI_SELECTOR))
34
+ .map(el => [el, RUNTIME_ATTRS.map(name => el.getAttribute(name))]);
33
35
  }
34
36
 
35
37
  function restoreRuntimeAttrs(recorded) {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * clay-router — URL-hash params as tab-local attributes.
3
+ *
4
+ * #entry=e-7f3a&view=week
5
+ * → <html route-entry="e-7f3a" route-view="week">
6
+ * → route-active="entry" on the child of [route="entry"] whose data-id is e-7f3a
7
+ *
8
+ * show-when:route-view="week" and CSS on [route-active] do the rest. ClayJS core
9
+ * treats route-* on the root and route-active anywhere as tab-local (root-attrs.js),
10
+ * so none of this is saved, marked dirty, undone, or sent to a peer.
11
+ */
12
+
13
+ const NAME = /^[a-z][a-z0-9-]*$/;
14
+ const PREFIX = "route-";
15
+ const ACTIVE = "route-active";
16
+ const SPEC = /^\s*([a-z][a-z0-9-]*)(?:\s+by\s+(\S+))?\s*$/;
17
+
18
+ let current = {};
19
+ let lastUnmatched = "";
20
+ let started = false;
21
+ let observer = null;
22
+ let queued = false;
23
+
24
+ /** Parse a hash into params. A hash with no "=" (a plain #anchor) carries none. */
25
+ export function parseHash(hash) {
26
+ const params = Object.create(null);
27
+ const raw = String(hash || "").replace(/^#/, "");
28
+ if (!raw.includes("=")) return params;
29
+ for (const [name, value] of new URLSearchParams(raw)) {
30
+ if (!NAME.test(name) || name === "active") {
31
+ console.warn(`clay-router: ignoring hash param "${name}"; names are lowercase letters, digits and hyphens, and "active" is reserved`);
32
+ continue;
33
+ }
34
+ if (value !== "") params[name] = value;
35
+ }
36
+ return params;
37
+ }
38
+
39
+ export function formatHash(params) {
40
+ const sp = new URLSearchParams();
41
+ for (const [name, value] of Object.entries(params)) {
42
+ if (value != null && value !== "") sp.set(name, String(value));
43
+ }
44
+ const s = sp.toString();
45
+ return s ? "#" + s : "";
46
+ }
47
+
48
+ function mirrorRoot(params) {
49
+ const root = document.documentElement;
50
+ for (const attr of [...root.attributes]) {
51
+ if (attr.name.startsWith(PREFIX) && !(attr.name.slice(PREFIX.length) in params)) root.removeAttribute(attr.name);
52
+ }
53
+ for (const [name, value] of Object.entries(params)) {
54
+ if (root.getAttribute(PREFIX + name) !== value) root.setAttribute(PREFIX + name, value);
55
+ }
56
+ }
57
+
58
+ // Stamp the matching child of every [route] container; return the declared
59
+ // params that have a value but matched nothing.
60
+ function stamp(params) {
61
+ const keep = new Set();
62
+ const declared = new Set();
63
+ const matched = new Set();
64
+ for (const container of document.querySelectorAll("[route]")) {
65
+ const m = SPEC.exec(container.getAttribute("route") || "");
66
+ if (!m) continue;
67
+ const [, name, by = "data-id"] = m;
68
+ declared.add(name);
69
+ const value = params[name];
70
+ if (value == null) continue;
71
+ for (const child of container.children) {
72
+ if (child.getAttribute(by) !== value) continue;
73
+ keep.add(child);
74
+ matched.add(name);
75
+ if (child.getAttribute(ACTIVE) !== name) child.setAttribute(ACTIVE, name);
76
+ }
77
+ }
78
+ for (const el of document.querySelectorAll(`[${ACTIVE}]`)) {
79
+ if (!keep.has(el)) el.removeAttribute(ACTIVE);
80
+ }
81
+ return [...declared].filter((name) => params[name] != null && !matched.has(name)).sort();
82
+ }
83
+
84
+ /** Re-read the hash and re-apply it. Writes only what differs; fires clay:route only on a change. */
85
+ export function apply() {
86
+ const params = parseHash(location.hash);
87
+ mirrorRoot(params);
88
+ const unmatched = document.body ? stamp(params) : [];
89
+ const previous = current;
90
+ const changed = [...new Set([...Object.keys(previous), ...Object.keys(params)])]
91
+ .filter((name) => previous[name] !== params[name]);
92
+ current = params;
93
+ const unmatchedKey = unmatched.join(" ");
94
+ if (!changed.length && unmatchedKey === lastUnmatched) return;
95
+ lastUnmatched = unmatchedKey;
96
+ document.dispatchEvent(new CustomEvent("clay:route", {
97
+ detail: { params: { ...params }, previous: { ...previous }, changed, unmatched },
98
+ }));
99
+ }
100
+
101
+ export function get() {
102
+ return { ...current };
103
+ }
104
+
105
+ /**
106
+ * Merge `patch` into the hash. null or "" removes a param. A push makes a history
107
+ * entry, so Back undoes it; { replace: true } does not.
108
+ */
109
+ export function set(patch, { replace = false } = {}) {
110
+ const next = parseHash(location.hash);
111
+ for (const [name, value] of Object.entries(patch || {})) {
112
+ if (value == null || value === "") delete next[name];
113
+ else next[name] = String(value);
114
+ }
115
+ const hash = formatHash(next);
116
+ if (hash === (location.hash.includes("=") ? location.hash : "")) return;
117
+ const bare = location.pathname + location.search;
118
+ if (replace) {
119
+ history.replaceState(history.state, "", bare + hash);
120
+ apply();
121
+ } else if (hash) {
122
+ location.hash = hash;
123
+ } else {
124
+ history.pushState(history.state, "", bare);
125
+ apply();
126
+ }
127
+ }
128
+
129
+ function queueApply() {
130
+ if (queued) return;
131
+ queued = true;
132
+ queueMicrotask(() => {
133
+ queued = false;
134
+ apply();
135
+ });
136
+ }
137
+
138
+ // A record added to or removed from a [route] container changes what matches,
139
+ // and so does a [route] container arriving whole.
140
+ function onChildList(records) {
141
+ for (const r of records) {
142
+ if (r.type !== "childList") continue;
143
+ if (r.target.nodeType === 1 && r.target.hasAttribute("route")) { queueApply(); return; }
144
+ for (const n of r.addedNodes) {
145
+ if (n.nodeType === 1 && (n.hasAttribute("route") || n.querySelector("[route]"))) { queueApply(); return; }
146
+ }
147
+ }
148
+ }
149
+
150
+ function onDomReady() {
151
+ apply();
152
+ if (!observer && typeof MutationObserver === "function") {
153
+ observer = new MutationObserver(onChildList);
154
+ observer.observe(document.body, { childList: true, subtree: true });
155
+ }
156
+ }
157
+
158
+ export function start() {
159
+ if (started) return;
160
+ started = true;
161
+ mirrorRoot(parseHash(location.hash));
162
+ window.addEventListener("hashchange", apply);
163
+ document.addEventListener("clay:sync-applied", queueApply);
164
+ document.addEventListener("clay:ready", queueApply);
165
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", onDomReady, { once: true });
166
+ else onDomReady();
167
+ }
168
+
169
+ /** Tear down listeners and forget state. For tests. */
170
+ export function stop() {
171
+ started = false;
172
+ current = {};
173
+ lastUnmatched = "";
174
+ window.removeEventListener("hashchange", apply);
175
+ document.removeEventListener("clay:sync-applied", queueApply);
176
+ document.removeEventListener("clay:ready", queueApply);
177
+ document.removeEventListener("DOMContentLoaded", onDomReady);
178
+ if (observer) observer.disconnect();
179
+ observer = null;
180
+ }
181
+
182
+ const route = { get, set, apply };
183
+
184
+ const clay = (window.clay = window.clay || {});
185
+ clay.route = route;
186
+ start();
187
+
188
+ export default route;
package/src/standalone.js CHANGED
@@ -78,6 +78,7 @@ import onDomReady from "./lib/dom-ready.js";
78
78
  }));
79
79
  satellite("ui", domReady().then(function () { return import("./ui/index.js"); }));
80
80
  satellite("options", domReady().then(function () { return import("./options/options.js"); }));
81
+ satellite("router", import("./route/route.js").then(function (m) { return m.default; }));
81
82
  satellite("events", domReady()
82
83
  .then(function () { return clay.loaded.dom && clay.loaded.dom.catch(function () {}); })
83
84
  .then(function () { return import("./events/index.js"); }));
@@ -1115,8 +1115,7 @@ export function applyRevert(plan) {
1115
1115
  // The page's change feed drops the writes and their undoing together: nothing
1116
1116
  // changed, so the dirty gate, autosave and the hub's other pausable subscribers
1117
1117
  // have nothing to see.
1118
- Mutation.pause();
1119
- Mutation.resume();
1118
+ Mutation.dropQueued();
1120
1119
  undo?.discardCaptured?.();
1121
1120
  undo?.resume?.();
1122
1121
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * editing-marker.js — the block somebody else is editing.
3
+ *
4
+ * A thin outline in that person's colour around the block the host names for them,
5
+ * with a chip carrying their name. Three rules, and they are the whole file:
6
+ *
7
+ * drawn for others only a `you: true` entry is this reader's own caret, and a
8
+ * host that does not announce `presence` draws nothing
9
+ * runtime only every element carries RUNTIME_ONLY and the name lives
10
+ * in one text node, never in an attribute
11
+ * never touched the edited element is measured and nothing else
12
+ */
13
+
14
+ import { hostSupports } from '../core/host-meta.js';
15
+ import { make, set } from '../lib/hostile-css.js';
16
+ import { personColor } from '../lib/person-face.js';
17
+ import { RUNTIME_ONLY } from '../ui/bevel-controls.js';
18
+ import { FONT_MONO } from '../ui/bevel.js';
19
+ import { EDITING_PATH, elementAt } from './editing-path.js';
20
+
21
+ const FACE = '#FFFCF6';
22
+ const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
23
+ const CHIP_HEIGHT = 18;
24
+ const GAP = 3;
25
+ const CHIP_FONT = `600 11px/16px ${FONT_MONO}`;
26
+
27
+ function runtimeRoot(el) {
28
+ el.setAttribute('clay', RUNTIME_ONLY);
29
+ return el;
30
+ }
31
+
32
+ function editorsOf(frame) {
33
+ const list = frame && Array.isArray(frame.people) ? frame.people : [];
34
+ const editors = [];
35
+ for (const person of list) {
36
+ if (!person || person.you === true || typeof person.id !== 'string' || !person.id) continue;
37
+ if (typeof person.editing !== 'string' || !EDITING_PATH.test(person.editing)) continue;
38
+ const name = typeof person.name === 'string' && person.name.trim() ? person.name.trim() : 'Someone';
39
+ editors.push({ id: person.id, name, path: person.editing });
40
+ }
41
+ return editors;
42
+ }
43
+
44
+ class EditingMarker {
45
+ constructor() {
46
+ this.editors = [];
47
+ this.root = null;
48
+ this._gate = null;
49
+ this._frame = 0;
50
+ this._following = false;
51
+ this._onMove = () => this.schedule();
52
+ }
53
+
54
+ supported() {
55
+ if (!this._gate) this._gate = hostSupports('presence').then((ok) => ok === true, () => false);
56
+ return this._gate;
57
+ }
58
+
59
+ async update(frame) {
60
+ this.editors = editorsOf(frame);
61
+ if (!(await this.supported())) return;
62
+ this.render();
63
+ }
64
+
65
+ clear() {
66
+ this.editors = [];
67
+ this.follow(false);
68
+ if (this._frame) cancelAnimationFrame(this._frame);
69
+ this._frame = 0;
70
+ if (this.root) this.root.remove();
71
+ this.root = null;
72
+ }
73
+
74
+ schedule() {
75
+ if (this._frame) return;
76
+ this._frame = requestAnimationFrame(() => {
77
+ this._frame = 0;
78
+ this.render();
79
+ });
80
+ }
81
+
82
+ render() {
83
+ const groups = new Map();
84
+ for (const editor of this.editors) {
85
+ const element = elementAt(editor.path);
86
+ if (!element) continue;
87
+ if (!groups.has(element)) groups.set(element, []);
88
+ groups.get(element).push(editor);
89
+ }
90
+ const boxes = [];
91
+ for (const [element, editors] of groups) {
92
+ const box = this.box(element, editors);
93
+ if (box) boxes.push(box);
94
+ }
95
+ if (!boxes.length) {
96
+ if (this.root) {
97
+ this.root.textContent = '';
98
+ set(this.root, 'display', 'none');
99
+ }
100
+ this.follow(this.editors.length > 0);
101
+ return;
102
+ }
103
+ if (!this.build()) return;
104
+ this.root.textContent = '';
105
+ this.root.append(...boxes);
106
+ set(this.root, 'display', 'block');
107
+ this.follow(true);
108
+ }
109
+
110
+ box(element, editors) {
111
+ const r = element.getBoundingClientRect();
112
+ if (!(r.width || r.height)) return null;
113
+ const color = personColor(editors[0].id);
114
+ const box = runtimeRoot(make('div', [
115
+ ...RESET,
116
+ 'position:fixed', 'box-sizing:border-box', 'pointer-events:none',
117
+ `top:${Math.round(r.top - GAP)}px`, `left:${Math.round(r.left - GAP)}px`,
118
+ `width:${Math.round(r.width + GAP * 2)}px`, `height:${Math.round(r.height + GAP * 2)}px`,
119
+ `border:2px solid ${color}`,
120
+ `box-shadow:0 0 0 1px ${FACE}, inset 0 0 0 1px ${FACE}`,
121
+ ]));
122
+ box.setAttribute('data-clay-editing-marker', '');
123
+ const above = r.top - GAP >= CHIP_HEIGHT + 2;
124
+ const chips = runtimeRoot(make('div', [
125
+ ...RESET,
126
+ 'position:absolute', 'left:-2px', above ? 'bottom:100%' : 'top:100%',
127
+ 'display:flex', 'gap:2px', 'pointer-events:none',
128
+ ]));
129
+ for (const editor of editors) {
130
+ const chip = runtimeRoot(make('span', [
131
+ ...RESET,
132
+ 'box-sizing:border-box', `height:${CHIP_HEIGHT}px`, 'padding:1px 6px',
133
+ `font:${CHIP_FONT}`, 'letter-spacing:.02em', 'white-space:nowrap',
134
+ 'max-width:180px', 'overflow:hidden', 'text-overflow:ellipsis',
135
+ `background-color:${personColor(editor.id)}`, `color:${FACE}`,
136
+ `box-shadow:0 0 0 1px ${FACE}`,
137
+ ], editor.name));
138
+ chip.setAttribute('data-clay-editing-chip', '');
139
+ chips.appendChild(chip);
140
+ }
141
+ box.appendChild(chips);
142
+ return box;
143
+ }
144
+
145
+ build() {
146
+ if (this.root && this.root.isConnected) return true;
147
+ if (typeof document === 'undefined' || !document.body) return false;
148
+ this.root = runtimeRoot(make('div', [
149
+ ...RESET,
150
+ 'position:fixed', 'top:0', 'left:0', 'width:0', 'height:0', 'overflow:visible',
151
+ 'z-index:2147482999', 'pointer-events:none', 'display:none',
152
+ ]));
153
+ this.root.setAttribute('data-clay-editing', '');
154
+ document.body.appendChild(this.root);
155
+ return true;
156
+ }
157
+
158
+ follow(on) {
159
+ if (typeof window === 'undefined' || !!on === this._following) return;
160
+ this._following = !!on;
161
+ const method = on ? 'addEventListener' : 'removeEventListener';
162
+ window[method]('scroll', this._onMove, { capture: true, passive: true });
163
+ window[method]('resize', this._onMove, { passive: true });
164
+ document[method]('input', this._onMove);
165
+ document[method]('clay:sync-applied', this._onMove);
166
+ }
167
+ }
168
+
169
+ const editingMarker = new EditingMarker();
170
+
171
+ export { editingMarker, EditingMarker };
172
+ export default editingMarker;
@@ -0,0 +1,46 @@
1
+ import { SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
2
+ import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
3
+
4
+ export const EDITING_PATH = /^\d{1,4}(?:\.\d{1,4}){0,63}$/;
5
+ const MAX_DEPTH = 64;
6
+ const MAX_INDEX = 9999;
7
+ const CONTROLS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
8
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
9
+ const LOCAL = `${SNAPSHOT_REMOVE_SELECTOR}, ${EXTENSION_NODE_SELECTOR}`;
10
+
11
+ const sharedChildren = (parent) => Array.from(parent.children).filter((child) => !child.matches(LOCAL));
12
+
13
+ export function blockOf(target) {
14
+ if (!target || target.nodeType !== 1 || typeof target.closest !== 'function') return null;
15
+ if (CONTROLS.has(target.tagName)) return target;
16
+ const host = target.closest(EDITABLE);
17
+ if (!host) return null;
18
+ const selection = typeof document.getSelection === 'function' ? document.getSelection() : null;
19
+ let node = selection && selection.rangeCount ? selection.focusNode : null;
20
+ if (node && node.nodeType !== 1) node = node.parentElement;
21
+ if (!node || node === host || !host.contains(node)) return host;
22
+ while (node.parentElement && node.parentElement !== host) node = node.parentElement;
23
+ return node.parentElement === host ? node : host;
24
+ }
25
+
26
+ export function pathOf(element) {
27
+ const body = document.body;
28
+ if (!element || !body || element === body || !body.contains(element) || element.closest(LOCAL)) return null;
29
+ const indices = [];
30
+ for (let node = element; node !== body; node = node.parentElement) {
31
+ const index = sharedChildren(node.parentElement).indexOf(node);
32
+ if (index < 0 || index > MAX_INDEX || indices.length === MAX_DEPTH) return null;
33
+ indices.unshift(index);
34
+ }
35
+ return indices.join('.');
36
+ }
37
+
38
+ export function elementAt(path) {
39
+ if (typeof path !== 'string' || !EDITING_PATH.test(path) || !document.body) return null;
40
+ let node = document.body;
41
+ for (const part of path.split('.')) {
42
+ node = sharedChildren(node)[Number(part)];
43
+ if (!node) return null;
44
+ }
45
+ return node;
46
+ }
@@ -0,0 +1,100 @@
1
+ import { blockOf, pathOf } from './editing-path.js';
2
+
3
+ const INTERVAL_MS = 250;
4
+ const RETRY_MS = 2000;
5
+ const EVENTS = ['focusin', 'focusout', 'selectionchange', 'clay:sync-applied', 'clay:workspace-background', 'clay:workspace-foreground'];
6
+
7
+ export class EditingSender {
8
+ constructor({ send, now = () => Date.now() }) {
9
+ this.send = send;
10
+ this.now = now;
11
+ this.others = false;
12
+ this.background = false;
13
+ this.sent = null;
14
+ this.lastAt = -Infinity;
15
+ this.timer = null;
16
+ this.poked = null;
17
+ this.inflight = false;
18
+ this.retry = null;
19
+ this.stopped = false;
20
+ this._onEvent = (event) => {
21
+ if (event.type === 'clay:workspace-background') this.background = true;
22
+ if (event.type === 'clay:workspace-foreground') this.background = false;
23
+ this.poke();
24
+ };
25
+ }
26
+
27
+ start() {
28
+ this.stopped = false;
29
+ for (const type of EVENTS) document.addEventListener(type, this._onEvent);
30
+ }
31
+
32
+ stop() {
33
+ this.stopped = true;
34
+ for (const type of EVENTS) document.removeEventListener(type, this._onEvent);
35
+ clearTimeout(this.timer);
36
+ clearTimeout(this.poked);
37
+ clearTimeout(this.retry);
38
+ this.timer = null;
39
+ this.poked = null;
40
+ this.retry = null;
41
+ }
42
+
43
+ roster(frame) {
44
+ const people = frame && Array.isArray(frame.people) ? frame.people : [];
45
+ const others = people.some((person) => person && person.you !== true);
46
+ if (others === this.others) return;
47
+ this.others = others;
48
+ this.schedule();
49
+ }
50
+
51
+ reconnected() {
52
+ this.sent = null;
53
+ this.schedule();
54
+ }
55
+
56
+ wanted() {
57
+ if (!this.others || this.background) return null;
58
+ return pathOf(blockOf(document.activeElement));
59
+ }
60
+
61
+ poke() {
62
+ if (this.poked !== null) return;
63
+ this.poked = setTimeout(() => {
64
+ this.poked = null;
65
+ this.schedule();
66
+ }, 0);
67
+ }
68
+
69
+ schedule() {
70
+ if (this.stopped || this.timer !== null || this.inflight || this.wanted() === this.sent) return;
71
+ const wait = Math.max(0, this.lastAt + INTERVAL_MS - this.now());
72
+ this.timer = setTimeout(() => {
73
+ this.timer = null;
74
+ this.flush();
75
+ }, wait);
76
+ }
77
+
78
+ flush() {
79
+ if (this.inflight) return;
80
+ const value = this.wanted();
81
+ if (value === this.sent) return;
82
+ this.sent = value;
83
+ this.lastAt = this.now();
84
+ this.inflight = true;
85
+ const settle = (ok) => {
86
+ this.inflight = false;
87
+ if (this.stopped) return;
88
+ if (ok) {
89
+ clearTimeout(this.retry);
90
+ this.retry = null;
91
+ } else if (this.sent === value) {
92
+ this.sent = undefined;
93
+ if (this.retry === null) this.retry = setTimeout(() => { this.retry = null; this.schedule(); }, RETRY_MS);
94
+ return;
95
+ }
96
+ this.schedule();
97
+ };
98
+ Promise.resolve(this.send(value)).then((ok) => settle(Boolean(ok)), () => settle(false));
99
+ }
100
+ }