relay-companion 0.1.113 → 0.1.115
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/bin/relay.js +33 -6
- package/overlay/inbox.html +11 -0
- package/overlay/main.cjs +22 -15
- package/overlay/message-face.cjs +110 -0
- package/overlay/preview-renderer.js +82 -14
- package/package.json +1 -1
package/bin/relay.js
CHANGED
|
@@ -178,14 +178,37 @@ async function applyInstall({
|
|
|
178
178
|
const detail = pill.reason ? ` (${pill.reason})` : "";
|
|
179
179
|
console.log(`Could not install Relay pill autostart${detail}. Run \`relay pill\` to open it.`);
|
|
180
180
|
}
|
|
181
|
+
// Nothing was installed because no agent lives on this machine. This is the
|
|
182
|
+
// single most likely outcome for someone who was sent a relay and followed the
|
|
183
|
+
// instructions, and it used to print the skip lines above and exit 0 -- with
|
|
184
|
+
// the downloadable .command wrapper then adding "Relay is set up." The person
|
|
185
|
+
// ends up with a paired device, a daemon and a pill, and no way to receive a
|
|
186
|
+
// relay in an agent, having been told it worked.
|
|
187
|
+
if (!installed.length) {
|
|
188
|
+
console.log("");
|
|
189
|
+
console.log("Relay could not finish: no agent was found on this machine.");
|
|
190
|
+
console.log("Relay delivers into a coding agent, so it needs one of these installed first:");
|
|
191
|
+
console.log(" Claude Code https://claude.com/claude-code");
|
|
192
|
+
console.log(" Codex https://developers.openai.com/codex");
|
|
193
|
+
console.log("Install one, then run this command again.");
|
|
194
|
+
console.log("");
|
|
195
|
+
console.log(`In the meantime you can read and reply to your relays on the web: ${absoluteWebTarget("/app/relays")}`);
|
|
196
|
+
return { installed, missing, daemon, pill, activations, agentMissing: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
const liveRequirement = liveToolRequirement({ activations, requiredHosts });
|
|
182
|
-
if (requireLiveTools &&
|
|
200
|
+
if (requireLiveTools && !liveRequirement.ok) {
|
|
201
|
+
// Registration only takes effect in a NEW agent session, so the session that
|
|
202
|
+
// ran this command can never report itself ready -- verifyClaudeMcpRegistration
|
|
203
|
+
// returns currentSessionReady:false on every path by construction. Treating
|
|
204
|
+
// that as a failure turned a successful install into `exit 1`, and skipped the
|
|
205
|
+
// open-relay step that is the entire reason the recipient ran this. Say what
|
|
206
|
+
// to do instead of failing.
|
|
183
207
|
const hostLabel = liveRequirement.missingHosts.length
|
|
184
208
|
? liveRequirement.missingHosts.join(" and ")
|
|
185
|
-
: "this
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
209
|
+
: "this agent session";
|
|
210
|
+
console.log("");
|
|
211
|
+
console.log(`Relay is installed. Restart ${hostLabel} (or open a new session) to use it.`);
|
|
189
212
|
}
|
|
190
213
|
return { installed, missing, daemon, pill, activations };
|
|
191
214
|
}
|
|
@@ -196,7 +219,7 @@ async function cmdSetup(flags) {
|
|
|
196
219
|
writeConfig({ companionMode: mode });
|
|
197
220
|
await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
|
|
198
221
|
console.log("");
|
|
199
|
-
await applyInstall({
|
|
222
|
+
const install = await applyInstall({
|
|
200
223
|
requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools(),
|
|
201
224
|
requiredHosts: requiredLiveHosts(),
|
|
202
225
|
mode,
|
|
@@ -222,6 +245,10 @@ async function cmdSetup(flags) {
|
|
|
222
245
|
console.log(`Relay ${result.relayId} is staged in the Relay pill.`);
|
|
223
246
|
}
|
|
224
247
|
}
|
|
248
|
+
// The relay is opened/staged first -- the recipient should still get the thing
|
|
249
|
+
// they came for -- but setup did not achieve what it claims, so it must not
|
|
250
|
+
// report success. `exitCode` rather than `exit()` so nothing above is truncated.
|
|
251
|
+
if (install?.agentMissing) process.exitCode = 1;
|
|
225
252
|
}
|
|
226
253
|
|
|
227
254
|
/** Install the tools + daemon on a device that is already paired. */
|
package/overlay/inbox.html
CHANGED
|
@@ -3557,6 +3557,7 @@
|
|
|
3557
3557
|
</span>
|
|
3558
3558
|
</div>
|
|
3559
3559
|
<div class="open-actions sv-actions" data-stop="1" role="menu">
|
|
3560
|
+
<button class="oa-item" type="button" role="menuitem" id="svAccount" ${svBusy ? "disabled" : ""}>Account Settings…</button>
|
|
3560
3561
|
<button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
|
|
3561
3562
|
<div class="oa-sep" role="separator"></div>
|
|
3562
3563
|
<button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" role="menuitem" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
|
|
@@ -3597,6 +3598,16 @@
|
|
|
3597
3598
|
for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
|
|
3598
3599
|
z.addEventListener("click", (e) => e.stopPropagation());
|
|
3599
3600
|
}
|
|
3601
|
+
// Managing addresses lives on the web (Clerk owns verification and the
|
|
3602
|
+
// reverification challenge), so the pill's job is to get you there rather
|
|
3603
|
+
// than to reimplement it. Without this the account panel showed an email the
|
|
3604
|
+
// user had no way to change from the surface they actually live in.
|
|
3605
|
+
const accountEl = document.getElementById("svAccount");
|
|
3606
|
+
if (accountEl) {
|
|
3607
|
+
accountEl.addEventListener("click", () => {
|
|
3608
|
+
if (window.relay.openUrl) window.relay.openUrl("/app/settings");
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3600
3611
|
const switchEl = document.getElementById("svSwitch");
|
|
3601
3612
|
if (switchEl) switchEl.addEventListener("click", beginPairFlow);
|
|
3602
3613
|
const signOutEl = document.getElementById("svSignOut");
|
package/overlay/main.cjs
CHANGED
|
@@ -96,6 +96,7 @@ const {
|
|
|
96
96
|
companionModeFromRuntime,
|
|
97
97
|
taskFeaturesAllowed,
|
|
98
98
|
} = require("./mode-policy.cjs");
|
|
99
|
+
const { openingFaceFor, titleFromBody } = require("./message-face.cjs");
|
|
99
100
|
const perf = require("./perf-counters.cjs");
|
|
100
101
|
|
|
101
102
|
const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
|
|
@@ -1392,6 +1393,17 @@ function previewPayloadForPacket(packetId) {
|
|
|
1392
1393
|
// The thread this message belongs to, so the preview can ask for the
|
|
1393
1394
|
// conversation around it. A relay that predates threads is its own root.
|
|
1394
1395
|
threadId: String(row.threadId || id),
|
|
1396
|
+
// A chat message opens IN its conversation; a relay carrying a subject of
|
|
1397
|
+
// its own opens on the reading face. Named fields only — the projector
|
|
1398
|
+
// never spreads a staged row, here or anywhere. See message-face.cjs.
|
|
1399
|
+
openFace: openingFaceFor({
|
|
1400
|
+
threadId: row.threadId || id,
|
|
1401
|
+
title: row.title || row.displayTitle,
|
|
1402
|
+
bodyMarkdown: row.bodyMarkdown,
|
|
1403
|
+
relayNotificationKind: row.relayNotificationKind,
|
|
1404
|
+
taskId: row.taskId,
|
|
1405
|
+
type: row.type,
|
|
1406
|
+
}),
|
|
1395
1407
|
};
|
|
1396
1408
|
}
|
|
1397
1409
|
|
|
@@ -1414,6 +1426,12 @@ function previewPayloadForSent(relayId) {
|
|
|
1414
1426
|
unread: false,
|
|
1415
1427
|
outbound: true,
|
|
1416
1428
|
threadId: String(item.threadId || id),
|
|
1429
|
+
openFace: openingFaceFor({
|
|
1430
|
+
threadId: item.threadId || id,
|
|
1431
|
+
title: item.title || item.displayTitle,
|
|
1432
|
+
bodyMarkdown: item.bodyMarkdown || item.preview,
|
|
1433
|
+
type: item.type,
|
|
1434
|
+
}),
|
|
1417
1435
|
};
|
|
1418
1436
|
}
|
|
1419
1437
|
|
|
@@ -3071,20 +3089,6 @@ async function previewChat(threadId) {
|
|
|
3071
3089
|
}
|
|
3072
3090
|
}
|
|
3073
3091
|
|
|
3074
|
-
/**
|
|
3075
|
-
* A chat message has a body, not a subject — but a relay carries a title, and
|
|
3076
|
-
* the Relays list, notifications and email subjects all read it. Take the first
|
|
3077
|
-
* line so a quick reply still looks like something in every one of those.
|
|
3078
|
-
*/
|
|
3079
|
-
function replyTitleFromBody(body) {
|
|
3080
|
-
const first = String(body || "")
|
|
3081
|
-
.split("\n")
|
|
3082
|
-
.map((line) => line.replace(/^\s*[>#*\-\d.]+\s*/, "").trim())
|
|
3083
|
-
.find(Boolean);
|
|
3084
|
-
const title = (first || "Reply").replace(/\s+/g, " ").trim();
|
|
3085
|
-
return title.length > 120 ? `${title.slice(0, 119).trimEnd()}…` : title;
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
3092
|
/**
|
|
3089
3093
|
* Send a reply. The recipient is deliberately left unset: the API addresses a
|
|
3090
3094
|
* reply to the other party of the message it answers, and fans it out to the
|
|
@@ -3106,7 +3110,10 @@ async function sendPreviewReply(input) {
|
|
|
3106
3110
|
const client = await relayClient();
|
|
3107
3111
|
const sent = await client.sendRelay({
|
|
3108
3112
|
recipient: {},
|
|
3109
|
-
|
|
3113
|
+
// A chat message has a body, not a subject: the first line becomes the
|
|
3114
|
+
// title every Relay surface reads — and is what marks this, when it comes
|
|
3115
|
+
// back, as a line of a conversation rather than a relay to read.
|
|
3116
|
+
title: titleFromBody(body),
|
|
3110
3117
|
bodyMarkdown: body,
|
|
3111
3118
|
inReplyToRelayId,
|
|
3112
3119
|
idempotencyKey,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which face the preview window opens on: the single message it was opened
|
|
5
|
+
* from, or the conversation that message belongs to.
|
|
6
|
+
*
|
|
7
|
+
* A relay carries a title. A CHAT MESSAGE does not — every surface that needs a
|
|
8
|
+
* subject for one (the Relays and Sent lists, notifications, the email subject)
|
|
9
|
+
* derives it from the first line of what the person typed, in `titleFromBody`
|
|
10
|
+
* below. So a message whose title says nothing its own body has not already
|
|
11
|
+
* said has nothing to READ on the message face: it is one line of a
|
|
12
|
+
* conversation, and it opens IN that conversation. Anything carrying a subject
|
|
13
|
+
* of its own — an agent's relay, a question, a task — still opens on its own
|
|
14
|
+
* reading face, with the conversation one click away on the chip.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Staged kinds that are agent or system output, never someone talking. */
|
|
18
|
+
const NON_CHAT_KINDS = new Set([
|
|
19
|
+
"human_question",
|
|
20
|
+
"task_request",
|
|
21
|
+
"task_open",
|
|
22
|
+
"task_completed",
|
|
23
|
+
"result",
|
|
24
|
+
"share_approval",
|
|
25
|
+
"connector_reauth",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** The length a derived title is cut to before it is ellipsised. */
|
|
29
|
+
const TITLE_MAX = 120;
|
|
30
|
+
|
|
31
|
+
/** The first line that says something, with its Markdown marker stripped. */
|
|
32
|
+
function firstLineOf(body) {
|
|
33
|
+
return (
|
|
34
|
+
String(body || "")
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => line.replace(/^\s*[>#*\-\d.]+\s*/, "").trim())
|
|
37
|
+
.find(Boolean) || ""
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A chat message has a body, not a subject — but a relay carries a title that
|
|
43
|
+
* Relay's lists, notifications and email subjects all read. Use the first line
|
|
44
|
+
* so a conversational message still reads as something in every one of those.
|
|
45
|
+
* This is the derivation that WRITES those titles; the recogniser below reads
|
|
46
|
+
* them back, and they live together so they cannot drift apart.
|
|
47
|
+
*/
|
|
48
|
+
function titleFromBody(body) {
|
|
49
|
+
const title = (firstLineOf(body) || "Reply").replace(/\s+/g, " ").trim();
|
|
50
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1).trimEnd()}…` : title;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalize(value) {
|
|
54
|
+
return String(value == null ? "" : value)
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Does this title say anything the body's first line does not? Compared
|
|
62
|
+
* loosely on purpose: a derived title is cut at TITLE_MAX while the line it
|
|
63
|
+
* came from keeps going, so a long single-line message is recognised by its
|
|
64
|
+
* stem. The stem has to be substantial before a prefix counts — a two-word
|
|
65
|
+
* subject that happens to start a longer sentence is a real subject.
|
|
66
|
+
*/
|
|
67
|
+
function titleIsJustTheFirstLine(title, body) {
|
|
68
|
+
const lead = normalize(firstLineOf(body));
|
|
69
|
+
if (!lead) return false; // no body to speak for itself
|
|
70
|
+
const subject = normalize(title);
|
|
71
|
+
if (!subject) return true; // nothing but a body
|
|
72
|
+
if (subject === lead) return true;
|
|
73
|
+
const stem = subject.replace(/…+$/, "").trim();
|
|
74
|
+
return stem.length >= 24 && lead.startsWith(stem);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Is this relay one line of a conversation rather than something to read? */
|
|
78
|
+
function isChatMessage(row = {}) {
|
|
79
|
+
const kind = String((row && row.relayNotificationKind) || "")
|
|
80
|
+
.trim()
|
|
81
|
+
.toLowerCase();
|
|
82
|
+
if (NON_CHAT_KINDS.has(kind)) return false;
|
|
83
|
+
// A relay attached to a task, or one an agent marked as a question or a
|
|
84
|
+
// completion, is that agent's output — it belongs on the reading face.
|
|
85
|
+
if (row && row.taskId) return false;
|
|
86
|
+
const type = String((row && row.type) || "")
|
|
87
|
+
.trim()
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
if (type === "question" || type === "completion") return false;
|
|
90
|
+
return titleIsJustTheFirstLine((row && (row.title || row.displayTitle)) || "", row && row.bodyMarkdown);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* "chat" opens in the conversation; "message" opens on the reading face. A
|
|
95
|
+
* message with no conversation to open into always gets the reading face.
|
|
96
|
+
*/
|
|
97
|
+
function openingFaceFor(row = {}) {
|
|
98
|
+
if (!String((row && row.threadId) || "").trim()) return "message";
|
|
99
|
+
return isChatMessage(row) ? "chat" : "message";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
NON_CHAT_KINDS,
|
|
104
|
+
TITLE_MAX,
|
|
105
|
+
firstLineOf,
|
|
106
|
+
isChatMessage,
|
|
107
|
+
openingFaceFor,
|
|
108
|
+
titleFromBody,
|
|
109
|
+
titleIsJustTheFirstLine,
|
|
110
|
+
};
|
|
@@ -45,6 +45,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
45
45
|
let content = {};
|
|
46
46
|
/** The loaded conversation this message belongs to, or null. */
|
|
47
47
|
let chat = null;
|
|
48
|
+
/** Has the reader already been placed in this conversation once? */
|
|
49
|
+
let landed = false;
|
|
48
50
|
let face = "message";
|
|
49
51
|
let sending = false;
|
|
50
52
|
/** Replies shown before the server has confirmed them. */
|
|
@@ -155,7 +157,9 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function composerPlaceholder() {
|
|
158
|
-
if (face === "chat"
|
|
160
|
+
if (face === "chat") {
|
|
161
|
+
return `Message ${text(chat && chat.title) || chatTitleHint(content) || "this conversation"}…`;
|
|
162
|
+
}
|
|
159
163
|
const who = text(content.senderName).replace(/^You → /, "");
|
|
160
164
|
return who && !content.outbound ? `Reply to ${who.split(" ")[0]}…` : "Write a reply…";
|
|
161
165
|
}
|
|
@@ -199,19 +203,60 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
199
203
|
headChatEl.classList.toggle("gone", !onChat);
|
|
200
204
|
faceMessageEl.setAttribute("aria-hidden", onChat ? "true" : "false");
|
|
201
205
|
faceChatEl.setAttribute("aria-hidden", onChat ? "false" : "true");
|
|
202
|
-
if (onChat)
|
|
203
|
-
renderChat();
|
|
204
|
-
scrollChatToLatest();
|
|
205
|
-
}
|
|
206
|
+
if (onChat) paintChat();
|
|
206
207
|
refreshComposer();
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Show the conversation, and put the reader where they belong in it: on the
|
|
212
|
+
* message they came in on the first time, at the live end after that.
|
|
213
|
+
*/
|
|
214
|
+
function paintChat({ stick = true } = {}) {
|
|
215
|
+
renderChat();
|
|
216
|
+
if (!chat) return; // still loading — the load will place them
|
|
217
|
+
if (landed) {
|
|
218
|
+
if (stick) scrollChatToLatest();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
landed = true;
|
|
222
|
+
scrollChatToOpened();
|
|
223
|
+
}
|
|
224
|
+
|
|
209
225
|
function scrollChatToLatest() {
|
|
210
226
|
requestAnimationFrame(() => {
|
|
211
227
|
chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
|
|
212
228
|
});
|
|
213
229
|
}
|
|
214
230
|
|
|
231
|
+
const cssEscape = (value) =>
|
|
232
|
+
typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&");
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Land on the message the window was opened from. Opening a text message
|
|
236
|
+
* three days old should not drop the reader at the bottom of a conversation
|
|
237
|
+
* that has moved on since — but the newest message belongs where a
|
|
238
|
+
* conversation always sits, at the end.
|
|
239
|
+
*
|
|
240
|
+
* Centred by moving THIS scroller and nothing else. scrollIntoView is wrong
|
|
241
|
+
* here: it scrolls every scrollable ancestor, and the faces track is one of
|
|
242
|
+
* them (overflow:hidden still scrolls programmatically), so it slid the whole
|
|
243
|
+
* conversation sideways out of the window while every DOM check still passed.
|
|
244
|
+
*/
|
|
245
|
+
function scrollChatToOpened() {
|
|
246
|
+
const id = text(content.relayId);
|
|
247
|
+
const target = id ? chatListEl.querySelector(`.bubble-row[data-relay-id="${cssEscape(id)}"]`) : null;
|
|
248
|
+
if (!target || !target.nextElementSibling) {
|
|
249
|
+
scrollChatToLatest();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
requestAnimationFrame(() => {
|
|
253
|
+
if (!target.isConnected) return;
|
|
254
|
+
const view = chatScrollEl.getBoundingClientRect();
|
|
255
|
+
const row = target.getBoundingClientRect();
|
|
256
|
+
chatScrollEl.scrollTop += row.top + row.height / 2 - (view.top + view.height / 2);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
215
260
|
function nearChatBottom() {
|
|
216
261
|
return chatScrollEl.scrollHeight - chatScrollEl.scrollTop - chatScrollEl.clientHeight < 90;
|
|
217
262
|
}
|
|
@@ -228,6 +273,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
228
273
|
if (!threadId) {
|
|
229
274
|
chat = null;
|
|
230
275
|
updateChip();
|
|
276
|
+
// Nothing to open into: never strand the reader on an empty conversation.
|
|
277
|
+
if (face === "chat") showFace("message");
|
|
231
278
|
return;
|
|
232
279
|
}
|
|
233
280
|
let result;
|
|
@@ -247,7 +294,13 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
247
294
|
}
|
|
248
295
|
chat = null;
|
|
249
296
|
updateChip();
|
|
250
|
-
|
|
297
|
+
// A message that opened straight into its conversation has nowhere to sit
|
|
298
|
+
// if that conversation will not load. Give the reader back the message
|
|
299
|
+
// they clicked, with the reason on the composer's note line.
|
|
300
|
+
if (face === "chat") {
|
|
301
|
+
showFace("message");
|
|
302
|
+
setNote(text(result && result.error) || "Could not open this conversation.", true);
|
|
303
|
+
}
|
|
251
304
|
return;
|
|
252
305
|
}
|
|
253
306
|
chat = result.chat || null;
|
|
@@ -258,11 +311,7 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
258
311
|
// the server accepted is retired here by its own local id instead.
|
|
259
312
|
outgoing = outgoing.filter((o) => o.state !== "sent");
|
|
260
313
|
updateChip();
|
|
261
|
-
if (face === "chat") {
|
|
262
|
-
const stick = keepScroll ? nearChatBottom() : true;
|
|
263
|
-
renderChat();
|
|
264
|
-
if (stick) scrollChatToLatest();
|
|
265
|
-
}
|
|
314
|
+
if (face === "chat") paintChat({ stick: keepScroll ? nearChatBottom() : true });
|
|
266
315
|
refreshComposer();
|
|
267
316
|
}
|
|
268
317
|
|
|
@@ -276,6 +325,17 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
276
325
|
return `p${hash % 8}`;
|
|
277
326
|
}
|
|
278
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Who the conversation is with, read off the message alone. Shown while the
|
|
330
|
+
* chat loads so a window that opens straight into a conversation names it
|
|
331
|
+
* from the first frame instead of saying "Conversation" and then changing.
|
|
332
|
+
*/
|
|
333
|
+
function chatTitleHint(row) {
|
|
334
|
+
return text(row && row.senderName)
|
|
335
|
+
.replace(/^From\s+/i, "")
|
|
336
|
+
.replace(/^You\s*→\s*/, "");
|
|
337
|
+
}
|
|
338
|
+
|
|
279
339
|
function renderChatState(message, isError) {
|
|
280
340
|
chatListEl.replaceChildren();
|
|
281
341
|
chatStateEl.textContent = text(message) || "No messages here yet.";
|
|
@@ -304,6 +364,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
304
364
|
function buildBubble(entry, { showAuthor, lastOfRun, firstOfRun }) {
|
|
305
365
|
const row = document.createElement("div");
|
|
306
366
|
row.className = `bubble-row ${entry.mine ? "mine" : "theirs"}`;
|
|
367
|
+
// Named so the conversation can be scrolled to one particular message.
|
|
368
|
+
if (entry.relayId) row.dataset.relayId = entry.relayId;
|
|
307
369
|
if (firstOfRun) row.classList.add("first-of-run");
|
|
308
370
|
if (lastOfRun) row.classList.add("last-of-run");
|
|
309
371
|
if (entry.unread) row.classList.add("unread");
|
|
@@ -638,11 +700,17 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
|
|
|
638
700
|
chat = null;
|
|
639
701
|
outgoing = [];
|
|
640
702
|
expanded.clear();
|
|
703
|
+
landed = false;
|
|
641
704
|
chatRevision += 1;
|
|
642
705
|
updateChip();
|
|
643
|
-
// A new message
|
|
644
|
-
// left the last one on
|
|
645
|
-
|
|
706
|
+
// A new message opens on the face its own content earns, whichever face the
|
|
707
|
+
// reader left the last one on: a chat message opens IN its conversation —
|
|
708
|
+
// there is nothing on the reading face the conversation does not already
|
|
709
|
+
// show — and everything else opens on the message itself. main decides;
|
|
710
|
+
// see message-face.cjs.
|
|
711
|
+
const opensInChat = text(row.openFace) === "chat" && Boolean(text(row.threadId));
|
|
712
|
+
if (opensInChat) chatNameEl.textContent = chatTitleHint(row) || "Conversation";
|
|
713
|
+
showFace(opensInChat ? "chat" : "message", { force: true });
|
|
646
714
|
setNote("");
|
|
647
715
|
refreshComposer();
|
|
648
716
|
|