@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 +11 -0
- package/dist/charter.js +2 -1
- package/dist/relay.js +10 -1
- package/dist/server.js +13 -1
- package/package.json +1 -1
- package/web/app.css +39 -8
- package/web/chat.html +15 -8
- package/web/chat.js +88 -11
- package/web/site.js +11 -0
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.
|
|
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
|
-
|
|
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
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
|
-
|
|
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:
|
|
115
|
-
width: min(
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
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="
|
|
27
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><
|
|
28
|
-
<span class="sr">
|
|
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 = "
|
|
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
|
-
//
|
|
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}
|
|
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("
|
|
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
|
-
|
|
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 = () =>
|
|
311
|
-
|
|
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: "
|
|
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 =
|
|
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)
|
|
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
|
+
})();
|