@polycode-projects/the-mechanical-code-talker 6.0.20 → 6.0.22
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/README.md +5 -15
- package/bin/tmct.mjs +8 -3
- package/package.json +1 -5
- package/src/adapters/memory/core.mjs +108 -96
- package/src/adapters/memory/corpus-bands.mjs +6 -0
- package/src/domain/agent-traits.mjs +1 -1
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/cli-verbs.mjs +1 -0
- package/src/domain/memory/causal-stability.mjs +22 -5
- package/src/domain/memory/fact-order.mjs +3 -3
- package/src/domain/memory/provenance-time.mjs +35 -0
- package/src/domain/memory/retraction.mjs +3 -5
- package/src/domain/memory/trust.mjs +11 -11
- package/src/domain/news-feed.mjs +1 -1
- package/src/domain/seeded-random.mjs +6 -6
- package/src/services/adventure.mjs +10 -41
- package/src/services/chat-page-viz.mjs +12 -1111
- package/src/services/chat-session.mjs +20 -6
- package/src/services/chat.mjs +269 -197
- package/src/services/extract-facts.mjs +1 -1
- package/src/services/import-file.mjs +1 -1
- package/src/services/mud-viz.mjs +21 -1082
- package/src/services/mudiii-viz.mjs +0 -4
- package/src/services/news.mjs +44 -24
- package/src/services/pill-complete.mjs +5 -8
- package/src/services/predator-prey.mjs +3 -5
- package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
- package/src/surfaces/web/mud-browser-entry.mjs +8 -48
- package/test-benchmarks/agentbench/README.md +7 -10
- package/src/adapters/p2p/webrtc-transport.mjs +0 -169
- package/src/domain/p2p/facts.mjs +0 -102
- package/src/domain/p2p/peer-id.mjs +0 -47
- package/src/domain/p2p/provenance-relabel.mjs +0 -37
- package/src/domain/p2p/sync-filter.mjs +0 -43
- package/src/domain/p2p/wire.mjs +0 -126
- package/src/services/p2p-room.mjs +0 -848
- package/src/services/share-overlay-viz.mjs +0 -623
- package/src/surfaces/web/p2p-browser-entry.mjs +0 -39
|
@@ -29,10 +29,6 @@
|
|
|
29
29
|
// to public/chat.html, after chat-browser.bundle.js/chat-seed.json already
|
|
30
30
|
// exist (both built earlier in that same script, for the embedded widget).
|
|
31
31
|
import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
|
|
32
|
-
import {
|
|
33
|
-
SHARE_OVERLAY_CSS, shareOverlayHtml, shareStepStates, activeWaves, offerBlobIn, peerTerm,
|
|
34
|
-
shareMessageFor, replyMessageFor, whatsAppShareUrl, isProbablyMobile, copyTextToClipboard, flashCopyTip,
|
|
35
|
-
} from "./share-overlay-viz.mjs";
|
|
36
32
|
import { provBucketFor } from "./ledger-viz.mjs";
|
|
37
33
|
import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
|
|
38
34
|
import { sessionLogTimeOfDay, sessionLogHeaderMarkdown, sessionLogTurnMarkdown } from "./session-log-format.mjs";
|
|
@@ -106,266 +102,6 @@ export function provenanceChipFor(answer, record, bucketFor) {
|
|
|
106
102
|
return "corpus";
|
|
107
103
|
}
|
|
108
104
|
|
|
109
|
-
/**
|
|
110
|
-
* One connection state as something a person can read: the headline word, the
|
|
111
|
-
* sentence under it, and the tone the page styles it by.
|
|
112
|
-
*
|
|
113
|
-
* The tones matter as much as the words. `sharing` and `answering` are
|
|
114
|
-
* open-ended BY DESIGN — until a blob is pasted nothing is in flight, so there
|
|
115
|
-
* is no network activity to time out on, and they get the calm "waiting" tone
|
|
116
|
-
* rather than error styling. `failed` is the opposite case: both blobs were
|
|
117
|
-
* exchanged and ICE gave up, which is a real fault and reads as one.
|
|
118
|
-
*
|
|
119
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
120
|
-
*/
|
|
121
|
-
export function wireStateLabel(state) {
|
|
122
|
-
switch (state) {
|
|
123
|
-
case "sharing":
|
|
124
|
-
return {
|
|
125
|
-
tone: "waiting",
|
|
126
|
-
pill: "waiting",
|
|
127
|
-
word: "waiting for their reply",
|
|
128
|
-
note: "nothing is in flight yet. this stays live as long as you leave the tab open.",
|
|
129
|
-
};
|
|
130
|
-
case "answering":
|
|
131
|
-
return {
|
|
132
|
-
tone: "waiting",
|
|
133
|
-
pill: "reply sent",
|
|
134
|
-
word: "send your reply back",
|
|
135
|
-
note: "they connect the moment they paste it. leave this tab open.",
|
|
136
|
-
};
|
|
137
|
-
case "connecting":
|
|
138
|
-
return {
|
|
139
|
-
tone: "working",
|
|
140
|
-
pill: "connecting",
|
|
141
|
-
word: "connecting",
|
|
142
|
-
note: "both halves are exchanged. this settles either way in a few seconds.",
|
|
143
|
-
};
|
|
144
|
-
case "connected":
|
|
145
|
-
return {
|
|
146
|
-
tone: "live",
|
|
147
|
-
pill: "connected",
|
|
148
|
-
word: "connected",
|
|
149
|
-
note: "what you teach from here reaches every node below, and theirs reaches you.",
|
|
150
|
-
};
|
|
151
|
-
case "failed":
|
|
152
|
-
return {
|
|
153
|
-
tone: "failed",
|
|
154
|
-
pill: "can't connect",
|
|
155
|
-
word: "couldn't connect",
|
|
156
|
-
note: "your two machines couldn't find a path to each other. a public STUN server helps with most networks, but some firewalls or strict NATs still block it.",
|
|
157
|
-
};
|
|
158
|
-
default:
|
|
159
|
-
return {
|
|
160
|
-
tone: "idle",
|
|
161
|
-
pill: "not shared",
|
|
162
|
-
word: "not shared",
|
|
163
|
-
note: "this browser holds the only copy of what you teach it.",
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* One wire message as a row for the traffic tape: its type, which colour
|
|
170
|
-
* family it belongs to, and the one number or name worth showing beside it.
|
|
171
|
-
*
|
|
172
|
-
* The families reuse the page's own provenance colours rather than inventing a
|
|
173
|
-
* fourth palette — facts crossing the wire wear the same green as the "taught"
|
|
174
|
-
* chip they will end up carrying, bulk state wears the corpus blue, and the
|
|
175
|
-
* introductions that get two peers talking wear the entailed amber.
|
|
176
|
-
*
|
|
177
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
178
|
-
*/
|
|
179
|
-
export function tapeRowFor(direction, message) {
|
|
180
|
-
const type = message && typeof message.type === "string" ? message.type : "unknown";
|
|
181
|
-
const FAMILIES = {
|
|
182
|
-
op: "facts",
|
|
183
|
-
"sync-response": "facts",
|
|
184
|
-
"sync-request": "state",
|
|
185
|
-
hello: "greeting",
|
|
186
|
-
"peer-list": "greeting",
|
|
187
|
-
"intro-offer": "signal",
|
|
188
|
-
"intro-answer": "signal",
|
|
189
|
-
};
|
|
190
|
-
const count = (list, one, many) => {
|
|
191
|
-
const n = Array.isArray(list) ? list.length : 0;
|
|
192
|
-
return n + " " + (n === 1 ? one : many);
|
|
193
|
-
};
|
|
194
|
-
let detail = "";
|
|
195
|
-
if (type === "op" || type === "sync-response") detail = count(message.facts, "fact", "facts");
|
|
196
|
-
else if (type === "peer-list") detail = count(message.peers, "node", "nodes");
|
|
197
|
-
else if (type === "hello") detail = String(message.displayName || "");
|
|
198
|
-
else if (type === "intro-offer" || type === "intro-answer") detail = String(message.to || "").slice(0, 8);
|
|
199
|
-
return { type: type, direction: direction, detail: detail, family: FAMILIES[type] || "link" };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* The wire tape's own clock stamp for a message as it arrives —
|
|
204
|
-
* `HH:MM:SS.mmm`, local time, zero-padded.
|
|
205
|
-
*
|
|
206
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
207
|
-
*/
|
|
208
|
-
export function tapeClock() {
|
|
209
|
-
const at = new Date();
|
|
210
|
-
const pad = (n, width) => String(n).padStart(width, "0");
|
|
211
|
-
return pad(at.getHours(), 2) + ":" + pad(at.getMinutes(), 2) + ":" + pad(at.getSeconds(), 2) + "." + pad(at.getMilliseconds(), 3);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* The node list: every peer this graph knows about, each with the node name it
|
|
216
|
-
* chose, the timestamp of the most recent fact it contributed, and that fact
|
|
217
|
-
* itself (`lastFact` — subject/predicate/object, null before any lands), most
|
|
218
|
-
* recently active first.
|
|
219
|
-
*
|
|
220
|
-
* Activity is read off the provenance the wire already carries. A fact a peer
|
|
221
|
-
* broadcast arrives tagged `teach:peer:<their node name>#node:<their id>@<ts>`,
|
|
222
|
-
* so the tag names both who contributed it and when — no separate activity
|
|
223
|
-
* ledger to keep. The `#node:` segment is identity and never reaches the
|
|
224
|
-
* screen; a tag from a peer old enough not to carry one is just a name. This
|
|
225
|
-
* node's own row reads its local `teach:`/`ace:` tags instead, because a fact
|
|
226
|
-
* never leaves here relabelled in its own store.
|
|
227
|
-
*
|
|
228
|
-
* `nameFor` and `latestTimestampOf` are injected (the room's own
|
|
229
|
-
* `displayNameFor` and the P2P layer's `latestProvenanceTimestamp`) rather
|
|
230
|
-
* than imported, so this stays `.toString()`-splice safe — the same discipline
|
|
231
|
-
* provenanceChipFor's injected `bucketFor` holds.
|
|
232
|
-
*/
|
|
233
|
-
export function nodeRowsFor({ peers, factRows, myPeerId, myDisplayName, nameFor, latestTimestampOf }) {
|
|
234
|
-
const activeByName = new Map();
|
|
235
|
-
const factByName = new Map();
|
|
236
|
-
const factAtByName = new Map();
|
|
237
|
-
let mineLastActive = null;
|
|
238
|
-
let mineLastFact = null;
|
|
239
|
-
let mineLastFactAt = null;
|
|
240
|
-
// The P2P layer's own bookkeeping rows (names, claims, waves, who invited
|
|
241
|
-
// whom) key on raw node ids, and the roster's rule is that no id ever
|
|
242
|
-
// reaches the screen — they still count as activity, they just never show
|
|
243
|
-
// as "last shared". The fact keeps its own newest-timestamp race, apart
|
|
244
|
-
// from activity, so a fresh wave can never mask an older real fact.
|
|
245
|
-
const BOOKKEEPING = ["mgx:worldName", "mgx:nodeName", "mgx:playedBy", "mgx:waved", "mgx:invitedBy"];
|
|
246
|
-
const factOf = (row) => (BOOKKEEPING.indexOf(row.predicate) >= 0
|
|
247
|
-
? null
|
|
248
|
-
: { subject: row.subject, predicate: row.predicate, object: row.object });
|
|
249
|
-
for (const row of factRows || []) {
|
|
250
|
-
for (const segment of String(row.provenance || "").split(" | ")) {
|
|
251
|
-
if (!segment) continue;
|
|
252
|
-
const at = latestTimestampOf(segment);
|
|
253
|
-
if (at === null) continue;
|
|
254
|
-
const fact = factOf(row);
|
|
255
|
-
if (segment.indexOf("teach:peer:") === 0) {
|
|
256
|
-
const marker = segment.lastIndexOf("@");
|
|
257
|
-
if (marker < 0) continue;
|
|
258
|
-
const label = segment.slice("teach:peer:".length, marker);
|
|
259
|
-
// Everything from the `#` on is the origin's stable node id, which
|
|
260
|
-
// keys the fact but is never shown: the roster matches on the name a
|
|
261
|
-
// peer chose, and that is the part before it.
|
|
262
|
-
const node = label.indexOf("#node:");
|
|
263
|
-
const name = node < 0 ? label : label.slice(0, node);
|
|
264
|
-
const prior = activeByName.get(name);
|
|
265
|
-
if (prior === undefined || at > prior) activeByName.set(name, at);
|
|
266
|
-
const factPrior = factAtByName.get(name);
|
|
267
|
-
if (fact && (factPrior === undefined || at > factPrior)) {
|
|
268
|
-
factAtByName.set(name, at);
|
|
269
|
-
factByName.set(name, fact);
|
|
270
|
-
}
|
|
271
|
-
} else if (segment.indexOf("teach:") === 0 || segment.indexOf("ace:") === 0) {
|
|
272
|
-
if (mineLastActive === null || at > mineLastActive) mineLastActive = at;
|
|
273
|
-
if (fact && (mineLastFactAt === null || at > mineLastFactAt)) {
|
|
274
|
-
mineLastFactAt = at;
|
|
275
|
-
mineLastFact = fact;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
const rows = [{
|
|
281
|
-
peerId: myPeerId,
|
|
282
|
-
name: myDisplayName,
|
|
283
|
-
connected: true,
|
|
284
|
-
isSelf: true,
|
|
285
|
-
lastActiveAt: mineLastActive,
|
|
286
|
-
lastFact: mineLastFact,
|
|
287
|
-
}];
|
|
288
|
-
for (const peer of peers || []) {
|
|
289
|
-
const name = nameFor(peer.peerId);
|
|
290
|
-
rows.push({
|
|
291
|
-
peerId: peer.peerId,
|
|
292
|
-
name: name,
|
|
293
|
-
connected: Boolean(peer.connected),
|
|
294
|
-
isSelf: false,
|
|
295
|
-
lastActiveAt: activeByName.has(name) ? activeByName.get(name) : null,
|
|
296
|
-
lastFact: factByName.has(name) ? factByName.get(name) : null,
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
rows.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0));
|
|
300
|
-
return rows;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* A node's monogram: the first letter of each of the two words its name is
|
|
305
|
-
* made of ("mossy-acorn" -> "ma"), falling back to the first two characters
|
|
306
|
-
* of anything that isn't shaped that way. Never empty, so a row never draws
|
|
307
|
-
* a blank circle.
|
|
308
|
-
*
|
|
309
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
310
|
-
*/
|
|
311
|
-
export function nodeInitials(name) {
|
|
312
|
-
const words = String(name || "").split(/[^a-z0-9]+/i).filter(Boolean);
|
|
313
|
-
if (words.length >= 2) return (words[0][0] + words[1][0]).toLowerCase();
|
|
314
|
-
if (words.length === 1) return words[0].slice(0, 2).toLowerCase();
|
|
315
|
-
return "??";
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* How long ago a node's most recent fact landed, relative to `nowMs`: "now"
|
|
320
|
-
* under 5s, otherwise the whole seconds/minutes/hours/days, coarsest unit
|
|
321
|
-
* that still reads as one number. `at` null/undefined (a peer with no
|
|
322
|
-
* activity yet) reads as an em dash rather than a bogus duration.
|
|
323
|
-
*
|
|
324
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
325
|
-
*/
|
|
326
|
-
export function relativeWhen(at, nowMs) {
|
|
327
|
-
if (at === null || at === undefined) return "—";
|
|
328
|
-
const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
|
|
329
|
-
if (seconds < 5) return "now";
|
|
330
|
-
if (seconds < 60) return seconds + "s";
|
|
331
|
-
if (seconds < 3600) return Math.round(seconds / 60) + "m";
|
|
332
|
-
if (seconds < 86400) return Math.round(seconds / 3600) + "h";
|
|
333
|
-
return Math.round(seconds / 86400) + "d";
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* The invite link: this page's own address carrying the offer blob, the world
|
|
338
|
-
* id and the world's name. Any query or fragment the current address already
|
|
339
|
-
* had is dropped, so inviting from a page that was itself opened from an
|
|
340
|
-
* invite mints a clean link rather than stacking two offers.
|
|
341
|
-
*
|
|
342
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
343
|
-
*/
|
|
344
|
-
export function inviteLinkFor(pageUrl, { blob, world, worldName }) {
|
|
345
|
-
const url = new URL(String(pageUrl));
|
|
346
|
-
url.search = "";
|
|
347
|
-
url.hash = "";
|
|
348
|
-
url.searchParams.set("offer", blob);
|
|
349
|
-
url.searchParams.set("world", world);
|
|
350
|
-
if (worldName) url.searchParams.set("name", worldName);
|
|
351
|
-
return url.toString();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* The invite an address carries, or null when it carries none. The world id
|
|
356
|
-
* and name are read here only to show the joiner what they were invited to
|
|
357
|
-
* before anything runs — the offer blob's own envelope is what actually
|
|
358
|
-
* decides, and it wins wherever the two disagree.
|
|
359
|
-
*
|
|
360
|
-
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
361
|
-
*/
|
|
362
|
-
export function inviteParamsFrom(search) {
|
|
363
|
-
const params = new URLSearchParams(String(search || ""));
|
|
364
|
-
const offer = params.get("offer");
|
|
365
|
-
if (!offer) return null;
|
|
366
|
-
return { offer: offer, world: params.get("world") || "", worldName: params.get("name") || "" };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
105
|
/**
|
|
370
106
|
* The page's backend mode from its own query string: "aws" when
|
|
371
107
|
* `backend=aws` is present, "local" for anything else — absent, empty, or
|
|
@@ -384,7 +120,7 @@ export function resolveBackendMode(search) {
|
|
|
384
120
|
* match `mode` — "aws" written explicitly, anything else removed rather than
|
|
385
121
|
* written out as "local", so a plain reload never carries a redundant param.
|
|
386
122
|
* Every other query parameter and the hash survive untouched, so a slider
|
|
387
|
-
* flip never drops
|
|
123
|
+
* flip never drops anything else the address was already carrying.
|
|
388
124
|
*
|
|
389
125
|
* Self-contained (no outer refs), `.toString()`-splice safe.
|
|
390
126
|
*/
|
|
@@ -523,13 +259,9 @@ ${THEME_TOKENS_CSS}
|
|
|
523
259
|
stacked), so this page keeps working exactly as before, just inside one
|
|
524
260
|
more layer. */
|
|
525
261
|
body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; display: flex; overflow: hidden; }
|
|
526
|
-
|
|
527
|
-
column; main.chatMain scrolls, and a burst anchored inside it would
|
|
528
|
-
scroll away mid-wave. */
|
|
529
|
-
.chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; position: relative; }
|
|
262
|
+
.chatCol { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
|
530
263
|
.mono { font-family: ${MONO_STACK}; }
|
|
531
|
-
/* every display rule below would otherwise beat the hidden attribute
|
|
532
|
-
hidden-but-displayed overlay still swallows clicks meant for the page. */
|
|
264
|
+
/* every display rule below would otherwise beat the hidden attribute. */
|
|
533
265
|
[hidden] { display: none !important; }
|
|
534
266
|
button { font: inherit; color: inherit; background: none; cursor: pointer; border: none; }
|
|
535
267
|
button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
@@ -657,14 +389,11 @@ ${THEME_TOKENS_CSS}
|
|
|
657
389
|
.statsPanel .researched-facts li { margin: .12rem 0; }
|
|
658
390
|
.statsPanel .researched-none { color: var(--muted); font-style: italic; margin: .3rem 0 0; }
|
|
659
391
|
|
|
660
|
-
/* ---- the page chrome
|
|
661
|
-
The
|
|
662
|
-
narrow window hides: the pill, the wave, the invite and the help link stay
|
|
663
|
-
reachable at every width. */
|
|
664
|
-
/* The composer is already this page's most familiar-chat element — a pill
|
|
392
|
+
/* ---- the page chrome ---------------------------------------------------
|
|
393
|
+
The composer is already this page's most familiar-chat element — a pill
|
|
665
394
|
input and a round send button. The chrome's controls take the same shape,
|
|
666
|
-
so
|
|
667
|
-
|
|
395
|
+
so they read as ordinary chat furniture rather than as an instrument
|
|
396
|
+
bolted on, and they stay reachable at every width. */
|
|
668
397
|
/* the live fact count, in the topbar rather than the statusline: it is the
|
|
669
398
|
one number that says what this session actually knows, and a small line of
|
|
670
399
|
grey mono under the composer is where a wrong one goes unnoticed. Reads as
|
|
@@ -684,32 +413,7 @@ ${THEME_TOKENS_CSS}
|
|
|
684
413
|
.chrome { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
|
|
685
414
|
.chrome-btn { font-family: ${SERIF_STACK}; font-size: .8rem; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .75rem; background: var(--card); text-decoration: none; display: inline-flex; align-items: center; gap: .32rem; white-space: nowrap; line-height: 1.35; }
|
|
686
415
|
.chrome-btn:hover { color: var(--ink); border-color: var(--ink); }
|
|
687
|
-
.chrome-btn.
|
|
688
|
-
.chrome-btn.help, .chrome-btn.icon { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
|
|
689
|
-
.chrome-btn .hand { font-size: .9rem; line-height: 1; }
|
|
690
|
-
|
|
691
|
-
.state-pill { display: inline-flex; align-items: center; gap: .4rem; font-family: ${SERIF_STACK}; font-size: .8rem; line-height: 1.35; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; padding: .22rem .78rem; background: var(--card); white-space: nowrap; }
|
|
692
|
-
.state-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); flex: 0 0 auto; }
|
|
693
|
-
.state-pill[data-tone="waiting"] .pill-dot, .state-pill[data-tone="working"] .pill-dot { background: var(--corpus); animation: wire-breathe 2.4s ease-in-out infinite; }
|
|
694
|
-
.state-pill[data-tone="working"] .pill-dot { animation-duration: .9s; }
|
|
695
|
-
.state-pill[data-tone="live"] { color: var(--taught); border-color: var(--taught-t1); }
|
|
696
|
-
.state-pill[data-tone="live"] .pill-dot { background: var(--taught); }
|
|
697
|
-
.state-pill[data-tone="failed"] { color: var(--alert); border-color: var(--alert); }
|
|
698
|
-
.state-pill[data-tone="failed"] .pill-dot { background: var(--alert); }
|
|
699
|
-
|
|
700
|
-
@keyframes wire-breathe { 0%, 100% { opacity: .2; } 50% { opacity: 1; } }
|
|
701
|
-
@keyframes hand-wave { 0%, 100% { transform: rotate(-14deg); } 50% { transform: rotate(20deg); } }
|
|
702
|
-
|
|
703
|
-
/* a wave, on every page it reaches: the waver's node name, over the
|
|
704
|
-
conversation, for as long as the wave is recent. Anchored under the
|
|
705
|
-
topbar, where a chat app puts a presence toast — the foot of the column
|
|
706
|
-
belongs to the composer, and a burst there lands behind it. */
|
|
707
|
-
.waveBurst { position: absolute; left: 50%; top: 3.4rem; transform: translateX(-50%); z-index: 20; display: flex; flex-direction: column; align-items: center; gap: .3rem; pointer-events: none; }
|
|
708
|
-
.wave-pill { display: flex; align-items: center; gap: .45rem; background: var(--card); border: 1px solid var(--line); border-radius: 99px; padding: .3rem .9rem .3rem .7rem; font-family: ${SERIF_STACK}; font-size: .85rem; color: var(--ink); box-shadow: 0 4px 14px rgba(0, 0, 0, .16); animation: wave-rise .3s ease-out; }
|
|
709
|
-
.wave-pill .hand { display: inline-block; font-size: 1rem; transform-origin: 70% 80%; animation: hand-wave .8s ease-in-out infinite; }
|
|
710
|
-
/* a wave aimed at you, specifically: the same pill, insistently louder. */
|
|
711
|
-
.wave-pill--you { border-color: var(--taught); box-shadow: 0 4px 14px rgba(0, 0, 0, .16), 0 0 0 3px var(--taught-soft); font-weight: 600; }
|
|
712
|
-
@keyframes wave-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
416
|
+
.chrome-btn.help { width: 1.7rem; height: 1.7rem; justify-content: center; padding: 0; }
|
|
713
417
|
|
|
714
418
|
@media (max-width: 1360px) {
|
|
715
419
|
/* the legend is decorative and the controls are not, so the legend goes
|
|
@@ -724,8 +428,6 @@ ${THEME_TOKENS_CSS}
|
|
|
724
428
|
}
|
|
725
429
|
@media (prefers-reduced-motion: reduce) {
|
|
726
430
|
* { scroll-behavior: auto !important; }
|
|
727
|
-
.state-pill .pill-dot { animation: none; transition: none; }
|
|
728
|
-
.wave-pill, .wave-pill .hand { animation: none; }
|
|
729
431
|
}
|
|
730
432
|
|
|
731
433
|
/* print: the WHOLE transcript, not the scrolled-into-view slice — the
|
|
@@ -740,14 +442,11 @@ ${THEME_TOKENS_CSS}
|
|
|
740
442
|
.chatCol { display: block; }
|
|
741
443
|
main.chatMain { overflow: visible; height: auto; }
|
|
742
444
|
.messages { min-height: 0; }
|
|
743
|
-
form.composer, .statusline, .statsPanel, .legend { display: none; }
|
|
744
|
-
.shareOverlay, .waveBurst, .chrome, .copyTip { display: none; }
|
|
445
|
+
form.composer, .statusline, .statsPanel, .legend, .chrome { display: none; }
|
|
745
446
|
}
|
|
746
|
-
${SHARE_OVERLAY_CSS}
|
|
747
447
|
</style>
|
|
748
448
|
</head>
|
|
749
449
|
<body>
|
|
750
|
-
${shareOverlayHtml({ withTape: true })}
|
|
751
450
|
<div class="chatCol">
|
|
752
451
|
<h1 class="visually-hidden">talk to it</h1>
|
|
753
452
|
<header class="topbar">
|
|
@@ -759,23 +458,11 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
759
458
|
title="every fact this session's memory holds right now — the starter memory it shipped with plus anything you have taught, researched or ingested">
|
|
760
459
|
<span class="fact-pill-value" id="factPillValue">loading</span><span id="factPillUnit"> starter memory…</span>
|
|
761
460
|
</span>
|
|
762
|
-
<button type="button" class="state-pill" id="statePill" data-tone="idle"
|
|
763
|
-
title="the shared-world connection; click to open the network panel">
|
|
764
|
-
<i class="pill-dot"></i><span id="statePillWord">not shared</span>
|
|
765
|
-
</button>
|
|
766
|
-
<button type="button" class="chrome-btn icon" id="waveBtn"
|
|
767
|
-
title="wave to everyone connected to this graph" aria-label="wave to everyone connected to this graph">
|
|
768
|
-
<span class="hand">👋</span>
|
|
769
|
-
</button>
|
|
770
|
-
<button type="button" class="chrome-btn share" id="shareBtn" title="copy a link that invites one person into this graph">
|
|
771
|
-
invite
|
|
772
|
-
</button>
|
|
773
461
|
<a class="chrome-btn help" href="./help.html#chat" target="_blank" rel="noopener"
|
|
774
462
|
title="how this page works, in a new tab" aria-label="help, opens in a new tab">?</a>
|
|
775
463
|
</div>
|
|
776
464
|
<div class="legend" aria-hidden="true">${legendHtml}</div>
|
|
777
465
|
</header>
|
|
778
|
-
<div class="waveBurst" id="waveBurst" aria-live="polite"></div>
|
|
779
466
|
<main class="chatMain">
|
|
780
467
|
<div class="messages" id="messages" role="log" aria-live="polite" aria-label="Conversation"></div>
|
|
781
468
|
</main>
|
|
@@ -847,26 +534,8 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
847
534
|
const renderStatsPanelInto = ${renderStatsPanelInto.toString()};
|
|
848
535
|
const createTicker = ${createTicker.toString()};
|
|
849
536
|
const prefersReducedMotion = ${prefersReducedMotion.toString()};
|
|
850
|
-
const wireStateLabel = ${wireStateLabel.toString()};
|
|
851
|
-
const tapeRowFor = ${tapeRowFor.toString()};
|
|
852
|
-
const nodeRowsFor = ${nodeRowsFor.toString()};
|
|
853
|
-
const nodeInitials = ${nodeInitials.toString()};
|
|
854
|
-
const inviteLinkFor = ${inviteLinkFor.toString()};
|
|
855
|
-
const inviteParamsFrom = ${inviteParamsFrom.toString()};
|
|
856
537
|
const resolveBackendMode = ${resolveBackendMode.toString()};
|
|
857
538
|
const backendModeUrl = ${backendModeUrl.toString()};
|
|
858
|
-
const tapeClock = ${tapeClock.toString()};
|
|
859
|
-
const relativeWhen = ${relativeWhen.toString()};
|
|
860
|
-
const shareStepStates = ${shareStepStates.toString()};
|
|
861
|
-
const activeWaves = ${activeWaves.toString()};
|
|
862
|
-
const offerBlobIn = ${offerBlobIn.toString()};
|
|
863
|
-
const peerTerm = ${peerTerm.toString()};
|
|
864
|
-
const shareMessageFor = ${shareMessageFor.toString()};
|
|
865
|
-
const replyMessageFor = ${replyMessageFor.toString()};
|
|
866
|
-
const whatsAppShareUrl = ${whatsAppShareUrl.toString()};
|
|
867
|
-
const isProbablyMobile = ${isProbablyMobile.toString()};
|
|
868
|
-
const copyText = ${copyTextToClipboard.toString()};
|
|
869
|
-
const flashTip = ${flashCopyTip.toString()};
|
|
870
539
|
const DIGEST_STRUCTURES = ${digestStructuresJson};
|
|
871
540
|
const el = (id) => document.getElementById(id);
|
|
872
541
|
|
|
@@ -1284,10 +953,6 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1284
953
|
await persist.clear();
|
|
1285
954
|
}
|
|
1286
955
|
restoredCount = 0;
|
|
1287
|
-
// The room holds the OLD session's store, so it can't outlive the swap —
|
|
1288
|
-
// it would keep merging peers' facts into a store nothing reads any more.
|
|
1289
|
-
// Rejoining is a fresh invite, which is what a dropped node needs anyway.
|
|
1290
|
-
dropRoom();
|
|
1291
956
|
await newSession();
|
|
1292
957
|
const stats = await window.tmct.page.memoryStats(window.tmct.session.memoryDir);
|
|
1293
958
|
const note = backendMode === "aws" && !persistenceUnavailable
|
|
@@ -1474,10 +1139,7 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1474
1139
|
: "wink-nlp: loading\\u2026";
|
|
1475
1140
|
const liveReference = window.tmct.session ? window.tmct.session.liveReference : liveReferenceForMode(checkedWikiMode());
|
|
1476
1141
|
const livePart = "live wikipedia: " + liveStatusWord(liveReference);
|
|
1477
|
-
|
|
1478
|
-
? " \\u00b7 world \\u201c" + worldName + "\\u201d: " + wireStateLabel(room.state).word
|
|
1479
|
-
: "";
|
|
1480
|
-
statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart + netPart;
|
|
1142
|
+
statusEl.textContent = seedPart + " \\u00b7 " + winkPart + " \\u00b7 " + livePart;
|
|
1481
1143
|
}
|
|
1482
1144
|
|
|
1483
1145
|
// A "/wiki on|off|supplement|always" turn flips the session's own state;
|
|
@@ -1540,12 +1202,7 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1540
1202
|
// were lost on reload when only via==="assert" saved. Commands write
|
|
1541
1203
|
// nothing, so they stay out. The save is debounced, so a read-through
|
|
1542
1204
|
// that changed nothing costs at most one coalesced write.
|
|
1543
|
-
if (result.record && result.record.via !== "command")
|
|
1544
|
-
scheduleSave();
|
|
1545
|
-
// Whatever this turn wrote goes out to every connected node. The room
|
|
1546
|
-
// diffs the store itself, so a turn that stored nothing costs nothing.
|
|
1547
|
-
if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
|
|
1548
|
-
}
|
|
1205
|
+
if (result.record && result.record.via !== "command") scheduleSave();
|
|
1549
1206
|
await renderStatsPanel(); // a teach or learned-load turn grew this session's memory; a plain ask leaves it unchanged either way
|
|
1550
1207
|
await noteResearchLearned(result);
|
|
1551
1208
|
} catch (err) {
|
|
@@ -1570,15 +1227,6 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1570
1227
|
const q = inputEl.value.trim();
|
|
1571
1228
|
if (!q || busy || !window.tmct.session) return;
|
|
1572
1229
|
inputEl.value = "";
|
|
1573
|
-
const lowered = q.toLowerCase();
|
|
1574
|
-
if (lowered === "wave" || lowered === "/wave") {
|
|
1575
|
-
addUserBubble(q);
|
|
1576
|
-
transcript.push({ role: "you", text: q, chipTier: null, ts: Date.now() });
|
|
1577
|
-
addSystemLine("you waved — everyone connected to this graph sees it.");
|
|
1578
|
-
waveNow();
|
|
1579
|
-
inputEl.focus();
|
|
1580
|
-
return;
|
|
1581
|
-
}
|
|
1582
1230
|
submitLine(q).then(() => inputEl.focus());
|
|
1583
1231
|
});
|
|
1584
1232
|
|
|
@@ -1726,10 +1374,7 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1726
1374
|
} catch (err) {
|
|
1727
1375
|
addSystemLine("something went wrong ingesting " + file.name + " (" + (err && err.message ? err.message : err) + ").");
|
|
1728
1376
|
}
|
|
1729
|
-
if (grounded)
|
|
1730
|
-
scheduleSave();
|
|
1731
|
-
if (room) room.afterLocalChange().catch(function () { /* a dead channel reports itself through its own close */ });
|
|
1732
|
-
}
|
|
1377
|
+
if (grounded) scheduleSave();
|
|
1733
1378
|
const skipped = sentences.length - grounded;
|
|
1734
1379
|
addSystemLine("ingested " + file.name + " \\u2014 " + sentences.length + " sentence"
|
|
1735
1380
|
+ (sentences.length === 1 ? "" : "s") + " read, " + grounded + " fact"
|
|
@@ -1767,746 +1412,6 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
1767
1412
|
window.location.reload();
|
|
1768
1413
|
});
|
|
1769
1414
|
|
|
1770
|
-
// ---- the shared world: nodes, the wire, and the two-paste handshake -----
|
|
1771
|
-
// Every piece of networking arrives from ./vendor/p2p.js, the site's own
|
|
1772
|
-
// shared P2P asset, imported the first time it is actually wanted rather
|
|
1773
|
-
// than at boot — a visitor who never shares never waits for it. The room
|
|
1774
|
-
// owns signaling, the mesh and the merge; this block owns what a person
|
|
1775
|
-
// sees and clicks, and nothing else.
|
|
1776
|
-
const netPanelEl = el("netPanel");
|
|
1777
|
-
const nodeNameInputEl = el("nodeNameInput");
|
|
1778
|
-
const worldNameInputEl = el("worldNameInput");
|
|
1779
|
-
const wireStateEl = el("wireState");
|
|
1780
|
-
const wireStateWordEl = el("wireStateWord");
|
|
1781
|
-
const wireStateNoteEl = el("wireStateNote");
|
|
1782
|
-
const statePillEl = el("statePill");
|
|
1783
|
-
const statePillWordEl = el("statePillWord");
|
|
1784
|
-
const shareLinkEl = el("shareLink");
|
|
1785
|
-
const inviteSummaryEl = el("inviteSummary");
|
|
1786
|
-
const webShareBtnEl = el("webShareBtn");
|
|
1787
|
-
const waShareBtnEl = el("waShareBtn");
|
|
1788
|
-
const replyShareBtnEl = el("replyShareBtn");
|
|
1789
|
-
const replyWaBtnEl = el("replyWaBtn");
|
|
1790
|
-
const joinShareBtnEl = el("joinShareBtn");
|
|
1791
|
-
const joinWaBtnEl = el("joinWaBtn");
|
|
1792
|
-
const replyBoxEl = el("replyBox");
|
|
1793
|
-
const replyProblemEl = el("replyProblem");
|
|
1794
|
-
const replyOutEl = el("replyOut");
|
|
1795
|
-
const nodeListEl = el("nodeList");
|
|
1796
|
-
const nodeEmptyEl = el("nodeEmpty");
|
|
1797
|
-
const nodeCountEl = el("nodeCount");
|
|
1798
|
-
const tapeEl = el("tape");
|
|
1799
|
-
const tapeEmptyEl = el("tapeEmpty");
|
|
1800
|
-
const tapeMeterEl = el("tapeMeter");
|
|
1801
|
-
const tapeTotalEl = el("tapeTotal");
|
|
1802
|
-
const waveBurstEl = el("waveBurst");
|
|
1803
|
-
const joinCardEl = el("joinCard");
|
|
1804
|
-
const joinWorldEl = el("joinWorld");
|
|
1805
|
-
const joinEyebrowEl = el("joinEyebrow");
|
|
1806
|
-
const joinBodyEl = el("joinBody");
|
|
1807
|
-
const joinBtn = el("joinBtn");
|
|
1808
|
-
const joinProblemEl = el("joinProblem");
|
|
1809
|
-
const joinReplyWrapEl = el("joinReplyWrap");
|
|
1810
|
-
const joinReplyEl = el("joinReply");
|
|
1811
|
-
const joinDismissBtn = el("joinDismiss");
|
|
1812
|
-
const shareBtn = el("shareBtn");
|
|
1813
|
-
const waveBtn = el("waveBtn");
|
|
1814
|
-
const copyReplyBtn = el("copyReplyBtn");
|
|
1815
|
-
const joinCopyBtn = el("joinCopyBtn");
|
|
1816
|
-
|
|
1817
|
-
const P2P_ASSET = "./vendor/p2p.js";
|
|
1818
|
-
const NODE_NAME_KEY = "tmct.chat.nodeName";
|
|
1819
|
-
// Not the IndexedDB store beside it: that record is stamped with the site
|
|
1820
|
-
// version and the seed, and is dropped whenever either moves. A node id has
|
|
1821
|
-
// to outlive a deploy, because peers have already keyed this node's facts
|
|
1822
|
-
// on it.
|
|
1823
|
-
const NODE_ID_KEY = "tmct.chat.nodeId";
|
|
1824
|
-
const invite = inviteParamsFrom(window.location.search);
|
|
1825
|
-
|
|
1826
|
-
let p2p = null;
|
|
1827
|
-
let p2pLoad = null;
|
|
1828
|
-
let room = null;
|
|
1829
|
-
let myPeerId = null;
|
|
1830
|
-
let myNodeId = "";
|
|
1831
|
-
let myDisplayName = "";
|
|
1832
|
-
let worldId = "";
|
|
1833
|
-
let worldName = "";
|
|
1834
|
-
let waveTimer = null;
|
|
1835
|
-
let nodeClockTimer = null;
|
|
1836
|
-
let channelCount = 0;
|
|
1837
|
-
let mintedBlob = "";
|
|
1838
|
-
|
|
1839
|
-
// Which seat this page occupies in the handshake — "idle" until something
|
|
1840
|
-
// happens, "sponsor" once an invite is minted here, "joiner" once an invite
|
|
1841
|
-
// is opened here. The overlay's CSS reads it to decide which calls to
|
|
1842
|
-
// action stand at each step; entry distinguishes an invite that arrived
|
|
1843
|
-
// as a link (the hero card owns the reply) from one pasted in as text
|
|
1844
|
-
// (step 4's own slot owns it).
|
|
1845
|
-
function setOverlayRole(role, entry) {
|
|
1846
|
-
netPanelEl.dataset.role = role;
|
|
1847
|
-
netPanelEl.dataset.entry = entry || "";
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
function loadP2p() {
|
|
1851
|
-
if (!p2pLoad) {
|
|
1852
|
-
p2pLoad = import(P2P_ASSET).then(function (mod) { p2p = mod; return mod; });
|
|
1853
|
-
p2pLoad.catch(function (err) {
|
|
1854
|
-
noteTape("note", "fault", "networking unavailable", err && err.message ? err.message : String(err));
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
1857
|
-
return p2pLoad;
|
|
1858
|
-
}
|
|
1859
|
-
window.tmctP2pLoad = loadP2p;
|
|
1860
|
-
|
|
1861
|
-
function readStoredNodeName() {
|
|
1862
|
-
try { return localStorage.getItem(NODE_NAME_KEY) || ""; } catch { return ""; }
|
|
1863
|
-
}
|
|
1864
|
-
function writeStoredNodeName(name) {
|
|
1865
|
-
try { localStorage.setItem(NODE_NAME_KEY, name); } catch { /* private mode — the name still holds for this visit */ }
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async function ensureIdentity() {
|
|
1869
|
-
const mod = await loadP2p();
|
|
1870
|
-
if (!myPeerId) myPeerId = mod.generatePeerId();
|
|
1871
|
-
if (!myNodeId) {
|
|
1872
|
-
try { myNodeId = localStorage.getItem(NODE_ID_KEY) || ""; } catch { myNodeId = ""; }
|
|
1873
|
-
if (!myNodeId) {
|
|
1874
|
-
myNodeId = mod.generateNodeId();
|
|
1875
|
-
try { localStorage.setItem(NODE_ID_KEY, myNodeId); } catch { /* private mode — this visit still has an id, it just won't outlive the tab */ }
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
if (!myDisplayName) {
|
|
1879
|
-
myDisplayName = readStoredNodeName() || mod.generateDisplayName();
|
|
1880
|
-
nodeNameInputEl.value = myDisplayName;
|
|
1881
|
-
}
|
|
1882
|
-
if (!worldId) worldId = invite && invite.world ? invite.world : mod.generateWorldId();
|
|
1883
|
-
if (!worldName) {
|
|
1884
|
-
worldName = invite && invite.worldName ? invite.worldName : mod.generateDisplayName();
|
|
1885
|
-
worldNameInputEl.value = worldName;
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
// The one place a transport gets made, which makes it the one place every
|
|
1890
|
-
// message crossing one can be seen. The room asks for transports through
|
|
1891
|
-
// this factory and never learns it is being watched.
|
|
1892
|
-
function instrumentedTransport() {
|
|
1893
|
-
const transport = p2p.createTransport();
|
|
1894
|
-
const channel = "ch" + (++channelCount);
|
|
1895
|
-
transport.onMessage(function (message) { noteWire("in", message, channel); });
|
|
1896
|
-
transport.onOpen(function () { noteTape("note", "link", "channel open", channel); });
|
|
1897
|
-
transport.onClose(function () { noteTape("note", "link", "channel closed", channel); });
|
|
1898
|
-
return {
|
|
1899
|
-
createOffer: function () {
|
|
1900
|
-
return transport.createOffer().then(function (sdp) { noteTape("note", "signal", "offer minted", channel); return sdp; });
|
|
1901
|
-
},
|
|
1902
|
-
createAnswerFor: function (offerSdp) {
|
|
1903
|
-
return transport.createAnswerFor(offerSdp).then(function (sdp) { noteTape("note", "signal", "answer minted", channel); return sdp; });
|
|
1904
|
-
},
|
|
1905
|
-
completeWithAnswer: function (answerSdp) {
|
|
1906
|
-
noteTape("note", "signal", "answer accepted", channel);
|
|
1907
|
-
return transport.completeWithAnswer(answerSdp);
|
|
1908
|
-
},
|
|
1909
|
-
send: function (message) { transport.send(message); noteWire("out", message, channel); },
|
|
1910
|
-
onMessage: function (fn) { transport.onMessage(fn); },
|
|
1911
|
-
onOpen: function (fn) { transport.onOpen(fn); },
|
|
1912
|
-
onClose: function (fn) { transport.onClose(fn); },
|
|
1913
|
-
close: function () { transport.close(); },
|
|
1914
|
-
get connectionState() { return transport.connectionState; },
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
async function ensureRoom() {
|
|
1919
|
-
if (room) return room;
|
|
1920
|
-
const mod = await loadP2p();
|
|
1921
|
-
await ensureIdentity();
|
|
1922
|
-
if (!window.tmct.session) await window.tmctChatReady;
|
|
1923
|
-
if (!window.tmct.session) throw new Error("the chat engine didn't finish booting");
|
|
1924
|
-
room = mod.createP2pRoom({
|
|
1925
|
-
memoryDir: window.tmct.session.memoryDir,
|
|
1926
|
-
myPeerId: myPeerId,
|
|
1927
|
-
myDisplayName: myDisplayName,
|
|
1928
|
-
myNodeId: myNodeId,
|
|
1929
|
-
worldId: worldId,
|
|
1930
|
-
worldName: worldName,
|
|
1931
|
-
transportFactory: instrumentedTransport,
|
|
1932
|
-
syncableFacts: mod.chatSyncableFacts,
|
|
1933
|
-
});
|
|
1934
|
-
room.onStateChanged(function (state) {
|
|
1935
|
-
noteTape("note", state === "failed" ? "fault" : "state", "state " + state, "");
|
|
1936
|
-
// The overlay exists to make one connection. The moment the channel is
|
|
1937
|
-
// open its work is done — the lights come back up, and the pill in the
|
|
1938
|
-
// chrome is the way back in.
|
|
1939
|
-
if (state === "connected") {
|
|
1940
|
-
joinCardEl.hidden = true;
|
|
1941
|
-
closeNetPanel();
|
|
1942
|
-
flashTip(statePillEl, "connected \\u2014 you're sharing live");
|
|
1943
|
-
}
|
|
1944
|
-
renderWire();
|
|
1945
|
-
renderStatus();
|
|
1946
|
-
});
|
|
1947
|
-
room.onPeersChanged(function () { renderNodes(); renderStatus(); });
|
|
1948
|
-
room.onFactsChanged(function (payload) {
|
|
1949
|
-
noteTape("note", "facts", "merged", payload.merged + (payload.merged === 1 ? " fact" : " facts"));
|
|
1950
|
-
renderStatsPanel();
|
|
1951
|
-
renderNodes();
|
|
1952
|
-
renderWaves();
|
|
1953
|
-
});
|
|
1954
|
-
await room.start();
|
|
1955
|
-
// The world's name is written into the graph the moment the room starts,
|
|
1956
|
-
// and this version retracts nothing — so the field stops taking edits.
|
|
1957
|
-
worldNameInputEl.readOnly = true;
|
|
1958
|
-
worldNameInputEl.title = "written into the graph when this world started";
|
|
1959
|
-
window.tmctP2pRoom = room;
|
|
1960
|
-
noteTape("note", "link", "world " + worldName, myDisplayName);
|
|
1961
|
-
renderWire();
|
|
1962
|
-
renderNodes();
|
|
1963
|
-
renderStatus();
|
|
1964
|
-
if (!nodeClockTimer) nodeClockTimer = setInterval(renderNodes, 10000);
|
|
1965
|
-
return room;
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
function dropRoom() {
|
|
1969
|
-
if (!room) return;
|
|
1970
|
-
room.close();
|
|
1971
|
-
room = null;
|
|
1972
|
-
window.tmctP2pRoom = null;
|
|
1973
|
-
clearInterval(nodeClockTimer);
|
|
1974
|
-
nodeClockTimer = null;
|
|
1975
|
-
shareLinkEl.value = "";
|
|
1976
|
-
replyOutEl.value = "";
|
|
1977
|
-
mintedBlob = "";
|
|
1978
|
-
setOverlayRole("idle", "");
|
|
1979
|
-
noteTape("note", "link", "world closed", worldName);
|
|
1980
|
-
renderWire();
|
|
1981
|
-
renderNodes();
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
// ---- the wire tape: every message, as it happens ------------------------
|
|
1985
|
-
const TAPE_CAP = 240;
|
|
1986
|
-
const TAPE_FAMILY_COLOR = {
|
|
1987
|
-
facts: "var(--taught)",
|
|
1988
|
-
state: "var(--corpus)",
|
|
1989
|
-
greeting: "var(--entail)",
|
|
1990
|
-
signal: "var(--entail-t1)",
|
|
1991
|
-
fault: "var(--alert)",
|
|
1992
|
-
link: "var(--muted)",
|
|
1993
|
-
};
|
|
1994
|
-
const tapeCounts = new Map();
|
|
1995
|
-
let wireMessageCount = 0;
|
|
1996
|
-
|
|
1997
|
-
function pushTape(entry) {
|
|
1998
|
-
// The meter counts real wire messages only; the tape below shows those
|
|
1999
|
-
// plus the local notes (state changes, a channel opening) that explain
|
|
2000
|
-
// them. Folding notes into the counts would make "12 op" mean two things.
|
|
2001
|
-
if (entry.dir !== "note") {
|
|
2002
|
-
wireMessageCount += 1;
|
|
2003
|
-
tapeCounts.set(entry.type, (tapeCounts.get(entry.type) || 0) + 1);
|
|
2004
|
-
}
|
|
2005
|
-
tapeEmptyEl.hidden = true;
|
|
2006
|
-
const row = document.createElement("li");
|
|
2007
|
-
row.className = "tape-row";
|
|
2008
|
-
row.dataset.dir = entry.dir;
|
|
2009
|
-
row.dataset.family = entry.family;
|
|
2010
|
-
row.dataset.type = entry.type;
|
|
2011
|
-
const bar = document.createElement("i");
|
|
2012
|
-
bar.className = "tape-bar";
|
|
2013
|
-
const clock = document.createElement("span");
|
|
2014
|
-
clock.className = "tape-clock";
|
|
2015
|
-
clock.textContent = tapeClock();
|
|
2016
|
-
const type = document.createElement("span");
|
|
2017
|
-
type.className = "tape-type";
|
|
2018
|
-
type.textContent = entry.type;
|
|
2019
|
-
const detail = document.createElement("span");
|
|
2020
|
-
detail.className = "tape-detail";
|
|
2021
|
-
detail.textContent = entry.detail || "";
|
|
2022
|
-
row.appendChild(bar);
|
|
2023
|
-
row.appendChild(clock);
|
|
2024
|
-
row.appendChild(type);
|
|
2025
|
-
row.appendChild(detail);
|
|
2026
|
-
tapeEl.insertBefore(row, tapeEl.firstChild);
|
|
2027
|
-
while (tapeEl.childElementCount > TAPE_CAP) tapeEl.removeChild(tapeEl.lastElementChild);
|
|
2028
|
-
renderTapeMeter();
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
function noteWire(direction, message, channel) {
|
|
2032
|
-
const row = tapeRowFor(direction, message);
|
|
2033
|
-
pushTape({ dir: direction, family: row.family, type: row.type, detail: row.detail || channel });
|
|
2034
|
-
}
|
|
2035
|
-
function noteTape(dir, family, type, detail) {
|
|
2036
|
-
pushTape({ dir: dir, family: family, type: type, detail: detail });
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
function renderTapeMeter() {
|
|
2040
|
-
tapeTotalEl.textContent = wireMessageCount + (wireMessageCount === 1 ? " message" : " messages");
|
|
2041
|
-
tapeMeterEl.textContent = "";
|
|
2042
|
-
const counted = [...tapeCounts.entries()].sort(function (a, b) { return b[1] - a[1]; });
|
|
2043
|
-
for (const pair of counted) {
|
|
2044
|
-
const item = document.createElement("span");
|
|
2045
|
-
item.className = "meter-item";
|
|
2046
|
-
item.dataset.type = pair[0];
|
|
2047
|
-
const bar = document.createElement("i");
|
|
2048
|
-
bar.className = "meter-bar";
|
|
2049
|
-
bar.style.background = TAPE_FAMILY_COLOR[tapeRowFor("in", { type: pair[0] }).family] || "var(--muted)";
|
|
2050
|
-
const label = document.createElement("span");
|
|
2051
|
-
label.textContent = pair[0];
|
|
2052
|
-
const count = document.createElement("span");
|
|
2053
|
-
count.className = "meter-n";
|
|
2054
|
-
count.textContent = String(pair[1]);
|
|
2055
|
-
item.appendChild(bar);
|
|
2056
|
-
item.appendChild(label);
|
|
2057
|
-
item.appendChild(count);
|
|
2058
|
-
tapeMeterEl.appendChild(item);
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// ---- what a person sees: state, the ladder, nodes, waves ----------------
|
|
2063
|
-
function renderWire() {
|
|
2064
|
-
const label = wireStateLabel(room ? room.state : "idle");
|
|
2065
|
-
wireStateEl.dataset.tone = label.tone;
|
|
2066
|
-
wireStateWordEl.textContent = label.word;
|
|
2067
|
-
wireStateNoteEl.textContent = label.note;
|
|
2068
|
-
statePillEl.dataset.tone = label.tone;
|
|
2069
|
-
statePillWordEl.textContent = label.pill;
|
|
2070
|
-
statePillEl.title = label.note;
|
|
2071
|
-
netPanelEl.dataset.tone = label.tone;
|
|
2072
|
-
renderSteps();
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
function renderSteps() {
|
|
2076
|
-
const states = shareStepStates({
|
|
2077
|
-
role: netPanelEl.dataset.role,
|
|
2078
|
-
state: room ? room.state : "idle",
|
|
2079
|
-
hasReply: Boolean(replyOutEl.value),
|
|
2080
|
-
});
|
|
2081
|
-
for (const step of states) {
|
|
2082
|
-
const item = el("step-" + step.key);
|
|
2083
|
-
if (item) item.dataset.status = step.status;
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
// The scene's two browser cards: this one wears the node's own name, the
|
|
2088
|
-
// far one wears the first peer's the moment there is one to name.
|
|
2089
|
-
function renderScene() {
|
|
2090
|
-
el("sceneYouName").textContent = myDisplayName || "you";
|
|
2091
|
-
const peers = room ? room.peers() : [];
|
|
2092
|
-
const first = peers.find(function (p) { return p.connected; }) || peers[0];
|
|
2093
|
-
el("sceneThemName").textContent = first ? room.displayNameFor(first.peerId) : "your friend";
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
function renderNodes() {
|
|
2097
|
-
if (!room || !p2p) {
|
|
2098
|
-
nodeCountEl.textContent = "";
|
|
2099
|
-
nodeListEl.textContent = "";
|
|
2100
|
-
nodeEmptyEl.hidden = false;
|
|
2101
|
-
renderScene();
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
const rows = nodeRowsFor({
|
|
2105
|
-
peers: room.peers(),
|
|
2106
|
-
factRows: room.factRows(),
|
|
2107
|
-
myPeerId: myPeerId,
|
|
2108
|
-
myDisplayName: myDisplayName,
|
|
2109
|
-
nameFor: room.displayNameFor,
|
|
2110
|
-
latestTimestampOf: p2p.latestProvenanceTimestamp,
|
|
2111
|
-
});
|
|
2112
|
-
const nowMs = Date.now();
|
|
2113
|
-
const wavers = new Set(activeWaves(room.factRows(), nowMs).map(function (w) { return w.waver; }));
|
|
2114
|
-
nodeCountEl.textContent = rows.length + (rows.length === 1 ? " node" : " nodes");
|
|
2115
|
-
nodeListEl.textContent = "";
|
|
2116
|
-
for (const entry of rows) {
|
|
2117
|
-
const item = document.createElement("li");
|
|
2118
|
-
item.className = "node-row";
|
|
2119
|
-
item.dataset.self = String(entry.isSelf);
|
|
2120
|
-
item.dataset.away = String(!entry.connected);
|
|
2121
|
-
item.dataset.peer = entry.peerId;
|
|
2122
|
-
item.dataset.waving = String(wavers.has(peerTerm(entry.peerId)));
|
|
2123
|
-
const avatar = document.createElement("span");
|
|
2124
|
-
avatar.className = "node-avatar";
|
|
2125
|
-
avatar.textContent = nodeInitials(entry.name);
|
|
2126
|
-
avatar.title = entry.connected ? "connected" : "away — closed the tab or dropped offline; everything it contributed stays";
|
|
2127
|
-
const dot = document.createElement("i");
|
|
2128
|
-
dot.className = "node-dot";
|
|
2129
|
-
avatar.appendChild(dot);
|
|
2130
|
-
const hand = document.createElement("span");
|
|
2131
|
-
hand.className = "node-hand";
|
|
2132
|
-
hand.textContent = "👋";
|
|
2133
|
-
avatar.appendChild(hand);
|
|
2134
|
-
const name = document.createElement("span");
|
|
2135
|
-
name.className = "node-name";
|
|
2136
|
-
name.textContent = entry.name;
|
|
2137
|
-
const when = document.createElement("span");
|
|
2138
|
-
when.className = "node-when";
|
|
2139
|
-
when.textContent = relativeWhen(entry.lastActiveAt, nowMs);
|
|
2140
|
-
when.title = entry.lastActiveAt
|
|
2141
|
-
? "last contributed a fact at " + new Date(entry.lastActiveAt).toLocaleTimeString()
|
|
2142
|
-
: "has contributed no fact yet";
|
|
2143
|
-
const fact = document.createElement("span");
|
|
2144
|
-
fact.className = "node-fact";
|
|
2145
|
-
if (entry.lastFact) {
|
|
2146
|
-
const line = entry.lastFact.subject + " " + String(entry.lastFact.predicate || "").replace(/^[a-z0-9_-]+:/i, "") + " " + entry.lastFact.object;
|
|
2147
|
-
fact.textContent = line;
|
|
2148
|
-
fact.title = "last shared: " + line;
|
|
2149
|
-
} else {
|
|
2150
|
-
fact.textContent = "nothing shared yet";
|
|
2151
|
-
}
|
|
2152
|
-
const waveOne = document.createElement("button");
|
|
2153
|
-
waveOne.type = "button";
|
|
2154
|
-
waveOne.className = "node-wave-btn";
|
|
2155
|
-
waveOne.textContent = "👋";
|
|
2156
|
-
waveOne.title = entry.isSelf ? "wave at everyone" : "wave at " + entry.name + " — they see it as a fact arriving";
|
|
2157
|
-
waveOne.setAttribute("aria-label", waveOne.title);
|
|
2158
|
-
waveOne.addEventListener("click", function () { waveAt(entry.isSelf ? null : entry.peerId, waveOne); });
|
|
2159
|
-
item.appendChild(avatar);
|
|
2160
|
-
item.appendChild(name);
|
|
2161
|
-
item.appendChild(when);
|
|
2162
|
-
item.appendChild(waveOne);
|
|
2163
|
-
item.appendChild(fact);
|
|
2164
|
-
nodeListEl.appendChild(item);
|
|
2165
|
-
}
|
|
2166
|
-
nodeEmptyEl.hidden = rows.length > 1;
|
|
2167
|
-
renderScene();
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
// A wave is a fact like any other, so it reaches every page through the
|
|
2171
|
-
// same merge every other fact does; "currently waving" is a recency read
|
|
2172
|
-
// over that fact, never stored state. Nothing here is a second live-update
|
|
2173
|
-
// path — onFactsChanged is what wakes it, and the timer below only lets a
|
|
2174
|
-
// wave stop rendering once its window has passed. The fact's object names
|
|
2175
|
-
// the audience: the presence scope is everyone, a peer term is one node —
|
|
2176
|
-
// and a wave aimed at this node says so to its face.
|
|
2177
|
-
function renderWaves() {
|
|
2178
|
-
if (!room) return;
|
|
2179
|
-
const nowMs = Date.now();
|
|
2180
|
-
const myTerm = peerTerm(myPeerId);
|
|
2181
|
-
const nameByTerm = new Map([[myTerm, myDisplayName]]);
|
|
2182
|
-
for (const peer of room.peers()) nameByTerm.set(peerTerm(peer.peerId), room.displayNameFor(peer.peerId));
|
|
2183
|
-
const waves = activeWaves(room.factRows(), nowMs).filter(function (w) { return nameByTerm.has(w.waver); });
|
|
2184
|
-
waveBurstEl.textContent = "";
|
|
2185
|
-
for (const wave of waves) {
|
|
2186
|
-
const pill = document.createElement("div");
|
|
2187
|
-
pill.className = "wave-pill" + (wave.target === myTerm ? " wave-pill--you" : "");
|
|
2188
|
-
const hand = document.createElement("span");
|
|
2189
|
-
hand.className = "hand";
|
|
2190
|
-
hand.textContent = "👋";
|
|
2191
|
-
const label = document.createElement("span");
|
|
2192
|
-
const who = nameByTerm.get(wave.waver);
|
|
2193
|
-
label.textContent = !wave.target ? who + " waved at everyone"
|
|
2194
|
-
: wave.target === myTerm ? who + " is waving at you"
|
|
2195
|
-
: nameByTerm.has(wave.target) ? who + " is waving at " + nameByTerm.get(wave.target)
|
|
2196
|
-
: who + " waved";
|
|
2197
|
-
pill.appendChild(hand);
|
|
2198
|
-
pill.appendChild(label);
|
|
2199
|
-
waveBurstEl.appendChild(pill);
|
|
2200
|
-
}
|
|
2201
|
-
const wavers = new Set(waves.map(function (w) { return w.waver; }));
|
|
2202
|
-
for (const item of nodeListEl.children) {
|
|
2203
|
-
item.dataset.waving = String(wavers.has(peerTerm(item.dataset.peer)));
|
|
2204
|
-
}
|
|
2205
|
-
clearTimeout(waveTimer);
|
|
2206
|
-
if (waves.length) waveTimer = setTimeout(renderWaves, 1000);
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
async function waveAt(targetPeerId, anchor) {
|
|
2210
|
-
try {
|
|
2211
|
-
const active = await ensureRoom();
|
|
2212
|
-
await active.wave("peer:" + myPeerId, targetPeerId ? "peer:" + targetPeerId : null);
|
|
2213
|
-
const audience = targetPeerId ? (active.displayNameFor(targetPeerId) || "one node") : "everyone";
|
|
2214
|
-
noteTape("note", "facts", "waved", audience);
|
|
2215
|
-
renderNodes();
|
|
2216
|
-
renderWaves();
|
|
2217
|
-
if (anchor) flashTip(anchor, "you waved at " + audience);
|
|
2218
|
-
} catch (err) {
|
|
2219
|
-
addSystemLine("couldn't wave (" + (err && err.message ? err.message : err) + ").");
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
function waveNow() { return waveAt(null, null); }
|
|
2223
|
-
|
|
2224
|
-
// ---- the overlay itself: the lights go down, the handshake takes the room
|
|
2225
|
-
function openNetPanel() {
|
|
2226
|
-
netPanelEl.hidden = false;
|
|
2227
|
-
const card = netPanelEl.querySelector(".so-card");
|
|
2228
|
-
if (card) card.focus({ preventScroll: true });
|
|
2229
|
-
}
|
|
2230
|
-
function closeNetPanel() {
|
|
2231
|
-
netPanelEl.hidden = true;
|
|
2232
|
-
}
|
|
2233
|
-
el("netPanelClose").addEventListener("click", closeNetPanel);
|
|
2234
|
-
netPanelEl.addEventListener("click", function (e) {
|
|
2235
|
-
if (e.target === netPanelEl) closeNetPanel();
|
|
2236
|
-
});
|
|
2237
|
-
document.addEventListener("keydown", function (e) {
|
|
2238
|
-
if (e.key === "Escape" && !netPanelEl.hidden) closeNetPanel();
|
|
2239
|
-
});
|
|
2240
|
-
statePillEl.addEventListener("click", function () {
|
|
2241
|
-
if (netPanelEl.hidden) openNetPanel();
|
|
2242
|
-
else closeNetPanel();
|
|
2243
|
-
});
|
|
2244
|
-
if (isProbablyMobile()) netPanelEl.classList.add("so-mobile");
|
|
2245
|
-
// The blobs are made to be copied whole — a click selects everything, so a
|
|
2246
|
-
// manual copy can never take half a code.
|
|
2247
|
-
for (const boxId of ["shareLink", "replyOut", "joinReply"]) {
|
|
2248
|
-
el(boxId).addEventListener("focus", function (e) { e.target.select(); });
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
function renderInviteShare() {
|
|
2252
|
-
const factsPart = lastStatsTotal === null ? "" : " \\u00b7 " + lastStatsTotal.toLocaleString() + " facts travel when they join";
|
|
2253
|
-
inviteSummaryEl.textContent = "invites one person into \\u201c" + worldName + "\\u201d" + factsPart;
|
|
2254
|
-
const message = shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" });
|
|
2255
|
-
waShareBtnEl.href = whatsAppShareUrl(message);
|
|
2256
|
-
webShareBtnEl.hidden = !navigator.share;
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
function renderReplyShare() {
|
|
2260
|
-
const message = replyMessageFor({ worldName: worldName, blob: replyOutEl.value });
|
|
2261
|
-
replyWaBtnEl.href = whatsAppShareUrl(message);
|
|
2262
|
-
joinWaBtnEl.href = whatsAppShareUrl(message);
|
|
2263
|
-
replyShareBtnEl.hidden = !navigator.share;
|
|
2264
|
-
joinShareBtnEl.hidden = !navigator.share;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
async function mintInvite(anchor) {
|
|
2268
|
-
try {
|
|
2269
|
-
const active = await ensureRoom();
|
|
2270
|
-
const minted = await active.startSharing();
|
|
2271
|
-
mintedBlob = minted.blob;
|
|
2272
|
-
shareLinkEl.value = inviteLinkFor(window.location.href, { blob: minted.blob, world: worldId, worldName: worldName });
|
|
2273
|
-
setOverlayRole("sponsor", "");
|
|
2274
|
-
renderInviteShare();
|
|
2275
|
-
replyProblemEl.hidden = true;
|
|
2276
|
-
renderWire();
|
|
2277
|
-
openNetPanel();
|
|
2278
|
-
await copyText(shareLinkEl.value);
|
|
2279
|
-
flashTip(anchor, "link copied — send it to one person");
|
|
2280
|
-
} catch (err) {
|
|
2281
|
-
addSystemLine("couldn't create an invite (" + (err && err.message ? err.message : err) + ").");
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
shareBtn.addEventListener("click", async function () {
|
|
2286
|
-
shareBtn.disabled = true;
|
|
2287
|
-
try {
|
|
2288
|
-
await mintInvite(shareBtn);
|
|
2289
|
-
} finally {
|
|
2290
|
-
shareBtn.disabled = false;
|
|
2291
|
-
}
|
|
2292
|
-
});
|
|
2293
|
-
for (const mintId of ["mintInviteBtn", "remintBtn"]) {
|
|
2294
|
-
el(mintId).addEventListener("click", async function () {
|
|
2295
|
-
const btn = el(mintId);
|
|
2296
|
-
btn.disabled = true;
|
|
2297
|
-
try {
|
|
2298
|
-
await mintInvite(btn);
|
|
2299
|
-
} finally {
|
|
2300
|
-
btn.disabled = false;
|
|
2301
|
-
}
|
|
2302
|
-
});
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
el("copyLinkBtn").addEventListener("click", async function () {
|
|
2306
|
-
await copyText(shareLinkEl.value);
|
|
2307
|
-
flashTip(el("copyLinkBtn"), "link copied — send it to one person");
|
|
2308
|
-
});
|
|
2309
|
-
el("copyCodeBtn").addEventListener("click", async function () {
|
|
2310
|
-
await copyText(mintedBlob);
|
|
2311
|
-
flashTip(el("copyCodeBtn"), "code copied — they paste it under step 3 on their page");
|
|
2312
|
-
});
|
|
2313
|
-
webShareBtnEl.addEventListener("click", function () {
|
|
2314
|
-
navigator.share({
|
|
2315
|
-
title: "join \\u201c" + worldName + "\\u201d",
|
|
2316
|
-
text: shareMessageFor({ worldName: worldName, link: shareLinkEl.value, thing: "graph" }),
|
|
2317
|
-
}).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
|
|
2318
|
-
});
|
|
2319
|
-
function shareReplyViaSheet() {
|
|
2320
|
-
navigator.share({
|
|
2321
|
-
title: "my reply to \\u201c" + worldName + "\\u201d",
|
|
2322
|
-
text: replyMessageFor({ worldName: worldName, blob: replyOutEl.value }),
|
|
2323
|
-
}).catch(function () { /* the sheet was closed — the copy buttons still stand */ });
|
|
2324
|
-
}
|
|
2325
|
-
replyShareBtnEl.addEventListener("click", shareReplyViaSheet);
|
|
2326
|
-
joinShareBtnEl.addEventListener("click", shareReplyViaSheet);
|
|
2327
|
-
|
|
2328
|
-
// The other way in: an invite that arrived as text rather than as a link.
|
|
2329
|
-
// The same paste box reads a whole link or a bare code — telling them
|
|
2330
|
-
// apart by looking is cheaper than asking anyone to.
|
|
2331
|
-
el("inviteBtn").addEventListener("click", async function () {
|
|
2332
|
-
const problem = el("inviteProblem");
|
|
2333
|
-
const pasted = el("inviteBox").value.trim();
|
|
2334
|
-
if (!pasted) {
|
|
2335
|
-
problem.textContent = "paste the invite you were sent, then try again";
|
|
2336
|
-
problem.hidden = false;
|
|
2337
|
-
return;
|
|
2338
|
-
}
|
|
2339
|
-
if (room) {
|
|
2340
|
-
problem.textContent = "this graph is already shared. reload the page first, then open the invite.";
|
|
2341
|
-
problem.hidden = false;
|
|
2342
|
-
return;
|
|
2343
|
-
}
|
|
2344
|
-
const blob = offerBlobIn(pasted);
|
|
2345
|
-
const mod = await loadP2p();
|
|
2346
|
-
const decoded = mod.decodeInviteBlob(blob);
|
|
2347
|
-
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2348
|
-
problem.textContent = decoded.error
|
|
2349
|
-
? "this invite looks cut short — ask for it to be sent again"
|
|
2350
|
-
: "that's a reply, not an invite — it belongs under step 5, on the page that sent the invite";
|
|
2351
|
-
problem.hidden = false;
|
|
2352
|
-
return;
|
|
2353
|
-
}
|
|
2354
|
-
// The world id has to be the invite's before the room is opened: a room
|
|
2355
|
-
// that minted its own would refuse the invite as belonging elsewhere.
|
|
2356
|
-
await ensureIdentity();
|
|
2357
|
-
worldId = decoded.value.world || worldId;
|
|
2358
|
-
if (decoded.value.worldName) {
|
|
2359
|
-
worldName = decoded.value.worldName;
|
|
2360
|
-
worldNameInputEl.value = worldName;
|
|
2361
|
-
}
|
|
2362
|
-
try {
|
|
2363
|
-
const active = await ensureRoom();
|
|
2364
|
-
const outcome = await active.acceptInvite(blob);
|
|
2365
|
-
if (outcome && outcome.error) {
|
|
2366
|
-
problem.textContent = outcome.message;
|
|
2367
|
-
problem.hidden = false;
|
|
2368
|
-
noteTape("note", "fault", "invite rejected", outcome.error);
|
|
2369
|
-
return;
|
|
2370
|
-
}
|
|
2371
|
-
problem.hidden = true;
|
|
2372
|
-
el("inviteBox").value = "";
|
|
2373
|
-
replyOutEl.value = outcome.blob;
|
|
2374
|
-
setOverlayRole("joiner", "paste");
|
|
2375
|
-
renderReplyShare();
|
|
2376
|
-
renderWire();
|
|
2377
|
-
await copyText(outcome.blob);
|
|
2378
|
-
flashTip(copyReplyBtn, "reply copied — send it back the same way");
|
|
2379
|
-
} catch (err) {
|
|
2380
|
-
problem.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2381
|
-
problem.hidden = false;
|
|
2382
|
-
}
|
|
2383
|
-
});
|
|
2384
|
-
|
|
2385
|
-
el("waveAllBtn").addEventListener("click", function () { waveAt(null, el("waveAllBtn")); });
|
|
2386
|
-
|
|
2387
|
-
// The inviter's page has exactly one paste target, so there is no wrong box
|
|
2388
|
-
// to choose. A rejected paste keeps its text so the copy can be fixed.
|
|
2389
|
-
el("replyBtn").addEventListener("click", async function () {
|
|
2390
|
-
let active = room;
|
|
2391
|
-
if (!active) {
|
|
2392
|
-
try { active = await ensureRoom(); } catch { return; }
|
|
2393
|
-
}
|
|
2394
|
-
const outcome = await active.completeInvite(replyBoxEl.value);
|
|
2395
|
-
if (outcome && outcome.error) {
|
|
2396
|
-
replyProblemEl.textContent = outcome.message;
|
|
2397
|
-
replyProblemEl.hidden = false;
|
|
2398
|
-
noteTape("note", "fault", "reply rejected", outcome.error);
|
|
2399
|
-
return;
|
|
2400
|
-
}
|
|
2401
|
-
replyProblemEl.hidden = true;
|
|
2402
|
-
replyBoxEl.value = "";
|
|
2403
|
-
// The box stays open on purpose. If two people opened the same link, the
|
|
2404
|
-
// second reply still arrives, and it needs somewhere to land so the page
|
|
2405
|
-
// can say the invite has already been used rather than swallowing it.
|
|
2406
|
-
renderWire();
|
|
2407
|
-
});
|
|
2408
|
-
|
|
2409
|
-
copyReplyBtn.addEventListener("click", async function () {
|
|
2410
|
-
await copyText(replyOutEl.value);
|
|
2411
|
-
flashTip(copyReplyBtn, "reply copied");
|
|
2412
|
-
});
|
|
2413
|
-
|
|
2414
|
-
function commitNodeName() {
|
|
2415
|
-
const name = nodeNameInputEl.value.trim();
|
|
2416
|
-
if (!name || name === myDisplayName) {
|
|
2417
|
-
nodeNameInputEl.value = myDisplayName;
|
|
2418
|
-
return;
|
|
2419
|
-
}
|
|
2420
|
-
myDisplayName = name;
|
|
2421
|
-
writeStoredNodeName(name);
|
|
2422
|
-
if (!room) { renderNodes(); return; }
|
|
2423
|
-
room.setMyDisplayName(name)
|
|
2424
|
-
.then(function () { noteTape("note", "facts", "renamed", name); renderNodes(); })
|
|
2425
|
-
.catch(function () { /* the name still holds locally; the next broadcast carries it */ });
|
|
2426
|
-
}
|
|
2427
|
-
nodeNameInputEl.addEventListener("change", commitNodeName);
|
|
2428
|
-
worldNameInputEl.addEventListener("change", function () {
|
|
2429
|
-
if (worldNameInputEl.readOnly) return;
|
|
2430
|
-
const name = worldNameInputEl.value.trim();
|
|
2431
|
-
if (name) worldName = name;
|
|
2432
|
-
worldNameInputEl.value = worldName;
|
|
2433
|
-
});
|
|
2434
|
-
|
|
2435
|
-
waveBtn.addEventListener("click", waveNow);
|
|
2436
|
-
|
|
2437
|
-
// ---- joining: the hero card, one button, nothing running until pressed --
|
|
2438
|
-
async function prepareJoinCard() {
|
|
2439
|
-
setOverlayRole("joiner", "link");
|
|
2440
|
-
joinCardEl.hidden = false;
|
|
2441
|
-
joinWorldEl.textContent = invite.worldName || "a shared graph";
|
|
2442
|
-
renderSteps();
|
|
2443
|
-
openNetPanel();
|
|
2444
|
-
joinBtn.focus();
|
|
2445
|
-
const mod = await loadP2p();
|
|
2446
|
-
const decoded = mod.decodeInviteBlob(invite.offer);
|
|
2447
|
-
if (decoded.error || decoded.value.kind !== "offer") {
|
|
2448
|
-
joinBtn.hidden = true;
|
|
2449
|
-
joinEyebrowEl.textContent = "this link didn't arrive in one piece";
|
|
2450
|
-
joinWorldEl.textContent = invite.worldName || "an invitation";
|
|
2451
|
-
joinBodyEl.textContent = decoded.error
|
|
2452
|
-
? "Part of the link was lost on the way here. Ask for it to be sent again, and check the whole thing travels."
|
|
2453
|
-
: "That link carries a reply rather than an invite. It belongs in the box on the page that sent the invite.";
|
|
2454
|
-
joinDismissBtn.textContent = "start talking on your own";
|
|
2455
|
-
return;
|
|
2456
|
-
}
|
|
2457
|
-
if (decoded.value.world) invite.world = decoded.value.world;
|
|
2458
|
-
if (decoded.value.worldName) {
|
|
2459
|
-
invite.worldName = decoded.value.worldName;
|
|
2460
|
-
joinWorldEl.textContent = decoded.value.worldName;
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
joinBtn.addEventListener("click", async function () {
|
|
2465
|
-
joinBtn.disabled = true;
|
|
2466
|
-
try {
|
|
2467
|
-
const active = await ensureRoom();
|
|
2468
|
-
const outcome = await active.acceptInvite(invite.offer);
|
|
2469
|
-
if (outcome && outcome.error) {
|
|
2470
|
-
joinProblemEl.textContent = outcome.message;
|
|
2471
|
-
joinProblemEl.hidden = false;
|
|
2472
|
-
noteTape("note", "fault", "invite rejected", outcome.error);
|
|
2473
|
-
joinBtn.disabled = false;
|
|
2474
|
-
joinBtn.textContent = "try again";
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
|
-
joinProblemEl.hidden = true;
|
|
2478
|
-
joinReplyEl.value = outcome.blob;
|
|
2479
|
-
replyOutEl.value = outcome.blob;
|
|
2480
|
-
joinReplyWrapEl.hidden = false;
|
|
2481
|
-
joinBtn.textContent = "reply created";
|
|
2482
|
-
joinDismissBtn.textContent = "close this and start talking";
|
|
2483
|
-
renderReplyShare();
|
|
2484
|
-
renderWire();
|
|
2485
|
-
await copyText(outcome.blob);
|
|
2486
|
-
flashTip(joinCopyBtn, "reply copied — send it back the same way");
|
|
2487
|
-
} catch (err) {
|
|
2488
|
-
joinProblemEl.textContent = "couldn't make a reply (" + (err && err.message ? err.message : err) + ").";
|
|
2489
|
-
joinProblemEl.hidden = false;
|
|
2490
|
-
joinBtn.disabled = false;
|
|
2491
|
-
}
|
|
2492
|
-
});
|
|
2493
|
-
|
|
2494
|
-
joinCopyBtn.addEventListener("click", async function () {
|
|
2495
|
-
await copyText(joinReplyEl.value);
|
|
2496
|
-
flashTip(joinCopyBtn, "reply copied");
|
|
2497
|
-
});
|
|
2498
|
-
joinDismissBtn.addEventListener("click", closeNetPanel);
|
|
2499
|
-
|
|
2500
|
-
renderWire();
|
|
2501
|
-
renderTapeMeter();
|
|
2502
|
-
if (invite) {
|
|
2503
|
-
prepareJoinCard().catch(function (err) {
|
|
2504
|
-
joinProblemEl.textContent = "the networking asset didn't load (" + (err && err.message ? err.message : err) + ").";
|
|
2505
|
-
joinProblemEl.hidden = false;
|
|
2506
|
-
joinBtn.hidden = true;
|
|
2507
|
-
});
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
1415
|
async function boot() {
|
|
2511
1416
|
if (!window.tmct) {
|
|
2512
1417
|
statusEl.textContent = "the chat engine didn't load \\u2014 this page needs its build step (npm run demo:build)";
|
|
@@ -2622,10 +1527,6 @@ ${shareOverlayHtml({ withTape: true })}
|
|
|
2622
1527
|
renderStatus();
|
|
2623
1528
|
setBusy(false);
|
|
2624
1529
|
inputEl.focus();
|
|
2625
|
-
// Off the boot path on purpose: this fetches the shared P2P asset so the
|
|
2626
|
-
// rail can show a real node name and world name before anyone clicks
|
|
2627
|
-
// anything. A failure here costs sharing, never the chat.
|
|
2628
|
-
ensureIdentity().then(renderNodes).catch(function () { /* the tape already carries the reason */ });
|
|
2629
1530
|
}
|
|
2630
1531
|
|
|
2631
1532
|
window.tmctChatReady = boot().catch((err) => {
|