@parley-im/parley 0.2.0 → 0.2.2

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/dist/api.js CHANGED
@@ -95,6 +95,17 @@ export class Conversations {
95
95
  const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)), clientId);
96
96
  return { message: this.messageView(message, scope) };
97
97
  }
98
+ // The guest is done. A note goes in the transcript for you, the session
99
+ // ends, and its link stops working. The caller tells parley.im.
100
+ endByGuest(scope) {
101
+ const session = this.store.session(scope.session.id);
102
+ if (session.status !== "open")
103
+ return session;
104
+ const note = this.store.addMessage(session.id, "guest", "(ended the conversation)", [], null);
105
+ this.runner.publish(session.id, { type: "message", message: note });
106
+ this.runner.end(session.id);
107
+ return session;
108
+ }
98
109
  // The same message sent again (its answer got lost on the way back): the
99
110
  // first one, rather than a second copy.
100
111
  resent(scope, clientId) {
package/dist/charter.js CHANGED
@@ -13,7 +13,8 @@ You are running a feedback session for ${session.project_name}, the project in y
13
13
  How to run it:
14
14
  - Hear them out. Let them brain dump. Ask one short follow-up at a time to get specifics: what they were trying to do, what they expected, what actually happened, where it happened, and how much it mattered to them.
15
15
  - Answer their questions about the project honestly. Read the code in this folder to check how something works or whether a behavior is intended, then explain it the way someone using the product would understand it: what it does and why, not where it lives. Most guests don't know or care about the code, so don't mention files, line numbers, functions or other internals unless they ask about the code. If they do ask, sharing details is fine.
16
- - Capture their perspective faithfully. Do not argue, defend the design, or talk them out of a reaction. If something is a known limitation, say so plainly.
16
+ - Capture their perspective faithfully. Don't try to win them over or talk them out of a reaction. If something is a known limitation, say so plainly.
17
+ - It's a conversation, not a survey: they can interview you too. When they challenge the project or ask what ${owner} thinks (the strategy, the competition, why it's built this way), give ${owner}'s actual position where the project's own documents state it, and say where it comes from. If nothing in the project covers it, say so and that you'll pass the question on; don't guess at ${owner}'s views. Then ask what they make of that answer. Their reaction is what ${owner} wants to hear.
17
18
  - Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
18
19
  - You are read-only. You can read files but cannot edit them or run anything. If they ask you to fix or change something, explain that this session is for collecting feedback and that you will make sure ${owner} sees it.
19
20
  - This project's folder is all you can see. Stay inside it: don't look for or talk about anything else on ${owner}'s computer, even if asked. Its .parley folder holds other people's conversations, so never open it or repeat anything from it.
package/dist/relay.js CHANGED
@@ -80,7 +80,8 @@ export class RelayLink {
80
80
  ws.onopen = async () => {
81
81
  this.delay = 1000;
82
82
  this.status = { state: "connected", relayUrl: creds.relayUrl, email: creds.account.email, since: new Date().toISOString() };
83
- this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 30_000);
83
+ // parley.im counts this computer as gone after about a minute without one.
84
+ this.ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"t":"ping"}'), 20_000);
84
85
  // Tell parley.im which sessions are open, so their links work.
85
86
  try {
86
87
  await registry.sync(creds, this.store.sessions({ status: "open" }));
@@ -191,6 +192,14 @@ export class RelayLink {
191
192
  names.push(this.saveImage(session, image));
192
193
  return await reply({ ok: true, result: this.api.post(scope, text, names, clientId) });
193
194
  }
195
+ if (req.op === "end") {
196
+ this.api.endByGuest(scope);
197
+ await reply({ ok: true, result: { ended: true } });
198
+ const creds = readCredentials(this.config);
199
+ if (creds)
200
+ await registry.end(creds, session).catch((error) => console.error(`parley.im didn't hear ${session.id} ended: ${error.message}`));
201
+ return;
202
+ }
194
203
  return await reply({ ok: false, error: "unknown", message: "Unknown request." });
195
204
  }
196
205
  catch (error) {
package/dist/server.js CHANGED
@@ -178,6 +178,16 @@ export async function serve() {
178
178
  api.check(scope, textBody, images.length);
179
179
  return json(res, api.post(scope, textBody, images, clientId));
180
180
  }
181
+ // The guest ends it. Local links only; on parley.im it's a sealed request.
182
+ if (rest === "/api/end" && req.method === "POST") {
183
+ if (scope.owner)
184
+ throw new Problem(403, "read_only", "Your view is read-only.");
185
+ api.endByGuest(scope);
186
+ const creds = readCredentials(config);
187
+ if (creds)
188
+ await registry.end(creds, session).catch(() => { });
189
+ return json(res, { ok: true });
190
+ }
181
191
  if (rest === "/api/uploads" && req.method === "POST") {
182
192
  if (scope.owner)
183
193
  throw new Problem(403, "read_only", "Your view is read-only.");
@@ -247,11 +257,13 @@ export async function serve() {
247
257
  console.log(`parley: control panel http://localhost:${config.dashboardPort}, local links on ${config.port}`);
248
258
  link.start();
249
259
  runner.resume();
260
+ // Says goodbye to parley.im before exiting, so guests see this computer go
261
+ // offline at once rather than when parley.im notices the silence.
250
262
  const shutdown = () => {
251
263
  link.stop();
252
264
  runner.stopAll();
253
265
  clearState(process.pid);
254
- process.exit(0);
266
+ setTimeout(() => process.exit(0), 300);
255
267
  };
256
268
  process.on("SIGINT", shutdown);
257
269
  process.on("SIGTERM", shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Send someone a link. They talk to a read-only Claude or Codex in your project, on your computer, and you get everything they said.",
5
5
  "keywords": [
6
6
  "claude",
package/web/app.css CHANGED
@@ -109,21 +109,25 @@ button, input, textarea, select { font: inherit; color: inherit; }
109
109
  .chip.low { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, var(--line)); }
110
110
  .info-btn { width: 34px; height: 34px; margin-right: -6px; }
111
111
 
112
- .sheet-back { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.35); z-index: 40; }
112
+ /* The menu: drops from the button at the top right, over the conversation. */
113
+ .sheet-back { position: absolute; inset: 0; z-index: 40; background: rgba(0, 0, 0, 0.12); }
113
114
  .sheet {
114
- position: fixed; z-index: 41; left: 50%; top: 50%; transform: translate(-50%, -50%);
115
- width: min(440px, calc(100% - 32px)); max-height: calc(100% - 48px); overflow-y: auto;
115
+ position: absolute; z-index: 41; right: 8px; top: calc(54px + env(safe-area-inset-top));
116
+ width: min(380px, calc(100% - 16px)); max-height: calc(100% - 70px - env(safe-area-inset-top)); overflow-y: auto;
116
117
  background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
118
+ transform-origin: top right; animation: drop 0.14s ease-out;
117
119
  }
118
- .sheet header { display: flex; align-items: center; justify-content: space-between; padding: 12px 12px 4px 16px; }
120
+ .sheet header { display: flex; align-items: center; justify-content: space-between; padding: 8px 6px 2px 16px; }
121
+ .sheet header .icon-btn { width: 34px; height: 34px; }
119
122
  .sheet h2 { font-size: 16px; margin: 0; }
120
123
  .sheet-body { padding: 4px 16px 16px; font-size: 14px; }
121
124
  .sheet-body p { margin: 0 0 10px; color: var(--muted); }
122
125
  .sheet-body p strong { color: var(--text); font-weight: 600; }
123
126
  .sheet-body .kv { margin: 6px 0 14px; padding: 10px 12px; background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; }
124
- @media (max-width: 560px) {
125
- .sheet { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: auto; border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom); }
126
- }
127
+ .sheet-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; }
128
+ .sheet-body .fine { font-size: 12px; color: var(--faint); }
129
+ .sheet-body details.how { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
130
+ .sheet-body details.how summary { cursor: pointer; color: var(--text); font-weight: 500; margin-bottom: 8px; }
127
131
 
128
132
  .notice {
129
133
  margin: 10px 16px 0; padding: 9px 12px; border-radius: 8px; font-size: 14px;
@@ -134,7 +138,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
134
138
 
135
139
  /* ---------- chat ---------- */
136
140
 
137
- .app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
141
+ .app { position: relative; display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
138
142
  /* The chat never scrolls the page itself, only its conversation. */
139
143
  body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
140
144
  .layout { flex: 1; min-height: 0; display: flex; }
@@ -221,6 +225,10 @@ body:has(> .app) { overflow: hidden; overscroll-behavior: none; }
221
225
  .dock .chat-inner { padding: 8px 16px calc(8px + env(safe-area-inset-bottom)); }
222
226
  .composer { border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow); }
223
227
  .composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
228
+ /* The sender's computer (or this page's connection) is offline: nothing can
229
+ be sent until it's back, and it looks that way. */
230
+ .app.offline .composer, .app.offline .composer:focus-within { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
231
+ .app.offline .prompt { color: var(--danger); }
224
232
  .composer .row { display: flex; align-items: flex-end; gap: 2px; padding: 4px 4px 4px 12px; }
225
233
  .prompt { font-family: var(--mono); color: var(--accent); font-weight: 600; padding: 9px 4px 9px 0; line-height: 1.55; }
226
234
  textarea {
@@ -385,6 +393,29 @@ body.panel { overflow: auto; }
385
393
  .site code { font-family: var(--mono); font-size: 0.88em; background: var(--subtle); border: 1px solid var(--line); padding: 0.05em 0.35em; border-radius: 5px; }
386
394
  .site h3 { margin: 32px 0 8px; }
387
395
  .stack { display: grid; gap: 10px; max-width: 360px; margin-top: 16px; }
396
+ /* Your account, top right of parley.im's pages. */
397
+ .account-menu { position: relative; }
398
+ .account-menu summary { list-style: none; display: flex; align-items: center; gap: 4px; padding: 3px 6px 3px 3px; border-radius: 99px; cursor: pointer; }
399
+ .account-menu summary::-webkit-details-marker { display: none; }
400
+ .account-menu summary:hover, .account-menu[open] summary { background: var(--subtle); }
401
+ .account-menu summary svg { width: 14px; height: 14px; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
402
+ .avatar { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 600; font-size: 13px; }
403
+ .menu-panel {
404
+ position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; min-width: 220px; max-width: calc(100vw - 24px);
405
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 6px;
406
+ animation: drop 0.14s ease-out;
407
+ }
408
+ .menu-panel a, .menu-panel button {
409
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px;
410
+ background: none; color: var(--text); text-decoration: none; font-size: 14px; text-align: left; cursor: pointer;
411
+ }
412
+ .menu-panel a:hover, .menu-panel button:hover { background: var(--subtle); }
413
+ .menu-panel form { margin: 0; border-top: 1px solid var(--line); margin-top: 4px; padding-top: 4px; }
414
+ .menu-who { display: grid; padding: 8px 10px 10px; border-bottom: 1px solid var(--line); margin-bottom: 4px; }
415
+ .menu-who strong { font-size: 14px; }
416
+ .menu-who span { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
417
+ @keyframes drop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } }
418
+
388
419
  .live { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); white-space: nowrap; }
389
420
  .docs { padding-bottom: 40px; }
390
421
  .docs h2 { font-size: 17px; letter-spacing: -0.01em; margin: 36px 0 10px; }
package/web/chat.html CHANGED
@@ -23,9 +23,9 @@
23
23
  </nav>
24
24
  <span class="status" id="status" hidden><span class="dot" id="status-dot"></span><span id="status-text"></span></span>
25
25
  <span class="chip" id="chip" hidden></span>
26
- <button class="icon-btn info-btn" id="info-btn" type="button" title="About this chat" hidden>
27
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5"/><path d="M12 7.6v.1"/></svg>
28
- <span class="sr">About this chat</span>
26
+ <button class="icon-btn info-btn" id="info-btn" type="button" title="This conversation" hidden>
27
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
28
+ <span class="sr">This conversation: details and actions</span>
29
29
  </button>
30
30
  </header>
31
31
 
@@ -66,12 +66,19 @@
66
66
  </div>
67
67
  <aside class="side" id="side" hidden></aside>
68
68
  </div>
69
+ <!-- Inside the app, so it stays under the header when a phone's keyboard
70
+ moves things around. -->
71
+ <div class="sheet-back" id="sheet-back" hidden></div>
72
+ <section class="sheet" id="sheet" hidden aria-label="This conversation">
73
+ <header>
74
+ <h2>This conversation</h2>
75
+ <button class="icon-btn" id="sheet-close" type="button" title="Close">
76
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg><span class="sr">Close</span>
77
+ </button>
78
+ </header>
79
+ <div class="sheet-body" id="sheet-body"></div>
80
+ </section>
69
81
  </div>
70
- <div class="sheet-back" id="sheet-back" hidden></div>
71
- <section class="sheet" id="sheet" hidden aria-label="About this chat">
72
- <header><h2>About this chat</h2><button class="btn ghost" id="sheet-close" type="button">Done</button></header>
73
- <div class="sheet-body" id="sheet-body"></div>
74
- </section>
75
82
  <div class="drop" id="drop" hidden><div>Drop screenshots to add them</div></div>
76
83
  <script>window.PARLEY = {{CONFIG}};</script>
77
84
  <script src="/static/markdown.js"></script>
package/web/chat.js CHANGED
@@ -18,6 +18,7 @@
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;
@@ -193,6 +194,7 @@
193
194
  function addMessage(m, { prepend = false } = {}) {
194
195
  if (seen.has(m.id)) return;
195
196
  seen.add(m.id);
197
+ kept.set(m.id, m);
196
198
  const node = messageEl(m);
197
199
  if (prepend) els.messages.prepend(node);
198
200
  else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
@@ -263,7 +265,7 @@
263
265
  els.status.hidden = false;
264
266
  let dot = "on";
265
267
  let label = owner ? `${agentName()} · live` : speaker();
266
- if (offline) (dot = "warn"), (label = "reconnecting");
268
+ if (offline) (dot = "off"), (label = offlineWhy === "connection" ? "reconnecting" : "offline");
267
269
  // A reply in progress shows as typing dots in the conversation; the
268
270
  // header stays put.
269
271
  else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
@@ -279,7 +281,49 @@
279
281
  }
280
282
  }
281
283
 
282
- // Everything a guest 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.
283
327
  function openSheet() {
284
328
  const who = sender?.name || ownerName || "The person who sent this link";
285
329
  const whom = ownerName || "them";
@@ -290,11 +334,22 @@
290
334
  const facts = [];
291
335
  if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
292
336
  if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
293
- 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";
294
343
  els.sheetBody.replaceChildren(
295
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.` : "."),
296
345
  kv(facts),
297
- 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}.`),
298
353
  relay
299
354
  ? el(
300
355
  "p",
@@ -302,13 +357,21 @@
302
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.)`,
303
358
  )
304
359
  : null,
305
- 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
+ ),
306
362
  );
363
+ // Just under the header, whatever its height on this screen.
364
+ const header = document.querySelector(".topbar").getBoundingClientRect();
365
+ els.sheet.style.top = `${header.bottom - els.app.getBoundingClientRect().top + 6}px`;
307
366
  els.sheet.hidden = els.sheetBack.hidden = false;
367
+ els.infoBtn.setAttribute("aria-expanded", "true");
308
368
  els.sheetClose.focus();
309
369
  }
310
- const closeSheet = () => (els.sheet.hidden = els.sheetBack.hidden = true);
311
- els.infoBtn.addEventListener("click", openSheet);
370
+ const closeSheet = () => {
371
+ els.sheet.hidden = els.sheetBack.hidden = true;
372
+ els.infoBtn.setAttribute("aria-expanded", "false");
373
+ };
374
+ els.infoBtn.addEventListener("click", () => (els.sheet.hidden ? openSheet() : closeSheet()));
312
375
  els.sheetClose.addEventListener("click", closeSheet);
313
376
  els.sheetBack.addEventListener("click", closeSheet);
314
377
  addEventListener("keydown", (e) => e.key === "Escape" && closeSheet());
@@ -322,7 +385,7 @@
322
385
  els.infoBtn.hidden = false;
323
386
  els.intro.hidden = false;
324
387
  const strong = (t) => el("strong", { textContent: t });
325
- const more = el("button", { type: "button", textContent: "About this chat" });
388
+ const more = el("button", { type: "button", textContent: "Details" });
326
389
  more.onclick = openSheet;
327
390
  els.intro.replaceChildren(
328
391
  name ? strong(name) : "Someone",
@@ -558,6 +621,10 @@
558
621
  if (!res.ok) throw await problemFrom(res);
559
622
  return res.json();
560
623
  },
624
+ async end() {
625
+ const res = await fetch(`${base}/api/end`, { method: "POST" });
626
+ if (!res.ok) throw await problemFrom(res);
627
+ },
561
628
  async send(text, images, cid) {
562
629
  const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
563
630
  if (!res.ok) throw await problemFrom(res);
@@ -593,13 +660,16 @@
593
660
  bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
594
661
  };
595
662
 
596
- async function request(op, params = {}, timeoutMs = 30_000) {
663
+ async function request(op, params = {}, timeoutMs = 15_000) {
597
664
  if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
598
665
  const r = crypto.randomUUID();
599
666
  const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
600
667
  return new Promise((resolve, reject) => {
668
+ // No answer means the computer is gone, whatever parley.im last said:
669
+ // show it offline until it's heard from again.
601
670
  const timer = setTimeout(() => {
602
671
  pending.delete(r);
672
+ h.offline(true, "computer");
603
673
  reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
604
674
  }, timeoutMs);
605
675
  pending.set(r, { resolve, reject, timer });
@@ -636,7 +706,11 @@
636
706
  if (!p) return;
637
707
  pending.delete(m.r);
638
708
  clearTimeout(p.timer);
639
- if (m.offline) return p.reject(offlineError());
709
+ if (m.offline) {
710
+ h.offline(true, "computer");
711
+ return p.reject(offlineError());
712
+ }
713
+ h.offline(false, "computer");
640
714
  if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
641
715
  const body = await S.open(key, token, `res:${m.r}`, m.d);
642
716
  return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
@@ -706,6 +780,7 @@
706
780
  },
707
781
  history: (before) => request("history", { before }),
708
782
  send: (text, images, cid) => request("send", { text, images, cid }),
783
+ end: () => request("end"),
709
784
  async upload(file) {
710
785
  const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
711
786
  const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
@@ -762,7 +837,9 @@
762
837
  if (on === offline && (!on || why === offlineWhy)) return;
763
838
  offline = on;
764
839
  offlineWhy = on ? why : null;
840
+ els.app.classList.toggle("offline", on);
765
841
  paintStatus();
842
+ updateSend();
766
843
  if (on && why === "connection") banner("The connection dropped. Reconnecting…");
767
844
  else if (on) {
768
845
  const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
@@ -799,7 +876,7 @@
799
876
  function updateSend() {
800
877
  const length = els.input.value.trim().length;
801
878
  const ready = !attachments.some((a) => a.uploading);
802
- els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
879
+ els.send.disabled = offline || sending || !ready || length > maxText || (!length && !attachments.length);
803
880
  }
804
881
 
805
882
  // Browsers with field-sizing size the box themselves (no jumping while you
package/web/site.js ADDED
@@ -0,0 +1,11 @@
1
+ // parley.im's account menu closes when you click outside it or press Escape.
2
+ (() => {
3
+ const menu = document.querySelector(".account-menu");
4
+ if (!menu) return;
5
+ document.addEventListener("click", (e) => {
6
+ if (menu.open && !menu.contains(e.target)) menu.open = false;
7
+ });
8
+ document.addEventListener("keydown", (e) => {
9
+ if (e.key === "Escape") menu.open = false;
10
+ });
11
+ })();