@parley-im/parley 0.1.2 → 0.2.1

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/web/chat.js CHANGED
@@ -10,7 +10,7 @@
10
10
  chip: $("chip"), infoBtn: $("info-btn"), sheet: $("sheet"), sheetBack: $("sheet-back"), sheetBody: $("sheet-body"),
11
11
  sheetClose: $("sheet-close"), app: document.querySelector(".app"),
12
12
  };
13
- if (!owner) document.body.classList.add("tester-view");
13
+ if (!owner) document.body.classList.add("guest-view");
14
14
 
15
15
  let session = null;
16
16
  let ownerName = null;
@@ -18,9 +18,11 @@
18
18
  let more = false;
19
19
  let loadingOlder = false;
20
20
  const seen = new Set();
21
+ const kept = new Map(); // id -> message, for saving a copy
21
22
  let live = null; // { el, text, steps, bodyEl, stepsEl }
22
23
  let attachments = []; // { handle, preview, uploading }
23
24
  let sending = false;
25
+ let draftId = null; // see submit()
24
26
  let ended = false;
25
27
  let offline = false;
26
28
 
@@ -109,34 +111,42 @@
109
111
  if (scroller.clientHeight !== viewHeight) viewResized();
110
112
  }).observe(scroller);
111
113
 
112
- // iOS doesn't shrink the page for the keyboard; it shrinks and scrolls the
113
- // visible area instead. While a keyboard is up, pin the app to exactly that
114
- // area (its size and its offset), or Safari shows the composer floating at
115
- // the top with empty space under it.
114
+ // Phones show the keyboard over the page and then scroll the page to keep
115
+ // the box in view, which pushes the header off the top and leaves empty
116
+ // space above the composer (Safari, and apps' in-app browsers). So the app
117
+ // is always pinned to exactly the visible area, its size and its offset,
118
+ // keyboard or not, and any scrolling of the page itself is undone. Desktop
119
+ // browsers see no difference.
116
120
  const vv = window.visualViewport;
117
121
  if (vv) {
122
+ let queued = false;
118
123
  const fit = () => {
119
- if (vv.height < window.innerHeight - 80) {
120
- Object.assign(els.app.style, {
121
- position: "fixed",
122
- left: "0",
123
- right: "0",
124
- top: "0",
125
- height: `${vv.height}px`,
126
- transform: `translateY(${vv.offsetTop}px)`,
127
- });
128
- } else els.app.removeAttribute("style");
124
+ queued = false;
125
+ Object.assign(els.app.style, {
126
+ position: "fixed",
127
+ left: "0",
128
+ right: "0",
129
+ top: "0",
130
+ height: `${vv.height}px`,
131
+ transform: vv.offsetTop ? `translateY(${vv.offsetTop}px)` : "",
132
+ });
133
+ if (window.scrollY) window.scrollTo(0, 0);
134
+ };
135
+ const soon = () => {
136
+ if (!queued) (queued = true), requestAnimationFrame(fit);
129
137
  };
138
+ fit();
130
139
  vv.addEventListener("resize", fit);
131
- vv.addEventListener("scroll", fit);
140
+ vv.addEventListener("scroll", soon);
132
141
  addEventListener("resize", fit);
142
+ addEventListener("scroll", soon, { passive: true });
133
143
  }
134
144
 
135
145
  // ---------- messages ----------
136
146
 
137
147
  function messageEl(m) {
138
148
  const time = clock(m.createdAt);
139
- const body = m.role === "tester" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
149
+ const body = m.role === "guest" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
140
150
  if (body) body.innerHTML = window.renderMarkdown(m.body);
141
151
  const shots = m.images.length
142
152
  ? el(
@@ -156,11 +166,12 @@
156
166
  )
157
167
  : null;
158
168
 
159
- if (m.role === "tester") {
160
- const who = owner ? session.tester : "You";
161
- return el("li", { className: "msg tester" }, el("div", { className: "bubble" }, body, shots), el("div", { className: "stamp", textContent: `${who} · ${time}` }));
169
+ if (m.role === "guest") {
170
+ const who = owner ? session.guest : "You";
171
+ return el("li", { className: "msg guest" }, el("div", { className: "bubble" }, body, shots), el("div", { className: "stamp", textContent: `${who} · ${time}` }));
162
172
  }
163
- if (m.role === "error") return el("li", { className: "msg error" }, body);
173
+ // Your own view says why a reply failed; guests just get the apology.
174
+ if (m.role === "error") return el("li", { className: "msg error" }, body, m.detail ? el("div", { className: "detail mono", textContent: m.detail }) : null);
164
175
  const meta = el("div", { className: "meta" }, el("span", { className: "who", textContent: agentName() }), ` · ${time}`);
165
176
  return el("li", { className: "msg agent" }, meta, body, shots, owner && m.turn ? trailEl(m.turn) : null);
166
177
  }
@@ -183,11 +194,12 @@
183
194
  function addMessage(m, { prepend = false } = {}) {
184
195
  if (seen.has(m.id)) return;
185
196
  seen.add(m.id);
197
+ kept.set(m.id, m);
186
198
  const node = messageEl(m);
187
199
  if (prepend) els.messages.prepend(node);
188
200
  else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
189
201
  else els.messages.append(node);
190
- if (m.role !== "tester" && !prepend) clearLive();
202
+ if (m.role !== "guest" && !prepend) clearLive();
191
203
  }
192
204
 
193
205
  function showLive(state) {
@@ -253,21 +265,65 @@
253
265
  els.status.hidden = false;
254
266
  let dot = "on";
255
267
  let label = owner ? `${agentName()} · live` : speaker();
256
- if (offline) (dot = "warn"), (label = "reconnecting");
257
- else if (live) (dot = "busy"), (label = owner ? `${agentName()} is replying` : "replying…");
268
+ if (offline) (dot = "off"), (label = offlineWhy === "connection" ? "reconnecting" : "offline");
269
+ // A reply in progress shows as typing dots in the conversation; the
270
+ // header stays put.
271
+ else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
258
272
  else if (!owner && repliesLeft === 0) (dot = "warn"), (label = "paused");
259
273
  else if (owner && session.status !== "open") (dot = "off"), (label = "ended");
260
274
  els.statusDot.className = `dot ${dot}`;
261
275
  els.statusText.textContent = label;
262
276
  if (!owner && repliesLeft != null) {
263
277
  els.chip.hidden = false;
264
- els.chip.textContent = `${repliesLeft} left`;
278
+ els.chip.textContent = `${repliesLeft}/${session.replyLimit}`;
265
279
  els.chip.title = `${repliesLeft} of ${session.replyLimit} replies left in this chat`;
266
280
  els.chip.className = `chip ${repliesLeft <= 10 ? "low" : ""}`;
267
281
  }
268
282
  }
269
283
 
270
- // Everything a tester might wonder about, behind the info button.
284
+ // The conversation as plain text, everything from the start, for the guest
285
+ // to keep.
286
+ async function saveCopy() {
287
+ while (more) {
288
+ const before = seen.size;
289
+ await loadOlder();
290
+ if (seen.size === before) break;
291
+ }
292
+ const projectName = session?.project ?? knownProject ?? "a project";
293
+ const lines = [`parley about ${projectName}, ${new Date().toLocaleDateString()}`, ""];
294
+ for (const m of [...kept.values()].sort((a, b) => a.id - b.id)) {
295
+ if (m.role === "error") continue;
296
+ lines.push(`${m.role === "guest" ? "You" : agentName()} · ${new Date(m.createdAt).toLocaleString()}`, m.body.trim(), "");
297
+ }
298
+ const a = el("a", { href: URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/plain" })), download: `parley-${projectName.replace(/[^\w-]+/g, "-")}.txt` });
299
+ document.body.append(a);
300
+ a.click();
301
+ a.remove();
302
+ }
303
+
304
+ // The guest is done: the link stops working for everyone, and the sender
305
+ // keeps what was said. Two taps, so it can't happen by accident.
306
+ async function endConversation(button) {
307
+ if (!button.dataset.armed) {
308
+ button.dataset.armed = "1";
309
+ button.textContent = "Tap again to end it";
310
+ return;
311
+ }
312
+ button.disabled = true;
313
+ button.textContent = "Ending…";
314
+ try {
315
+ await transport.end();
316
+ closeSheet();
317
+ showEnded();
318
+ } catch (error) {
319
+ button.disabled = false;
320
+ delete button.dataset.armed;
321
+ button.textContent = "End conversation";
322
+ banner(`Couldn't end it just now. ${error.message}`);
323
+ }
324
+ }
325
+
326
+ // Details and actions for the guest, behind the button in the header.
271
327
  function openSheet() {
272
328
  const who = sender?.name || ownerName || "The person who sent this link";
273
329
  const whom = ownerName || "them";
@@ -278,11 +334,22 @@
278
334
  const facts = [];
279
335
  if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
280
336
  if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
281
- if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft} of ${session.replyLimit}`]);
337
+ if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft}/${session.replyLimit}`]);
338
+ const save = el("button", { className: "btn", type: "button", textContent: "Save a copy" });
339
+ save.onclick = () => saveCopy().catch(() => banner("Couldn't save a copy just now."));
340
+ const end = el("button", { className: "btn danger", type: "button", textContent: "End conversation" });
341
+ end.onclick = () => endConversation(end);
342
+ const open = !ended && session?.status !== "ended";
282
343
  els.sheetBody.replaceChildren(
283
344
  el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. parley.im checked it's really them, with Google.` : "."),
284
345
  kv(facts),
285
- el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
346
+ el("div", { className: "sheet-actions" }, save, open ? end : null),
347
+ open ? el("p", { className: "fine" }, `Ending it stops this link working, for you and anyone else who has it. ${ownerName || "They"} keep${ownerName ? "s" : ""} everything said so far.`) : null,
348
+ el(
349
+ "details",
350
+ { className: "how" },
351
+ el("summary", { textContent: "How this works" }),
352
+ el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
286
353
  relay
287
354
  ? el(
288
355
  "p",
@@ -290,7 +357,8 @@
290
357
  `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. parley.im passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. 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.im, so that promise relies on parley.im serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
291
358
  )
292
359
  : null,
293
- 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 if ${whom} ends the chat.`),
360
+ 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.`),
361
+ ),
294
362
  );
295
363
  els.sheet.hidden = els.sheetBack.hidden = false;
296
364
  els.sheetClose.focus();
@@ -301,16 +369,16 @@
301
369
  els.sheetBack.addEventListener("click", closeSheet);
302
370
  addEventListener("keydown", (e) => e.key === "Escape" && closeSheet());
303
371
 
304
- // The tester's intro. On parley.im links it can show before the
372
+ // The guest's intro. On parley.im links it can show before the
305
373
  // conversation loads, from what parley.im vouches for.
306
- function paintTester() {
374
+ function paintGuest() {
307
375
  const name = ownerName || sender?.name?.split(" ")[0];
308
376
  const projectName = session?.project ?? knownProject ?? "";
309
377
  els.project.textContent = projectName;
310
378
  els.infoBtn.hidden = false;
311
379
  els.intro.hidden = false;
312
380
  const strong = (t) => el("strong", { textContent: t });
313
- const more = el("button", { type: "button", textContent: "About this chat" });
381
+ const more = el("button", { type: "button", textContent: "Details" });
314
382
  more.onclick = openSheet;
315
383
  els.intro.replaceChildren(
316
384
  name ? strong(name) : "Someone",
@@ -389,9 +457,9 @@
389
457
  el("span", { className: "slash", textContent: "/" }),
390
458
  el("a", { className: "product", href: "/", textContent: "Control panel" }),
391
459
  el("span", { className: "slash", textContent: "/" }),
392
- el("span", { className: "here", textContent: `${s.tester} on ${s.project}` }),
460
+ el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
393
461
  );
394
- document.title = `${s.tester} on ${s.project} · parley`;
462
+ document.title = `${s.guest} on ${s.project} · parley`;
395
463
  const used = s.replyLimit - s.repliesLeft;
396
464
  const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
397
465
  const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
@@ -420,14 +488,14 @@
420
488
 
421
489
  els.side.textContent = "";
422
490
  els.side.append(
423
- el("h2", { textContent: s.tester }),
491
+ el("h2", { textContent: s.guest }),
424
492
  el("div", { className: "sub", textContent: `${s.project} · ${s.agent === "codex" ? "Codex" : "Claude"} · ${open ? (s.busy ? "replying now" : "open") : "ended"}` }),
425
493
  el(
426
494
  "section",
427
495
  {},
428
496
  el("div", { className: "label", textContent: "Replies" }),
429
497
  el("div", { className: `meter ${s.repliesLeft === 0 ? "full" : ""}` }, el("span", { style: `width:${pct}%` })),
430
- el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The tester is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left` }),
498
+ el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The guest is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left` }),
431
499
  open
432
500
  ? el(
433
501
  "div",
@@ -442,15 +510,15 @@
442
510
  "section",
443
511
  {},
444
512
  kv([
445
- ["Messages from tester", String(s.stats.tester_messages)],
513
+ ["Messages from guest", String(s.stats.guest_messages)],
446
514
  ["Model", s.model ?? "known after the first reply"],
447
515
  ["Spend", s.stats.cost_usd ? `$${s.stats.cost_usd.toFixed(2)} at API prices` : s.agent === "codex" ? "not reported by Codex" : "$0.00"],
448
- ["Last heard from", ago(s.stats.last_tester_at)],
516
+ ["Last heard from", ago(s.stats.last_guest_at)],
449
517
  ["Started", ago(s.createdAt)],
450
518
  ["Session", s.id],
451
519
  ]),
452
520
  ),
453
- open ? el("section", {}, el("div", { className: "label", textContent: "Tester link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
521
+ open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
454
522
  el("section", {}, el("div", { className: "label", textContent: "Brief" }), el("div", { className: "brief-text", style: "margin-top:6px", textContent: s.brief })),
455
523
  el("section", {}, el("div", { className: "actions", style: "margin-top:0" }, kill), el("div", { className: "cell-muted", style: "margin-top:8px", textContent: open ? "Ending kills the link for good. You keep the transcript." : "Deleting erases the transcript and screenshots." })),
456
524
  );
@@ -491,6 +559,7 @@
491
559
 
492
560
  const problemError = (status, code, message) => Object.assign(new Error(message), { status, code });
493
561
  const offlineError = () => Object.assign(new Error(`${ownerLabel()[0].toUpperCase() + ownerLabel().slice(1)}'s computer is offline right now.`), { offline: true });
562
+ const connectionError = () => Object.assign(new Error("The connection dropped. Reconnecting…"), { offline: true, connection: true });
494
563
 
495
564
  function httpTransport() {
496
565
  let h;
@@ -545,8 +614,12 @@
545
614
  if (!res.ok) throw await problemFrom(res);
546
615
  return res.json();
547
616
  },
548
- async send(text, images) {
549
- const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images }) });
617
+ async end() {
618
+ const res = await fetch(`${base}/api/end`, { method: "POST" });
619
+ if (!res.ok) throw await problemFrom(res);
620
+ },
621
+ async send(text, images, cid) {
622
+ const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
550
623
  if (!res.ok) throw await problemFrom(res);
551
624
  return res.json();
552
625
  },
@@ -580,13 +653,16 @@
580
653
  bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
581
654
  };
582
655
 
583
- async function request(op, params = {}, timeoutMs = 30_000) {
656
+ async function request(op, params = {}, timeoutMs = 15_000) {
584
657
  if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
585
658
  const r = crypto.randomUUID();
586
- const d = await S.seal(key, token, "req", { op, r, at: Date.now(), ...params });
659
+ const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
587
660
  return new Promise((resolve, reject) => {
661
+ // No answer means the computer is gone, whatever parley.im last said:
662
+ // show it offline until it's heard from again.
588
663
  const timer = setTimeout(() => {
589
664
  pending.delete(r);
665
+ h.offline(true, "computer");
590
666
  reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
591
667
  }, timeoutMs);
592
668
  pending.set(r, { resolve, reject, timer });
@@ -606,7 +682,7 @@
606
682
  for (const item of early) if (item.epoch === epoch && item.seq > cursor) (cursor = item.seq), h.event(item.event);
607
683
  } catch (error) {
608
684
  if (error.code === "ended") h.ended();
609
- else if (error.offline) h.offline(true);
685
+ else if (error.offline) h.offline(true, error.connection ? "connection" : "computer");
610
686
  else if (error.code === "bad_request") h.badKey();
611
687
  } finally {
612
688
  early = [];
@@ -623,9 +699,13 @@
623
699
  if (!p) return;
624
700
  pending.delete(m.r);
625
701
  clearTimeout(p.timer);
626
- if (m.offline) return p.reject(offlineError());
702
+ if (m.offline) {
703
+ h.offline(true, "computer");
704
+ return p.reject(offlineError());
705
+ }
706
+ h.offline(false, "computer");
627
707
  if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
628
- const body = await S.open(key, token, "res", m.d);
708
+ const body = await S.open(key, token, `res:${m.r}`, m.d);
629
709
  return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
630
710
  }
631
711
  if (m.t === "evt") {
@@ -636,6 +716,7 @@
636
716
  return;
637
717
  }
638
718
  if (m.t === "host") {
719
+ if (typeof m.now === "number") clock = m.now - Date.now();
639
720
  h.offline(!m.on);
640
721
  if (m.on) load();
641
722
  return;
@@ -643,21 +724,43 @@
643
724
  if (m.t === "ended") h.ended();
644
725
  }
645
726
 
727
+ // Frames are handled one at a time, in order: decrypting is asynchronous,
728
+ // and a later update finishing first would make an earlier one look old.
729
+ let frames = Promise.resolve();
730
+ // parley.im's clock minus this phone's, so a request's age is judged the
731
+ // same way at both ends whatever time the phone thinks it is.
732
+ let clock = 0;
733
+ let retry = null;
734
+
646
735
  function connect() {
736
+ clearTimeout(retry);
737
+ retry = null;
647
738
  ws = new WebSocket(`${location.origin.replace(/^http/, "ws")}${base}/ws`);
648
- ws.onopen = () => (failures = 0);
649
- ws.onmessage = (event) => onFrame(event.data).catch((error) => console.error(error));
739
+ ws.onopen = () => {
740
+ failures = 0;
741
+ h.offline(false, "connection");
742
+ };
743
+ ws.onmessage = (event) => (frames = frames.then(() => onFrame(event.data)).catch((error) => console.error(error)));
650
744
  ws.onclose = (event) => {
651
- for (const p of pending.values()) clearTimeout(p.timer), p.reject(offlineError());
745
+ for (const p of pending.values()) clearTimeout(p.timer), p.reject(connectionError());
652
746
  pending.clear();
653
747
  if (event.code === 4410) return h.ended();
654
748
  if (ended) return;
655
749
  failures++;
656
- if (failures > 1) h.offline(true);
657
- setTimeout(connect, Math.min(1000 * failures, 10_000));
750
+ // This page lost its own connection (a phone switching networks, or
751
+ // waking up); the sender's computer may be fine.
752
+ if (failures > 1) h.offline(true, "connection");
753
+ retry = setTimeout(connect, Math.min(1000 * failures, 10_000));
658
754
  };
659
755
  }
660
756
 
757
+ // Back from the background or back online: reconnect now, not on a timer.
758
+ const wake = () => {
759
+ if (!ended && document.visibilityState === "visible" && retry) connect();
760
+ };
761
+ document.addEventListener("visibilitychange", wake);
762
+ window.addEventListener("online", wake);
763
+
661
764
  return {
662
765
  async start(handlers) {
663
766
  h = handlers;
@@ -669,7 +772,8 @@
669
772
  connect();
670
773
  },
671
774
  history: (before) => request("history", { before }),
672
- send: (text, images) => request("send", { text, images }),
775
+ send: (text, images, cid) => request("send", { text, images, cid }),
776
+ end: () => request("end"),
673
777
  async upload(file) {
674
778
  const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
675
779
  const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
@@ -686,7 +790,7 @@
686
790
  // while it's offline; forget the failure and try again later.
687
791
  const res = await fetch(`${base}/blobs/${ref.id}`);
688
792
  if (!res.ok) throw new Error(`screenshot ${res.status}`);
689
- const plain = await S.openBytes(key, token, "blob", new Uint8Array(await res.arrayBuffer()));
793
+ const plain = await S.openBytes(key, token, `shot:${ref.id}`, new Uint8Array(await res.arrayBuffer()));
690
794
  return URL.createObjectURL(new Blob([plain], { type: ref.type }));
691
795
  })().catch((error) => {
692
796
  images.delete(ref.id);
@@ -713,16 +817,24 @@
713
817
  for (const m of state.messages) addMessage(m);
714
818
  if (state.live) showLive(state.live);
715
819
  if (owner) paintSide();
716
- else paintTester();
820
+ else paintGuest();
717
821
  banner("");
718
822
  toEnd();
719
823
  }
720
824
 
721
- function setOffline(on) {
722
- if (on === offline) return;
825
+ // Offline means one of two things: the sender's computer (asleep, or
826
+ // parley isn't running), or this page's own connection.
827
+ let offlineWhy = null;
828
+ function setOffline(on, why = "computer") {
829
+ if (!on && offlineWhy && why !== offlineWhy) return;
830
+ if (on === offline && (!on || why === offlineWhy)) return;
723
831
  offline = on;
832
+ offlineWhy = on ? why : null;
833
+ els.app.classList.toggle("offline", on);
724
834
  paintStatus();
725
- if (on) {
835
+ updateSend();
836
+ if (on && why === "connection") banner("The connection dropped. Reconnecting…");
837
+ else if (on) {
726
838
  const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
727
839
  banner(`${whose} computer is offline right now, so nothing can be sent or loaded. This page will reconnect on its own.`);
728
840
  } else banner("");
@@ -757,7 +869,7 @@
757
869
  function updateSend() {
758
870
  const length = els.input.value.trim().length;
759
871
  const ready = !attachments.some((a) => a.uploading);
760
- els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
872
+ els.send.disabled = offline || sending || !ready || length > maxText || (!length && !attachments.length);
761
873
  }
762
874
 
763
875
  // Browsers with field-sizing size the box themselves (no jumping while you
@@ -783,9 +895,28 @@
783
895
  updateSend();
784
896
  }
785
897
 
898
+ // Phone photos are often several MB, more than the agents take, so big
899
+ // ones are scaled down (2000 px on the long side) before they're sent.
900
+ async function shrink(file) {
901
+ if (file.type === "image/gif" || file.size <= 1_500_000) return file;
902
+ try {
903
+ const bitmap = await createImageBitmap(file);
904
+ const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height));
905
+ const canvas = document.createElement("canvas");
906
+ canvas.width = Math.round(bitmap.width * scale);
907
+ canvas.height = Math.round(bitmap.height * scale);
908
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
909
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.85));
910
+ return blob && blob.size < file.size ? new File([blob], "screenshot.jpg", { type: "image/jpeg" }) : file;
911
+ } catch {
912
+ return file;
913
+ }
914
+ }
915
+
786
916
  async function addFiles(files) {
787
- for (const file of files) {
788
- if (!/^image\/(png|jpeg|gif|webp)$/.test(file.type)) {
917
+ for (const original of files) {
918
+ const file = await shrink(original);
919
+ if (!/^image\/(png|jpeg|gif|webp)$/.test(original.type)) {
789
920
  banner("Only images can be added: PNG, JPEG, GIF or WebP.");
790
921
  continue;
791
922
  }
@@ -810,10 +941,14 @@
810
941
  if (els.send.disabled) return;
811
942
  const text = els.input.value.trim();
812
943
  const images = attachments.filter((a) => a.handle).map((a) => a.handle);
944
+ // One id per message you mean to send, kept until it goes through, so
945
+ // sending again after a lost answer can't post it twice.
946
+ draftId ??= crypto.randomUUID();
813
947
  sending = true;
814
948
  updateSend();
815
949
  try {
816
- const { message } = await transport.send(text, images);
950
+ const { message } = await transport.send(text, images, draftId);
951
+ draftId = null;
817
952
  addMessage(message);
818
953
  toEnd(); // you just spoke: back to the bottom, and follow from here
819
954
  els.input.value = "";
@@ -842,6 +977,7 @@
842
977
  }
843
978
 
844
979
  els.input.addEventListener("input", () => {
980
+ draftId = null; // edited: a new message, not a resend
845
981
  autosize();
846
982
  paintComposer();
847
983
  });
@@ -882,7 +1018,7 @@
882
1018
  }
883
1019
 
884
1020
  let started = false;
885
- if (relay) paintTester();
1021
+ if (relay) paintGuest();
886
1022
  transport.start({
887
1023
  state(state) {
888
1024
  showState(state);
@@ -0,0 +1,41 @@
1
+ // Keeps the parley.im dashboard current: who's on each session's page right
2
+ // now, when they were last active, and whether a reply is coming in. It
3
+ // never shows what was said; parley.im doesn't have that.
4
+ (() => {
5
+ const ago = (ms) => {
6
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
7
+ if (s < 60) return "just now";
8
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
9
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
10
+ return `${Math.round(s / 86400)}d ago`;
11
+ };
12
+ const dot = (kind, text) => {
13
+ const span = document.createElement("span");
14
+ span.className = `dot${kind ? ` ${kind}` : ""}`;
15
+ return [span, document.createTextNode(text)];
16
+ };
17
+ // Matches live() in relay/src/pages.ts.
18
+ const liveNodes = (s) => {
19
+ if (s.online === false) return dot("off", "computer offline");
20
+ if (s.replying) return dot("busy", "replying");
21
+ if (s.here) return dot("on", s.here > 1 ? `${s.here} people here` : "here now");
22
+ if (s.active) return dot("", `active ${ago(s.active)}`);
23
+ return dot("", "not opened yet");
24
+ };
25
+
26
+ async function refresh() {
27
+ if (document.hidden) return;
28
+ try {
29
+ const res = await fetch("/dashboard.json", { cache: "no-store" });
30
+ if (!res.ok) return;
31
+ const { sessions } = await res.json();
32
+ const rows = [...document.querySelectorAll("[data-session]")];
33
+ // A session opened or ended elsewhere: show the page fresh.
34
+ if (rows.map((r) => r.dataset.session).sort().join() !== sessions.map((s) => s.id).sort().join()) return location.reload();
35
+ for (const s of sessions) rows.find((r) => r.dataset.session === s.id)?.querySelector(".live")?.replaceChildren(...liveNodes(s));
36
+ } catch {}
37
+ }
38
+
39
+ setInterval(refresh, 4000);
40
+ document.addEventListener("visibilitychange", refresh);
41
+ })();
package/web/ended.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="robots" content="noindex, nofollow">
7
7
  <title>This parley has ended</title>
8
- <link rel="icon" href="/static/icon.svg">
8
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="/static/app.css">
10
10
  </head>
11
11
  <body>
package/web/missing.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="robots" content="noindex, nofollow">
7
7
  <title>Link not found</title>
8
- <link rel="icon" href="/static/icon.svg">
8
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="/static/app.css">
10
10
  </head>
11
11
  <body>
package/web/panel.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Control panel · parley</title>
7
- <link rel="icon" href="/static/icon.svg">
7
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
8
8
  <link rel="stylesheet" href="/static/app.css">
9
9
  </head>
10
10
  <body class="panel">
@@ -20,7 +20,7 @@
20
20
 
21
21
  <div class="toolbar">
22
22
  <div class="tabs" id="tabs" role="tablist"></div>
23
- <input class="search" id="search" type="search" placeholder="Filter by tester or project" aria-label="Filter sessions">
23
+ <input class="search" id="search" type="search" placeholder="Filter by guest or project" aria-label="Filter sessions">
24
24
  <button class="btn primary" id="new" type="button">
25
25
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New session
26
26
  </button>
@@ -41,8 +41,8 @@
41
41
  <span class="help">The interviewer runs here, read-only.</span>
42
42
  </div>
43
43
  <div class="field">
44
- <label for="f-tester">Who it's for</label>
45
- <input id="f-tester" name="tester" placeholder="Sam" required autocomplete="off">
44
+ <label for="f-guest">Who it's for</label>
45
+ <input id="f-guest" name="guest" placeholder="Sam" required autocomplete="off">
46
46
  </div>
47
47
  <div class="field">
48
48
  <label for="f-brief">Brief</label>