@parley-im/parley 0.2.12 → 0.2.14
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 +1 -1
- package/dist/account.js +6 -4
- package/dist/api.js +1 -0
- package/dist/relay.js +28 -0
- package/dist/server.js +10 -1
- package/package.json +1 -1
- package/web/app.css +21 -0
- package/web/chat.js +1 -1
- package/web/dashboard.js +73 -4
- package/web/home.js +11 -0
- package/web/panel.js +9 -0
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ guest's browser ──wss──▶ parley.im (Cloudflare Worker) ◀──wss─
|
|
|
55
55
|
|
|
56
56
|
Each session has a random 256-bit key, kept in your parley database and in the link after the `#`, which browsers never send to a server. Every request, reply, live update and screenshot is sealed with AES-256-GCM (fresh IV each time) and bound to its session and direction, so parley.im can't read or alter anything, or move it between sessions. Your computer refuses requests older than five minutes or seen before, so replays go nowhere. `src/seal.ts` and `web/seal.js` are the two halves; the tests check they read each other.
|
|
57
57
|
|
|
58
|
-
What it doesn't hide, said plainly on the guest's info sheet: parley.im knows who sent a link, the project name, and when messages pass; the page's code comes from parley.im, so the promise relies on parley.im serving it honestly (a strict Content-Security-Policy means the key can only ever go back to parley.im); and Claude or Codex reads the conversation to reply. Anyone with the whole link can read and join the chat.
|
|
58
|
+
What it doesn't hide, said plainly on the guest's info sheet: parley.im knows who sent a link, the project name, which agent is answering and how many replies it has given, and when messages pass; the page's code comes from parley.im, so the promise relies on parley.im serving it honestly (a strict Content-Security-Policy means the key can only ever go back to parley.im); and Claude or Codex reads the conversation to reply. Anyone with the whole link can read and join the chat.
|
|
59
59
|
|
|
60
60
|
## What the guest gets
|
|
61
61
|
|
package/dist/account.js
CHANGED
|
@@ -32,9 +32,11 @@ async function call(creds, path, init = {}) {
|
|
|
32
32
|
signal: AbortSignal.timeout(15_000),
|
|
33
33
|
});
|
|
34
34
|
if (res.status === 401)
|
|
35
|
-
throw Object.assign(new Error("This computer isn't linked to your
|
|
36
|
-
if (!res.ok)
|
|
37
|
-
|
|
35
|
+
throw Object.assign(new Error("This computer isn't linked to your Parley account anymore. Run parley login."), { unlinked: true });
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = (await res.json().catch(() => null));
|
|
38
|
+
throw Object.assign(new Error(body?.message ?? `Parley's servers answered ${res.status}`), { status: res.status, code: body?.error });
|
|
39
|
+
}
|
|
38
40
|
return res.json();
|
|
39
41
|
}
|
|
40
42
|
// What parley.im is told about a session: its id and a title for link
|
|
@@ -74,7 +76,7 @@ export async function login(config, show) {
|
|
|
74
76
|
}
|
|
75
77
|
// Signed in on parley.im, but not let in yet: no computer links until then.
|
|
76
78
|
if (poll.status === "waitlisted") {
|
|
77
|
-
throw new Error(`You're on the
|
|
79
|
+
throw new Error(`You're on the Parley waitlist as ${poll.email}. You'll get an email when you're let in; then run parley login again.`);
|
|
78
80
|
}
|
|
79
81
|
if (poll.status === "expired")
|
|
80
82
|
break;
|
package/dist/api.js
CHANGED
package/dist/relay.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Problem, MAX_IMAGES } from "./api.js";
|
|
|
6
6
|
import { readCredentials, relay as registry } from "./account.js";
|
|
7
7
|
import { seal, open, sealBytes, openBytes } from "./seal.js";
|
|
8
8
|
import { VERSION } from "./version.js";
|
|
9
|
+
import { modelLabel } from "./models.js";
|
|
9
10
|
const IMAGE_TYPES = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
|
|
10
11
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
11
12
|
const MAX_SEALED_BYTES = 11 * 1024 * 1024;
|
|
@@ -89,6 +90,8 @@ export class RelayLink {
|
|
|
89
90
|
catch (error) {
|
|
90
91
|
console.error(`parley.im sync failed: ${error.message}`);
|
|
91
92
|
}
|
|
93
|
+
this.told = "";
|
|
94
|
+
this.tellDashboard();
|
|
92
95
|
};
|
|
93
96
|
ws.onmessage = (event) => {
|
|
94
97
|
this.onFrame(String(event.data)).catch((error) => console.error("relay frame:", error));
|
|
@@ -303,7 +306,32 @@ export class RelayLink {
|
|
|
303
306
|
this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
|
|
304
307
|
answer({});
|
|
305
308
|
}
|
|
309
|
+
// For your dashboard on parley.im: which agent runs each open session and
|
|
310
|
+
// how many of its replies it has used, and the port this computer's control
|
|
311
|
+
// panel listens on. Never the guest's name, the brief or anything said.
|
|
312
|
+
told = "";
|
|
313
|
+
telling = null;
|
|
314
|
+
tellDashboard() {
|
|
315
|
+
if (this.telling)
|
|
316
|
+
return;
|
|
317
|
+
this.telling = setTimeout(() => {
|
|
318
|
+
this.telling = null;
|
|
319
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
320
|
+
return;
|
|
321
|
+
const sessions = {};
|
|
322
|
+
for (const s of this.store.sessions({ status: "open" })) {
|
|
323
|
+
sessions[s.token] = { agent: s.agent, model: modelLabel(this.store.latestModel(s.id) ?? s.model), used: this.store.repliesUsed(s.id), limit: s.reply_limit };
|
|
324
|
+
}
|
|
325
|
+
const frame = JSON.stringify({ t: "meta", panel: this.config.dashboardPort, sessions });
|
|
326
|
+
if (frame === this.told)
|
|
327
|
+
return;
|
|
328
|
+
this.told = frame;
|
|
329
|
+
this.ws.send(frame);
|
|
330
|
+
}, 1000);
|
|
331
|
+
}
|
|
306
332
|
forward(sessionId, entry) {
|
|
333
|
+
if (entry.event.type === "replies" || entry.event.type === "ended")
|
|
334
|
+
this.tellDashboard();
|
|
307
335
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
308
336
|
return;
|
|
309
337
|
const session = this.store.session(sessionId);
|
package/dist/server.js
CHANGED
|
@@ -105,6 +105,12 @@ export async function serve() {
|
|
|
105
105
|
return problem(res, 400, "invalid", error.message);
|
|
106
106
|
}
|
|
107
107
|
const shared = await share(session);
|
|
108
|
+
// Parley's servers refused it for good (the account is at its limit
|
|
109
|
+
// of open sessions), so it isn't kept here either.
|
|
110
|
+
if (typeof shared === "string") {
|
|
111
|
+
store.deleteSession(session.id);
|
|
112
|
+
return problem(res, 429, "too_many", shared);
|
|
113
|
+
}
|
|
108
114
|
return json(res, { ...api.ownerView(session), session, shared });
|
|
109
115
|
}
|
|
110
116
|
const action = url.pathname.match(/^\/api\/sessions\/([a-z0-9]+)\/(end|delete|limit)$/);
|
|
@@ -137,17 +143,20 @@ export async function serve() {
|
|
|
137
143
|
return handleSession(req, res, { session, base: `/o/${m[1]}`, owner: true, images: "url" }, m[2] ?? "/", url);
|
|
138
144
|
}
|
|
139
145
|
// Registers a new session with parley.im so its link works there.
|
|
146
|
+
// True once parley.im has it; false if it couldn't be reached (the next
|
|
147
|
+
// connection's sync catches up); the reason if it refused for good.
|
|
140
148
|
async function share(session) {
|
|
141
149
|
const creds = readCredentials(config);
|
|
142
150
|
if (!creds)
|
|
143
151
|
return false;
|
|
144
152
|
try {
|
|
145
153
|
await registry.register(creds, session);
|
|
154
|
+
link.tellDashboard();
|
|
146
155
|
return true;
|
|
147
156
|
}
|
|
148
157
|
catch (error) {
|
|
149
158
|
console.error(`parley.im didn't take ${session.id}: ${error.message}`);
|
|
150
|
-
return false;
|
|
159
|
+
return error.code === "too_many" ? error.message : false;
|
|
151
160
|
}
|
|
152
161
|
}
|
|
153
162
|
async function handleSession(req, res, scope, rest, url) {
|
package/package.json
CHANGED
package/web/app.css
CHANGED
|
@@ -513,6 +513,9 @@ span.live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px
|
|
|
513
513
|
.docs .flow { grid-template-columns: 1fr; gap: 26px; }
|
|
514
514
|
.docs .flow div + div::before { left: 50%; top: -21px; transform: translateX(-50%) rotate(90deg); }
|
|
515
515
|
}
|
|
516
|
+
/* Admin: letting people in a batch at a time. */
|
|
517
|
+
.let-in-next { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 6px; font-size: 14px; }
|
|
518
|
+
.let-in-next .search { flex: none; width: 76px; }
|
|
516
519
|
/* Admin: page views and where visitors came from. */
|
|
517
520
|
.table-wrap { overflow-x: auto; }
|
|
518
521
|
table.views { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
@@ -523,6 +526,16 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
|
|
|
523
526
|
.referrers { list-style: none; padding: 0; margin: 0 0 40px; max-width: 420px; }
|
|
524
527
|
.referrers li { display: flex; justify-content: space-between; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
|
|
525
528
|
.trow.site-row { grid-template-columns: 1fr auto; }
|
|
529
|
+
.dash-hero { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; }
|
|
530
|
+
.dash-hero h1 { margin-bottom: 4px; }
|
|
531
|
+
.dash-hero p { margin: 0; }
|
|
532
|
+
.dash .stats { margin: 24px 0 8px; }
|
|
533
|
+
.dash .stat .value { white-space: nowrap; }
|
|
534
|
+
.dash .row-actions { align-items: center; }
|
|
535
|
+
.dash .row-actions .live { margin-right: 6px; }
|
|
536
|
+
.dash .update-note { font-size: 12.5px; color: var(--warn); margin-top: 4px; }
|
|
537
|
+
.dash .empty q { color: var(--text); }
|
|
538
|
+
.dash .setup { margin-top: 24px; }
|
|
526
539
|
/* Long details shorten instead of pushing the buttons off a phone screen. */
|
|
527
540
|
.trow.site-row .who-cell { min-width: 0; }
|
|
528
541
|
|
|
@@ -579,6 +592,14 @@ table.views tfoot td { font-weight: 600; border-bottom: 0; }
|
|
|
579
592
|
.how h2 { font-size: 20px; margin: 0; }
|
|
580
593
|
.how p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
|
|
581
594
|
.how q { color: var(--text); }
|
|
595
|
+
.setup { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); margin: 16px 0 0; }
|
|
596
|
+
.setup > div { border: 1px solid var(--line); border-radius: 16px; padding: 22px; background: var(--panel); display: grid; gap: 12px; align-content: start; min-width: 0; }
|
|
597
|
+
.setup h2 { font-size: 20px; margin: 0; }
|
|
598
|
+
.setup p { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted); }
|
|
599
|
+
.setup .paste { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg); padding: 10px 10px 10px 14px; }
|
|
600
|
+
.setup .paste code { flex: 1 1 230px; min-width: 0; font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: break-word; }
|
|
601
|
+
.setup .paste .btn { flex: none; height: 32px; padding: 0 12px; font-size: 13px; }
|
|
602
|
+
.setup pre { margin: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); padding: 12px 14px; font-size: 13.5px; line-height: 1.7; overflow-x: auto; color: var(--text); }
|
|
582
603
|
.promises { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 28px; margin: 40px 0 0; padding: 32px 0 48px; border-top: 1px solid var(--line-strong); }
|
|
583
604
|
.promises h3 { font-family: var(--sans); font-size: 16px; letter-spacing: 0; margin: 0 0 6px; }
|
|
584
605
|
.promises p { font-size: 15px; line-height: 1.55; color: var(--muted); margin: 0; }
|
package/web/chat.js
CHANGED
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
? el(
|
|
368
368
|
"p",
|
|
369
369
|
{},
|
|
370
|
-
`Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. Parley passes them along but can't read them; it knows who sent this link, the project's name and when messages go by, and counts how many times pages are opened each day. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from Parley's servers, so that promise relies on Parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
|
|
370
|
+
`Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. Parley passes them along but can't read them; it knows who sent this link, the project's name, which agent is answering and how many replies it has given, and when messages go by, and counts how many times pages are opened each day. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from Parley's servers, so that promise relies on Parley serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
|
|
371
371
|
)
|
|
372
372
|
: null,
|
|
373
373
|
el("p", {}, `When the replies run out, the chat pauses until ${whom} adds more. Replies come from ${theirs} computer, so it needs to be on, and the link stops working for good once the chat ends.`),
|
package/web/dashboard.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// Keeps the parley.im dashboard current: who's on each session's page right
|
|
2
|
-
// now, when they were last active,
|
|
2
|
+
// now, when they were last active, whether a reply is coming in, and what
|
|
3
|
+
// each computer says about its sessions (which agent, replies used). Each
|
|
3
4
|
// linked computer pushes news over its own WebSocket, which costs nothing
|
|
4
5
|
// while it's quiet, so nothing here polls. It never shows what was said;
|
|
5
6
|
// parley.im doesn't have that.
|
|
6
7
|
(() => {
|
|
7
8
|
const REPLYING_MS = 6000;
|
|
8
|
-
const
|
|
9
|
+
const PANEL = 4748;
|
|
10
|
+
const live = new Map(); // session id -> { here, active, replying, online, agent, model, used, limit }
|
|
11
|
+
const computers = new Map(); // computer id -> { on, version }
|
|
12
|
+
const latest = document.querySelector(".dash")?.dataset.latest || "";
|
|
9
13
|
|
|
10
14
|
const ago = (ms) => {
|
|
11
15
|
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
@@ -14,25 +18,67 @@
|
|
|
14
18
|
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
15
19
|
return `${Math.round(s / 86400)}d ago`;
|
|
16
20
|
};
|
|
21
|
+
const newer = (a, b) => {
|
|
22
|
+
const pa = a.split(".").map(Number);
|
|
23
|
+
const pb = b.split(".").map(Number);
|
|
24
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
25
|
+
return false;
|
|
26
|
+
};
|
|
17
27
|
const dot = (kind, text) => {
|
|
18
28
|
const span = document.createElement("span");
|
|
19
29
|
span.className = `dot${kind ? ` ${kind}` : ""}`;
|
|
20
30
|
return [span, document.createTextNode(text)];
|
|
21
31
|
};
|
|
32
|
+
const replyingNow = (s) => s.replying && Date.now() - s.replying < REPLYING_MS;
|
|
33
|
+
const outOfReplies = (s) => s.limit && s.used != null && s.used >= s.limit;
|
|
22
34
|
// Matches live() in relay/src/pages.ts.
|
|
23
35
|
const liveNodes = (s) => {
|
|
24
36
|
if (s.online === false) return dot("off", "computer offline");
|
|
25
|
-
if (
|
|
37
|
+
if (replyingNow(s)) return dot("busy", "replying");
|
|
38
|
+
if (outOfReplies(s)) return dot("warn", "out of replies");
|
|
26
39
|
if (s.here) return dot("on", s.here > 1 ? `${s.here} people here` : "here now");
|
|
27
40
|
if (s.active) return dot("", `active ${ago(s.active)}`);
|
|
28
41
|
return dot("", "not opened yet");
|
|
29
42
|
};
|
|
43
|
+
// Matches about() in relay/src/pages.ts.
|
|
44
|
+
const about = (s) => (s.agent ? `${s.agent === "codex" ? "Codex" : "Claude"}${s.model ? ` ${s.model}` : ""} · ${s.used}/${s.limit} replies · ` : "");
|
|
45
|
+
|
|
46
|
+
function setStat(key, value, note) {
|
|
47
|
+
const v = document.querySelector(`[data-stat="${key}"]`);
|
|
48
|
+
const n = document.querySelector(`[data-note="${key}"]`);
|
|
49
|
+
if (v) v.textContent = value;
|
|
50
|
+
if (n) n.textContent = note;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The numbers at the top, from the same news as the rows.
|
|
54
|
+
function paintStats() {
|
|
55
|
+
const rows = [...document.querySelectorAll("[data-session]")].map((row) => live.get(row.dataset.session)).filter(Boolean);
|
|
56
|
+
if (!rows.length && !computers.size) return;
|
|
57
|
+
const replying = rows.filter(replyingNow).length;
|
|
58
|
+
const out = rows.filter(outOfReplies).length;
|
|
59
|
+
const open = document.querySelectorAll("[data-session]").length;
|
|
60
|
+
setStat("open", String(open), replying ? `${replying} replying now` : out ? `${out} out of replies` : open ? "links are live" : "none yet");
|
|
61
|
+
const here = rows.reduce((n, s) => n + s.here, 0);
|
|
62
|
+
const busy = rows.filter((s) => s.here).length;
|
|
63
|
+
const last = Math.max(0, ...rows.map((s) => s.active ?? 0));
|
|
64
|
+
setStat("here", String(here), busy ? `on ${busy === 1 ? "one session" : `${busy} sessions`}` : last ? `last active ${ago(last)}` : "no one yet");
|
|
65
|
+
const told = rows.filter((s) => s.used != null);
|
|
66
|
+
if (told.length) setStat("replies", String(told.reduce((n, s) => n + s.used, 0)), `of ${told.reduce((n, s) => n + s.limit, 0)} allowed`);
|
|
67
|
+
const all = document.querySelectorAll("[data-computer]");
|
|
68
|
+
const on = [...computers.values()].filter((c) => c.on);
|
|
69
|
+
const outdated = [...all].some((row) => latest && row.dataset.version && newer(latest, row.dataset.version));
|
|
70
|
+
setStat("computers", `${on.length} of ${all.length}`, outdated ? "an update is out" : on[0]?.version ? `Parley ${on[0].version}` : on.length ? "connected" : "none connected");
|
|
71
|
+
}
|
|
30
72
|
|
|
31
73
|
function paint() {
|
|
32
74
|
for (const row of document.querySelectorAll("[data-session]")) {
|
|
33
75
|
const s = live.get(row.dataset.session);
|
|
34
|
-
if (s)
|
|
76
|
+
if (!s) continue;
|
|
77
|
+
row.querySelector(".live")?.replaceChildren(...liveNodes(s));
|
|
78
|
+
const text = about(s);
|
|
79
|
+
if (text) row.querySelector(".about").textContent = text;
|
|
35
80
|
}
|
|
81
|
+
paintStats();
|
|
36
82
|
}
|
|
37
83
|
|
|
38
84
|
function apply(computer, m) {
|
|
@@ -40,6 +86,7 @@
|
|
|
40
86
|
if (m.t !== "live") return;
|
|
41
87
|
// Times come from parley.im's clock; shift them onto this one.
|
|
42
88
|
const shift = Date.now() - m.now;
|
|
89
|
+
const panel = `http://localhost:${m.panel || PANEL}`;
|
|
43
90
|
for (const row of document.querySelectorAll(`[data-device="${computer}"]`)) {
|
|
44
91
|
const s = m.sessions[row.dataset.session] ?? {};
|
|
45
92
|
live.set(row.dataset.session, {
|
|
@@ -47,12 +94,22 @@
|
|
|
47
94
|
here: s.here ?? 0,
|
|
48
95
|
active: s.active ? s.active + shift : null,
|
|
49
96
|
replying: s.replying ? s.replying + shift : null,
|
|
97
|
+
agent: s.agent ?? null,
|
|
98
|
+
model: s.model ?? null,
|
|
99
|
+
used: s.used ?? null,
|
|
100
|
+
limit: s.limit ?? null,
|
|
50
101
|
});
|
|
102
|
+
const open = row.querySelector("a.btn");
|
|
103
|
+
if (open) open.href = `${panel}/#s=${encodeURIComponent(row.dataset.session)}`;
|
|
51
104
|
}
|
|
105
|
+
computers.set(computer, { on: m.on, version: m.version });
|
|
52
106
|
const row = document.querySelector(`[data-computer="${computer}"]`);
|
|
53
107
|
if (row) {
|
|
108
|
+
if (m.version) row.dataset.version = m.version;
|
|
54
109
|
row.querySelector(".dot").className = `dot${m.on ? " on" : ""}`;
|
|
55
110
|
row.querySelector(".computer-status").textContent = m.on ? `online now${m.version ? ` · Parley ${m.version}` : ""}` : "offline";
|
|
111
|
+
row.querySelector(".update-note").hidden = !(latest && row.dataset.version && newer(latest, row.dataset.version));
|
|
112
|
+
row.querySelector("a.btn").href = panel;
|
|
56
113
|
}
|
|
57
114
|
paint();
|
|
58
115
|
}
|
|
@@ -72,6 +129,18 @@
|
|
|
72
129
|
};
|
|
73
130
|
}
|
|
74
131
|
|
|
132
|
+
// "Copy" for the line people paste into their agent, before any computer
|
|
133
|
+
// is linked.
|
|
134
|
+
document.querySelectorAll("[data-copy]").forEach((button) => {
|
|
135
|
+
if (!navigator.clipboard) return;
|
|
136
|
+
button.hidden = false;
|
|
137
|
+
button.addEventListener("click", async () => {
|
|
138
|
+
await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent);
|
|
139
|
+
button.textContent = "Copied";
|
|
140
|
+
setTimeout(() => (button.textContent = "Copy"), 1500);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
75
144
|
for (const row of document.querySelectorAll("[data-computer]")) watch(row.dataset.computer);
|
|
76
145
|
// "active 3m ago" and the end of "replying" move on with the clock alone.
|
|
77
146
|
setInterval(paint, 5000);
|
package/web/home.js
CHANGED
|
@@ -139,6 +139,17 @@
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// "Copy" for the line people paste into their agent.
|
|
143
|
+
document.querySelectorAll("[data-copy]").forEach((button) => {
|
|
144
|
+
if (!navigator.clipboard) return;
|
|
145
|
+
button.hidden = false;
|
|
146
|
+
button.addEventListener("click", async () => {
|
|
147
|
+
await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent);
|
|
148
|
+
button.textContent = "Copied";
|
|
149
|
+
setTimeout(() => (button.textContent = "Copy"), 1500);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
142
153
|
tabs.forEach((tab, i) => tab.addEventListener("click", () => (still ? show(i) : loop(i, STAY))));
|
|
143
154
|
if (!still) loop(0);
|
|
144
155
|
})();
|
package/web/panel.js
CHANGED
|
@@ -30,10 +30,19 @@
|
|
|
30
30
|
return json;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// A session's "Open" on the parley.im dashboard comes here as #s=<its link id>.
|
|
34
|
+
let wanted = location.hash.match(/^#s=([A-Za-z0-9_-]+)$/)?.[1] ?? null;
|
|
35
|
+
|
|
33
36
|
async function refresh() {
|
|
34
37
|
try {
|
|
35
38
|
const res = await fetch("/api/panel", { cache: "no-store" });
|
|
36
39
|
data = await res.json();
|
|
40
|
+
if (wanted) {
|
|
41
|
+
const found = data.sessions.find((s) => s.token === wanted);
|
|
42
|
+
wanted = null;
|
|
43
|
+
history.replaceState(null, "", location.pathname);
|
|
44
|
+
if (found) return location.replace(`/o/${found.id}`);
|
|
45
|
+
}
|
|
37
46
|
render();
|
|
38
47
|
} catch {
|
|
39
48
|
$("server-dot").className = "dot off";
|