relay-companion 0.1.320 → 0.1.321
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/overlay/inbox.html +184 -22
- package/overlay/main.cjs +116 -43
- package/overlay/preload.cjs +9 -0
- package/overlay/read-receipts.cjs +11 -1
- package/package.json +1 -1
- package/src/outbox.cjs +468 -0
package/overlay/inbox.html
CHANGED
|
@@ -1525,6 +1525,10 @@
|
|
|
1525
1525
|
.th-under .th-seen { appearance:none; border:0; padding:0; background:transparent; font-family:var(--sans); font-size:10px; color:var(--muted-2); opacity:.75; }
|
|
1526
1526
|
.th-under button.th-seen { cursor:pointer; }
|
|
1527
1527
|
.th-under button.th-seen:hover { color:var(--ink); opacity:1; }
|
|
1528
|
+
/* A message the server refused stays a whisper — it is not an alarm — but it
|
|
1529
|
+
has to read as a problem, and it carries the only two acts left on it. */
|
|
1530
|
+
.th-under .th-seen.warn { color:var(--danger); opacity:.95; }
|
|
1531
|
+
.th-under button.th-seen.act { text-decoration:underline; text-underline-offset:2px; opacity:.95; }
|
|
1528
1532
|
.th-receipt-panel { width:min(280px, calc(100% - 32px)); margin:5px 16px 2px auto; padding:8px 10px;
|
|
1529
1533
|
border:1px solid var(--hair); border-radius:var(--r-2); background:var(--wash); font-family:var(--sans); }
|
|
1530
1534
|
.th-receipt-row { display:flex; justify-content:space-between; gap:16px; padding:3px 0; font-size:10.5px; color:var(--ink-2); }
|
|
@@ -6019,32 +6023,45 @@
|
|
|
6019
6023
|
textLike:true, request:false, pending:true,
|
|
6020
6024
|
reactions:{ aggregates:[], events:[] }, preview:"",
|
|
6021
6025
|
});
|
|
6026
|
+
// Same contract as the room composer: Send commits the reply to this
|
|
6027
|
+
// device's outbox and answers immediately. The queue owes the
|
|
6028
|
+
// delivery, so a dead connection no longer ends with the words back
|
|
6029
|
+
// under the cursor and a red note.
|
|
6022
6030
|
const res = await window.relay.sendReply({
|
|
6023
6031
|
text,
|
|
6024
6032
|
inReplyToRelayId: r.id,
|
|
6025
6033
|
idempotencyKey,
|
|
6034
|
+
chat: {
|
|
6035
|
+
threadId: r.threadId || r.id,
|
|
6036
|
+
party: sender,
|
|
6037
|
+
partyKey: (r.senderEmail || r.fromEmail)
|
|
6038
|
+
? `email:${String(r.senderEmail || r.fromEmail).trim().toLowerCase()}`
|
|
6039
|
+
: `name:${String(sender).trim().toLowerCase()}`,
|
|
6040
|
+
isGroup: false,
|
|
6041
|
+
groupName: "",
|
|
6042
|
+
},
|
|
6026
6043
|
}).catch((error) => ({ ok:false, error:String(error?.message || error) }));
|
|
6027
6044
|
readerReplySending = false;
|
|
6028
6045
|
send.disabled = false;
|
|
6029
6046
|
if (!res?.ok) {
|
|
6047
|
+
// Only a device that cannot hold the message at all gets here.
|
|
6030
6048
|
optimisticChatReplies.delete(idempotencyKey);
|
|
6031
6049
|
setRowNote(r.id, res?.error || "Reply did not send.", "err");
|
|
6032
6050
|
input.focus();
|
|
6033
6051
|
return;
|
|
6034
6052
|
}
|
|
6035
6053
|
const optimistic = optimisticChatReplies.get(idempotencyKey);
|
|
6036
|
-
if (optimistic) {
|
|
6037
|
-
optimistic.
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
// canonical transcript row.
|
|
6042
|
-
optimistic.groupSendId = String((res.result && res.result.groupSendId) || "");
|
|
6043
|
-
optimistic.threadId = String((res.result && res.result.threadId) || optimistic.threadId);
|
|
6044
|
-
optimistic.pending = false;
|
|
6054
|
+
if (optimistic && res.entry) {
|
|
6055
|
+
optimistic.fromOutbox = true;
|
|
6056
|
+
optimistic.outboxId = String(res.entry.id || idempotencyKey);
|
|
6057
|
+
optimistic.outboxState = String(res.entry.state || "queued");
|
|
6058
|
+
optimistic.outboxAttempts = Number(res.entry.attempts || 0);
|
|
6045
6059
|
}
|
|
6046
6060
|
input.value = "";
|
|
6047
6061
|
readerHumanDrafts.delete(String(r.id));
|
|
6062
|
+
// "Queued" would be a lie the instant the network is fine, and "Sent"
|
|
6063
|
+
// would be one while it is not. The message's own bubble carries the
|
|
6064
|
+
// state; this note only confirms the composer let go of it.
|
|
6048
6065
|
setRowNote(r.id, `Sent to ${sender}.`, "ok");
|
|
6049
6066
|
fadeRowNoteLater(r.id, 3000);
|
|
6050
6067
|
renderReader();
|
|
@@ -6586,6 +6603,13 @@
|
|
|
6586
6603
|
const state = String((r && r.state) || "").toLowerCase();
|
|
6587
6604
|
return state === "read" || state === "acknowledged";
|
|
6588
6605
|
}
|
|
6606
|
+
// The rung below Read. The server's relay state is a ladder — pending (the
|
|
6607
|
+
// API has it) → delivered (it has been routed to the recipient's device, or
|
|
6608
|
+
// to the email that stands in for one) → read — and "delivered" is the fact
|
|
6609
|
+
// a sender wants when the recipient has not opened it yet.
|
|
6610
|
+
function sentIsDelivered(r) {
|
|
6611
|
+
return String((r && r.state) || "").toLowerCase() === "delivered" || sentIsRead(r);
|
|
6612
|
+
}
|
|
6589
6613
|
function sentDeliveryLabel(r) {
|
|
6590
6614
|
const d = (r && r.delivery) || {};
|
|
6591
6615
|
const state = String(d.state || r.state || "").toLowerCase();
|
|
@@ -8060,6 +8084,68 @@
|
|
|
8060
8084
|
// canonical relay id appears on either side of the local payload.
|
|
8061
8085
|
const optimisticChatReplies = new Map(); // idempotencyKey -> thread-message row
|
|
8062
8086
|
const chatReplySending = new Set(); // visible room/thread id
|
|
8087
|
+
|
|
8088
|
+
/**
|
|
8089
|
+
* Mirror the device's send queue into the room transcript.
|
|
8090
|
+
*
|
|
8091
|
+
* An unsent message used to live ONLY here, in renderer memory, and only for
|
|
8092
|
+
* as long as one send attempt took. That is why a send on weak wifi ended
|
|
8093
|
+
* with the words back in the composer: when the attempt failed there was
|
|
8094
|
+
* nothing left holding them. The queue in the main process is now the record,
|
|
8095
|
+
* and this projects it — so a message typed offline keeps its place in the
|
|
8096
|
+
* conversation across repaints, room switches, and quitting the pill.
|
|
8097
|
+
*/
|
|
8098
|
+
function syncOutboxProjection() {
|
|
8099
|
+
const live = new Set();
|
|
8100
|
+
for (const entry of payload.outbox || []) {
|
|
8101
|
+
if (!entry || !entry.id) continue;
|
|
8102
|
+
const key = String(entry.id);
|
|
8103
|
+
live.add(key);
|
|
8104
|
+
const chat = entry.chat || {};
|
|
8105
|
+
const files = Array.isArray(entry.files) ? entry.files : [];
|
|
8106
|
+
const existing = optimisticChatReplies.get(key) || {};
|
|
8107
|
+
optimisticChatReplies.set(key, {
|
|
8108
|
+
...existing,
|
|
8109
|
+
id: entry.relayId || `outbox:${key}`,
|
|
8110
|
+
fromOutbox: true,
|
|
8111
|
+
outboxId: key,
|
|
8112
|
+
// "queued" is the honest word for what the clock means in every other
|
|
8113
|
+
// messenger: this device has it, the server does not yet.
|
|
8114
|
+
outboxState: String(entry.state || "queued"),
|
|
8115
|
+
outboxAttempts: Number(entry.attempts || 0),
|
|
8116
|
+
outboxError: String(entry.lastError || ""),
|
|
8117
|
+
relayId: String(entry.relayId || ""),
|
|
8118
|
+
groupSendId: String(entry.groupSendId || ""),
|
|
8119
|
+
threadId: String(entry.threadId || chat.threadId || ""),
|
|
8120
|
+
inReplyToRelayId: String(entry.inReplyToRelayId || ""),
|
|
8121
|
+
direction: "out",
|
|
8122
|
+
seen: false,
|
|
8123
|
+
seenAt: null,
|
|
8124
|
+
readReceipts: [],
|
|
8125
|
+
// Render-model field, not the wire title: a text bubble paints m.title.
|
|
8126
|
+
title: entry.text || (files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files`),
|
|
8127
|
+
body: entry.text || "",
|
|
8128
|
+
party: chat.party || "",
|
|
8129
|
+
partyKey: chat.partyKey || "",
|
|
8130
|
+
at: entry.createdAt,
|
|
8131
|
+
isGroup: Boolean(chat.isGroup),
|
|
8132
|
+
groupId: chat.groupId || "",
|
|
8133
|
+
groupName: chat.groupName || "",
|
|
8134
|
+
unread: false,
|
|
8135
|
+
textLike: true,
|
|
8136
|
+
request: false,
|
|
8137
|
+
pending: String(entry.state || "queued") !== "sent",
|
|
8138
|
+
reactions: { aggregates: [], events: [] },
|
|
8139
|
+
preview: "",
|
|
8140
|
+
});
|
|
8141
|
+
}
|
|
8142
|
+
// A row this projection put here and the queue has since retired is gone
|
|
8143
|
+
// for good — its canonical relay is in the Sent list now. Rows the composer
|
|
8144
|
+
// created moments ago are left alone; they retire on the canonical match.
|
|
8145
|
+
for (const [key, row] of optimisticChatReplies) {
|
|
8146
|
+
if (row && row.fromOutbox && !live.has(key)) optimisticChatReplies.delete(key);
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8063
8149
|
// The room composer is wired ONCE and outlives every repaint, so its
|
|
8064
8150
|
// listeners dispatch through this ref instead of capturing one render's
|
|
8065
8151
|
// closure. Re-binding per render would stack a stale sender per repaint.
|
|
@@ -8103,6 +8189,28 @@
|
|
|
8103
8189
|
function receiptFor(m, msgs) {
|
|
8104
8190
|
return RelayReadReceipts.forLatest(m, msgs, timeAgo);
|
|
8105
8191
|
}
|
|
8192
|
+
// The rungs BELOW the read receipt: this device has it, and this device is
|
|
8193
|
+
// waiting. Said in the same whisper, because a message on its way is not an
|
|
8194
|
+
// event — only a message that will not go is.
|
|
8195
|
+
//
|
|
8196
|
+
// "Waiting for network" is deliberately the second word rather than the
|
|
8197
|
+
// first: one failed attempt is a hiccup, and a composer that announces the
|
|
8198
|
+
// wifi every time someone presses Send is noise. It appears once the queue
|
|
8199
|
+
// has actually had to wait for a connection.
|
|
8200
|
+
function outboxStatusBits(m) {
|
|
8201
|
+
const state = String(m.outboxState || "");
|
|
8202
|
+
if (state === "failed") {
|
|
8203
|
+
return [
|
|
8204
|
+
`<span class="th-seen warn">Not sent</span>`,
|
|
8205
|
+
`<button type="button" class="th-seen act" data-outbox-retry="${esc(m.outboxId)}" data-stop="1">Retry</button>`,
|
|
8206
|
+
`<button type="button" class="th-seen act" data-outbox-discard="${esc(m.outboxId)}" data-stop="1">Delete</button>`,
|
|
8207
|
+
];
|
|
8208
|
+
}
|
|
8209
|
+
if (state === "queued" && Number(m.outboxAttempts || 0) >= 1) {
|
|
8210
|
+
return [`<span class="th-seen">Waiting for network</span>`];
|
|
8211
|
+
}
|
|
8212
|
+
return [`<span class="th-seen">Sending…</span>`];
|
|
8213
|
+
}
|
|
8106
8214
|
function receiptTimestamp(value) {
|
|
8107
8215
|
const date = new Date(value || "");
|
|
8108
8216
|
if (!Number.isFinite(date.getTime())) return "Seen";
|
|
@@ -8168,11 +8276,18 @@
|
|
|
8168
8276
|
// single outbound copy per groupSendId or the transcript shows doubles.
|
|
8169
8277
|
const seenGroupSends = new Set();
|
|
8170
8278
|
const groupReceiptMembers = new Map();
|
|
8279
|
+
// A fan-out is delivered when EVERY sibling is — the same rule the two
|
|
8280
|
+
// ticks follow in any group chat. The collapse below keeps one sibling, so
|
|
8281
|
+
// asking that survivor alone would call a room delivered on the strength of
|
|
8282
|
+
// whichever member happened to be sorted first.
|
|
8283
|
+
const groupDelivered = new Map();
|
|
8171
8284
|
for (const member of payload.sent || []) {
|
|
8172
8285
|
if (!member.groupSendId) continue;
|
|
8173
8286
|
const bucket = groupReceiptMembers.get(member.groupSendId) || [];
|
|
8174
8287
|
bucket.push({ name:sentRecipient(member), seen:sentIsRead(member), readAt:member.readAt || "" });
|
|
8175
8288
|
groupReceiptMembers.set(member.groupSendId, bucket);
|
|
8289
|
+
const soFar = groupDelivered.get(member.groupSendId);
|
|
8290
|
+
groupDelivered.set(member.groupSendId, soFar === false ? false : sentIsDelivered(member));
|
|
8176
8291
|
}
|
|
8177
8292
|
for (const s of payload.sent || []) {
|
|
8178
8293
|
const id = s.relayId || s.id;
|
|
@@ -8189,6 +8304,7 @@
|
|
|
8189
8304
|
}
|
|
8190
8305
|
msgs.push({
|
|
8191
8306
|
id, threadId: s.threadId || id, direction: "out", seen: sentIsRead(s), seenAt: s.readAt || null,
|
|
8307
|
+
delivered: s.groupSendId ? groupDelivered.get(s.groupSendId) === true : sentIsDelivered(s),
|
|
8192
8308
|
inReplyToRelayId: s.inReplyToRelayId || "",
|
|
8193
8309
|
addressRecipient: s.recipientGroupId ? { groupId:s.recipientGroupId } : (s.recipient || null),
|
|
8194
8310
|
readReceipts: s.groupSendId
|
|
@@ -8911,7 +9027,7 @@
|
|
|
8911
9027
|
const receipt = receiptFor(m, msgs);
|
|
8912
9028
|
const bits = [];
|
|
8913
9029
|
if (!m.request && !groupPostingBlocked) bits.push(`<button type="button" class="th-reply-ghost" data-reply-to="${esc(m.id)}" data-stop="1">reply</button>`);
|
|
8914
|
-
if (mine && m.pending) bits.push(
|
|
9030
|
+
if (mine && m.pending) bits.push(...outboxStatusBits(m));
|
|
8915
9031
|
if (receipt) bits.push(receipt.expandable
|
|
8916
9032
|
? `<button type="button" class="th-seen" data-receipt-toggle="${esc(m.id)}" data-stop="1" aria-expanded="${expandedReceiptIds.has(m.id) ? "true" : "false"}">${esc(receipt.label)}</button>`
|
|
8917
9033
|
: `<span class="th-seen">${esc(receipt.label)}</span>`);
|
|
@@ -9083,7 +9199,7 @@
|
|
|
9083
9199
|
seen: false,
|
|
9084
9200
|
seenAt: null,
|
|
9085
9201
|
readReceipts: [],
|
|
9086
|
-
// Render-model field, not the wire title (
|
|
9202
|
+
// Render-model field, not the wire title (the send path sends no
|
|
9087
9203
|
// title for typed text): textLike bubbles paint m.title as the text.
|
|
9088
9204
|
title: text || (files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files`),
|
|
9089
9205
|
body: text,
|
|
@@ -9108,15 +9224,34 @@
|
|
|
9108
9224
|
thQrInput.relayResize?.();
|
|
9109
9225
|
threadComposerDrafts.delete(threadStateKey);
|
|
9110
9226
|
renderThreadDetail();
|
|
9227
|
+
// Send COMMITS the message to this device. The answer is immediate on
|
|
9228
|
+
// any network, because the network is no longer in this path: the
|
|
9229
|
+
// outbox owes the delivery and reports its own progress through the
|
|
9230
|
+
// payload. The old code awaited the send here, and a fifteen-second
|
|
9231
|
+
// timeout on weak wifi ended with the bubble deleted and the words
|
|
9232
|
+
// pushed back under the cursor (David, Granular, 2026-08-20).
|
|
9111
9233
|
let res;
|
|
9112
9234
|
try {
|
|
9113
|
-
res = await window.relay.sendReply({
|
|
9235
|
+
res = await window.relay.sendReply({
|
|
9236
|
+
text, recipient, ...(inReplyToRelayId ? { inReplyToRelayId } : {}), files, idempotencyKey,
|
|
9237
|
+
// The room the queue is speaking into, so a bubble rebuilt from the
|
|
9238
|
+
// queue after a restart knows which conversation it belongs to.
|
|
9239
|
+
chat: {
|
|
9240
|
+
threadId: sendAnchor.threadId || thread.threadId || "",
|
|
9241
|
+
party: sendAnchor.party || thread.party || "",
|
|
9242
|
+
partyKey: sendAnchor.partyKey || thread.partyKey || "",
|
|
9243
|
+
isGroup: Boolean(sendAnchor.isGroup || thread.isGroup),
|
|
9244
|
+
groupId: sendAnchor.groupId || thread.groupId || "",
|
|
9245
|
+
groupName: sendAnchor.groupName || thread.groupName || "",
|
|
9246
|
+
},
|
|
9247
|
+
});
|
|
9114
9248
|
} catch (error) {
|
|
9115
9249
|
res = { ok: false, error: error && error.message ? error.message : "Send failed — try again." };
|
|
9116
9250
|
}
|
|
9117
9251
|
if (!res || !res.ok) {
|
|
9118
|
-
// The
|
|
9119
|
-
//
|
|
9252
|
+
// The device itself refused to hold the message — not a network
|
|
9253
|
+
// failure, which the queue absorbs, but something it cannot store at
|
|
9254
|
+
// all. Only then do the words belong back in the composer.
|
|
9120
9255
|
optimisticChatReplies.delete(idempotencyKey);
|
|
9121
9256
|
chatReplySending.delete(threadStateKey);
|
|
9122
9257
|
threadComposerDrafts.set(threadStateKey, text);
|
|
@@ -9134,15 +9269,14 @@
|
|
|
9134
9269
|
}
|
|
9135
9270
|
return;
|
|
9136
9271
|
}
|
|
9272
|
+
// The queue's own record supersedes this bubble from the next payload
|
|
9273
|
+
// onward; until then it keeps the row on screen under the same key.
|
|
9137
9274
|
const optimistic = optimisticChatReplies.get(idempotencyKey);
|
|
9138
|
-
if (optimistic) {
|
|
9139
|
-
optimistic.
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
optimistic.groupSendId = String((res.result && res.result.groupSendId) || "");
|
|
9144
|
-
optimistic.threadId = String((res.result && res.result.threadId) || optimistic.threadId);
|
|
9145
|
-
optimistic.pending = false;
|
|
9275
|
+
if (optimistic && res.entry) {
|
|
9276
|
+
optimistic.fromOutbox = true;
|
|
9277
|
+
optimistic.outboxId = String(res.entry.id || idempotencyKey);
|
|
9278
|
+
optimistic.outboxState = String(res.entry.state || "queued");
|
|
9279
|
+
optimistic.outboxAttempts = Number(res.entry.attempts || 0);
|
|
9146
9280
|
}
|
|
9147
9281
|
threadComposerDrafts.delete(threadStateKey);
|
|
9148
9282
|
threadReplyTargets.delete(threadStateKey);
|
|
@@ -9173,6 +9307,25 @@
|
|
|
9173
9307
|
if (box) { box.focus(); box.scrollIntoView({ block: "nearest" }); }
|
|
9174
9308
|
});
|
|
9175
9309
|
}
|
|
9310
|
+
// The two acts left on a message the server refused. Retry puts it back in
|
|
9311
|
+
// line; Delete is the only path that ever throws typed words away, and it
|
|
9312
|
+
// is the human's own hand doing it.
|
|
9313
|
+
for (const b of thHistoryEl.querySelectorAll("[data-outbox-retry]")) {
|
|
9314
|
+
b.addEventListener("click", async (e) => {
|
|
9315
|
+
e.stopPropagation();
|
|
9316
|
+
const id = String(b.getAttribute("data-outbox-retry") || "");
|
|
9317
|
+
if (!id || !window.relay.outboxRetry) return;
|
|
9318
|
+
await window.relay.outboxRetry(id).catch(() => {});
|
|
9319
|
+
});
|
|
9320
|
+
}
|
|
9321
|
+
for (const b of thHistoryEl.querySelectorAll("[data-outbox-discard]")) {
|
|
9322
|
+
b.addEventListener("click", async (e) => {
|
|
9323
|
+
e.stopPropagation();
|
|
9324
|
+
const id = String(b.getAttribute("data-outbox-discard") || "");
|
|
9325
|
+
if (!id || !window.relay.outboxDiscard) return;
|
|
9326
|
+
await window.relay.outboxDiscard(id).catch(() => {});
|
|
9327
|
+
});
|
|
9328
|
+
}
|
|
9176
9329
|
wireReplyCancel(thHistoryEl, threadStateKey);
|
|
9177
9330
|
for (const b of thHistoryEl.querySelectorAll("[data-reply-ref]")) {
|
|
9178
9331
|
b.addEventListener("click", (e) => {
|
|
@@ -10296,9 +10449,14 @@
|
|
|
10296
10449
|
features: next.features || payload.features,
|
|
10297
10450
|
relays: Array.isArray(next.relays) ? next.relays : [],
|
|
10298
10451
|
sent: Array.isArray(next.sent) ? next.sent : [],
|
|
10452
|
+
outbox: Array.isArray(next.outbox) ? next.outbox : [],
|
|
10299
10453
|
requests: Array.isArray(next.requests) ? next.requests : [],
|
|
10300
10454
|
contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
|
|
10301
10455
|
};
|
|
10456
|
+
// The device's own queue is the truth about unsent messages; this renderer
|
|
10457
|
+
// only mirrors it. Doing this on every payload is what lets a queued bubble
|
|
10458
|
+
// survive a repaint, a room switch and a restart of the pill itself.
|
|
10459
|
+
syncOutboxProjection();
|
|
10302
10460
|
if (payload.features?.requests !== true && ["requests", "requestDetail"].includes(activeView)) {
|
|
10303
10461
|
activeView = "relays";
|
|
10304
10462
|
}
|
|
@@ -10321,6 +10479,10 @@
|
|
|
10321
10479
|
}
|
|
10322
10480
|
|
|
10323
10481
|
window.relay.onInbox(onPayload);
|
|
10482
|
+
// The renderer holds the only connectivity signal either process has. A
|
|
10483
|
+
// regained network flushes the queue at once — a message must not sit out
|
|
10484
|
+
// the backoff its last attempt earned during an outage that is over.
|
|
10485
|
+
window.addEventListener("online", () => { window.relay.networkOnline?.().catch?.(() => {}); });
|
|
10324
10486
|
if (window.relay.onNewRelay) window.relay.onNewRelay((row, opts) => {
|
|
10325
10487
|
if (opts && opts.ghost) ghostArrival(row); // pill dismissed -> free-standing banner
|
|
10326
10488
|
else notifyArrival(row, opts);
|
package/overlay/main.cjs
CHANGED
|
@@ -99,11 +99,13 @@ const { openingFaceFor } = require("./message-face.cjs");
|
|
|
99
99
|
const perf = require("./perf-counters.cjs");
|
|
100
100
|
const { fittedOverlayBounds, shouldIgnoreOverlayMouse } = require("./window-fit.cjs");
|
|
101
101
|
const { productFeatures } = require("../src/product-features.cjs");
|
|
102
|
+
const { createOutbox } = require("../src/outbox.cjs");
|
|
102
103
|
|
|
103
104
|
const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
|
|
104
105
|
const STATE_PATH = path.join(RELAY_HOME, "state.json");
|
|
105
106
|
const SCHEDULES_PATH = path.join(RELAY_HOME, "schedules.json");
|
|
106
107
|
const PILL_STATUS_PATH = path.join(RELAY_HOME, "pill-status.json");
|
|
108
|
+
const OUTBOX_PATH = path.join(RELAY_HOME, "outbox.json");
|
|
107
109
|
// The companion CLI entrypoint (same path the overlay resolves its ESM modules
|
|
108
110
|
// from). Spawned with ELECTRON_RUN_AS_NODE so Electron runs it as plain Node.
|
|
109
111
|
const RELAY_CLI = path.resolve(__dirname, "..", "bin", "relay.js");
|
|
@@ -1084,6 +1086,16 @@ async function refreshSent() {
|
|
|
1084
1086
|
const res = await client.sent({ limit: SENT_FETCH_LIMIT });
|
|
1085
1087
|
sentCache = Array.isArray(res && res.items) ? res.items : [];
|
|
1086
1088
|
sentFingerprint = sentFingerprintOf(sentCache);
|
|
1089
|
+
// A queued message retires against the SERVER's own view, never against our
|
|
1090
|
+
// record of a response: the canonical row is the same evidence the renderer
|
|
1091
|
+
// uses to retire the bubble, so the two can never disagree on screen.
|
|
1092
|
+
outbox.retireConfirmed({
|
|
1093
|
+
relayIds: sentCache.map((r) => r && (r.relayId || r.id)).filter(Boolean),
|
|
1094
|
+
groupSendIds: sentCache.map((r) => r && r.groupSendId).filter(Boolean),
|
|
1095
|
+
});
|
|
1096
|
+
// This poll just proved the network is up. Anything still waiting out a
|
|
1097
|
+
// backoff earned during the outage is due now.
|
|
1098
|
+
if (outbox.pendingCount()) outbox.resume();
|
|
1087
1099
|
} catch (error) {
|
|
1088
1100
|
console.error("[overlay] listSent failed:", error && error.message);
|
|
1089
1101
|
}
|
|
@@ -1313,6 +1325,21 @@ async function deleteContactFromBook(input) {
|
|
|
1313
1325
|
return { ok: true, contacts: await contactsAfterWrite(), deletedContactId: contactId };
|
|
1314
1326
|
}
|
|
1315
1327
|
|
|
1328
|
+
// ---- the send outbox ------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
// Pressing Send commits the message to this device; the queue owes the human
|
|
1331
|
+
// the delivery from there, across a dead connection and across a restart. See
|
|
1332
|
+
// src/outbox.cjs for why that is the only behaviour that keeps a message when
|
|
1333
|
+
// the wifi does not.
|
|
1334
|
+
const outbox = createOutbox({
|
|
1335
|
+
file: OUTBOX_PATH,
|
|
1336
|
+
send: (entry) => postQueuedRelay(entry),
|
|
1337
|
+
// Every state change is a bubble changing under someone's eyes: queued to
|
|
1338
|
+
// sent, an attempt that failed, a message that will not go. Repaint.
|
|
1339
|
+
onChange: () => pushInboxQuiet(),
|
|
1340
|
+
log: (message, error) => console.error(`[overlay] ${message}`, (error && error.message) || ""),
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1316
1343
|
// ---- payload assembly + push to renderer ---------------------------------
|
|
1317
1344
|
|
|
1318
1345
|
// buildPayload is synchronous over CACHES so the first paint never waits on the
|
|
@@ -1351,6 +1378,11 @@ function buildPayload() {
|
|
|
1351
1378
|
features: PRODUCT_FEATURES,
|
|
1352
1379
|
relays: hydrateReactions(relaysNow),
|
|
1353
1380
|
sent: hydrateReactions(sentWithMaterializationState(sentCache)),
|
|
1381
|
+
// Messages this device has accepted but the server has not confirmed. They
|
|
1382
|
+
// are the renderer's source of truth for unsent bubbles, which is why they
|
|
1383
|
+
// survive a repaint and a restart where an in-renderer optimistic map did
|
|
1384
|
+
// not.
|
|
1385
|
+
outbox: outbox.list(),
|
|
1354
1386
|
tasks: [],
|
|
1355
1387
|
contacts: contactsCache,
|
|
1356
1388
|
};
|
|
@@ -1713,6 +1745,10 @@ async function pushInboxNow(force) {
|
|
|
1713
1745
|
reactionStateFingerprint(r.reactions),
|
|
1714
1746
|
]),
|
|
1715
1747
|
contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
|
|
1748
|
+
// Without these the queue's own progress — waiting, attempted again, sent,
|
|
1749
|
+
// refused — would never reach the renderer: nothing else in the payload
|
|
1750
|
+
// moves while a message sits offline.
|
|
1751
|
+
outbox: (payload.outbox || []).map((e) => [e.id, e.state, e.attempts, e.nextAttemptAt, e.relayId, e.lastError]),
|
|
1716
1752
|
account: [payload.account.paired, payload.account.email],
|
|
1717
1753
|
features: payload.features,
|
|
1718
1754
|
});
|
|
@@ -3594,6 +3630,10 @@ function createWindow() {
|
|
|
3594
3630
|
pushInbox(true)
|
|
3595
3631
|
.then(() => reconcileCanonicalCompletionMonitors())
|
|
3596
3632
|
.catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
3633
|
+
// Anything typed before the last quit — or before the last crash — goes out
|
|
3634
|
+
// now. A message the human already pressed Send on is owed a delivery, and
|
|
3635
|
+
// the app restarting is not their problem.
|
|
3636
|
+
outbox.start();
|
|
3597
3637
|
pillReady = true;
|
|
3598
3638
|
// Relay is independently useful and searchable even when no host app happens to
|
|
3599
3639
|
// be open. A normal login launch still respects the persisted dismissed preference.
|
|
@@ -6244,55 +6284,88 @@ ipcMain.handle("relay:taskStatus", async (_e, taskId) => {
|
|
|
6244
6284
|
// path. The recipient addresses the room; inReplyToRelayId is present only
|
|
6245
6285
|
// when the human deliberately selected a message to quote. Files arrive as {path} when
|
|
6246
6286
|
// the OS gave one (picker, drag), else {name, contentBase64} (paste) — the
|
|
6247
|
-
//
|
|
6248
|
-
|
|
6287
|
+
// outbox spools both into bytes it owns so ONE pipeline prepares them all.
|
|
6288
|
+
//
|
|
6289
|
+
// This function is the TRANSPORT ONLY, and it THROWS. Whether a failure is
|
|
6290
|
+
// worth waiting out is the outbox's judgement, and it needs the real error —
|
|
6291
|
+
// a status, an abort, a DNS code — to make it. Flattening every failure into
|
|
6292
|
+
// `{ ok: false, error: "fetch failed" }` here is what left the composer unable
|
|
6293
|
+
// to tell a dead wifi from a rejected message.
|
|
6294
|
+
async function postQueuedRelay(entry) {
|
|
6295
|
+
const client = await relayClient();
|
|
6296
|
+
const attachmentsUrl = pathToFileURL(path.join(__dirname, "..", "src", "attachments.js")).href;
|
|
6297
|
+
const { prepareOrdinaryRelayAttachments } = await import(attachmentsUrl);
|
|
6298
|
+
const text = String(entry.text || "").trim();
|
|
6299
|
+
const files = Array.isArray(entry.files) ? entry.files : [];
|
|
6300
|
+
// The queue holds its own copy of every attachment, so the send reads from
|
|
6301
|
+
// the spool — the picker's original may have been moved or deleted during a
|
|
6302
|
+
// long offline stretch. `name` rides along because the spool file is stored
|
|
6303
|
+
// under an index-prefixed name and the human's filename is what shows.
|
|
6304
|
+
const prepared = await prepareOrdinaryRelayAttachments({
|
|
6305
|
+
files: files
|
|
6306
|
+
.filter((f) => f && f.spoolPath)
|
|
6307
|
+
.map((f) => ({ path: f.spoolPath, name: f.name, ...(f.contentType ? { contentType: f.contentType } : {}) })),
|
|
6308
|
+
});
|
|
6309
|
+
const explicit = entry.recipient || {};
|
|
6310
|
+
const hasRecipient = explicit.email || explicit.contactId || explicit.relayUserId || explicit.groupId || explicit.chatId;
|
|
6311
|
+
return client.sendRelay({
|
|
6312
|
+
recipient: hasRecipient ? explicit : {},
|
|
6313
|
+
kind: "message",
|
|
6314
|
+
// A typed text sends no title — titlelessness IS what marks it as a
|
|
6315
|
+
// text. A file-only send keeps a label: the file is the content and the
|
|
6316
|
+
// name is the only thing there is to show for it.
|
|
6317
|
+
...(text
|
|
6318
|
+
? {}
|
|
6319
|
+
: { title: files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files` }),
|
|
6320
|
+
forHuman: text || " ",
|
|
6321
|
+
attachments: prepared,
|
|
6322
|
+
...(entry.inReplyToRelayId ? { inReplyToRelayId: String(entry.inReplyToRelayId) } : {}),
|
|
6323
|
+
idempotencyKey: entry.idempotencyKey,
|
|
6324
|
+
// Human-authored pill text must never enter the MCP-only forHuman review
|
|
6325
|
+
// gate. Keep this explicit even though the API now positively gates only
|
|
6326
|
+
// relay-mcp: source also drives trustworthy provider attribution.
|
|
6327
|
+
source: { host: "relay-preview" },
|
|
6328
|
+
});
|
|
6329
|
+
}
|
|
6330
|
+
|
|
6331
|
+
// Send hands the message to the DEVICE. The queue owes the delivery from there:
|
|
6332
|
+
// it retries on its own schedule, resumes across restarts, and never hands the
|
|
6333
|
+
// words back to the composer for anything a working connection would have
|
|
6334
|
+
// fixed. The composer no longer waits on the network at all, so the answer here
|
|
6335
|
+
// is immediate whatever the wifi is doing.
|
|
6336
|
+
function enqueueReplyFromPill(input = {}) {
|
|
6337
|
+
const crypto = require("crypto");
|
|
6338
|
+
const text = String(input.text || "").trim();
|
|
6339
|
+
const files = Array.isArray(input.files) ? input.files : [];
|
|
6340
|
+
if (!text && !files.length) return { ok: false, error: "empty reply" };
|
|
6249
6341
|
try {
|
|
6250
|
-
const
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
const files = Array.isArray(input.files) ? input.files : [];
|
|
6258
|
-
if (!text && !files.length) return { ok: false, error: "empty reply" };
|
|
6259
|
-
const localPaths = files.filter((f) => f && f.path).map((f) => String(f.path));
|
|
6260
|
-
for (const f of files) {
|
|
6261
|
-
if (f && !f.path && f.contentBase64) {
|
|
6262
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "relay-paste-"));
|
|
6263
|
-
const safe = String(f.name || "file").replace(/[\\/]/g, "_");
|
|
6264
|
-
const p2 = path.join(dir, safe);
|
|
6265
|
-
fs.writeFileSync(p2, Buffer.from(String(f.contentBase64), "base64"));
|
|
6266
|
-
localPaths.push(p2);
|
|
6267
|
-
}
|
|
6268
|
-
}
|
|
6269
|
-
const prepared = await prepareOrdinaryRelayAttachments({ files: localPaths });
|
|
6270
|
-
const explicit = input.recipient || {};
|
|
6271
|
-
const hasRecipient = explicit.email || explicit.contactId || explicit.relayUserId || explicit.groupId || explicit.chatId;
|
|
6272
|
-
const res = await client.sendRelay({
|
|
6273
|
-
recipient: hasRecipient ? explicit : {},
|
|
6274
|
-
kind: "message",
|
|
6275
|
-
// A typed text sends no title — titlelessness IS what marks it as a
|
|
6276
|
-
// text. A file-only send keeps a label: the file is the content and the
|
|
6277
|
-
// name is the only thing there is to show for it.
|
|
6278
|
-
...(text
|
|
6279
|
-
? {}
|
|
6280
|
-
: { title: files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files` }),
|
|
6281
|
-
forHuman: text || " ",
|
|
6282
|
-
attachments: prepared,
|
|
6283
|
-
...(input.inReplyToRelayId ? { inReplyToRelayId: String(input.inReplyToRelayId) } : {}),
|
|
6284
|
-
idempotencyKey: replyIdempotencyKey,
|
|
6285
|
-
// Human-authored pill text must never enter the MCP-only forHuman review
|
|
6286
|
-
// gate. Keep this explicit even though the API now positively gates only
|
|
6287
|
-
// relay-mcp: source also drives trustworthy provider attribution.
|
|
6288
|
-
source: { host: "relay-preview" },
|
|
6342
|
+
const entry = outbox.enqueue({
|
|
6343
|
+
idempotencyKey: String(input.idempotencyKey || "").trim() || `pill-reply-${crypto.randomUUID()}`,
|
|
6344
|
+
text,
|
|
6345
|
+
recipient: input.recipient || {},
|
|
6346
|
+
inReplyToRelayId: input.inReplyToRelayId,
|
|
6347
|
+
files,
|
|
6348
|
+
chat: input.chat || {},
|
|
6289
6349
|
});
|
|
6290
|
-
|
|
6350
|
+
outbox.kick(0);
|
|
6351
|
+
return { ok: true, queued: true, entry };
|
|
6291
6352
|
} catch (error) {
|
|
6292
6353
|
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
6293
6354
|
}
|
|
6294
6355
|
}
|
|
6295
|
-
ipcMain.handle("relay:sendReply", (_e, input) =>
|
|
6356
|
+
ipcMain.handle("relay:sendReply", (_e, input) => enqueueReplyFromPill(input));
|
|
6357
|
+
// The human pressed Retry on a message the server refused, or asked to throw it
|
|
6358
|
+
// away. Both are deliberate acts on a message they can still see.
|
|
6359
|
+
ipcMain.handle("relay:outboxRetry", (_e, id) => ({ ok: outbox.retry(String(id || "")) }));
|
|
6360
|
+
ipcMain.handle("relay:outboxDiscard", (_e, id) => ({ ok: outbox.retire(String(id || "")) }));
|
|
6361
|
+
// The renderer owns the only honest connectivity signal in an Electron app
|
|
6362
|
+
// (`window.online`). A regained connection flushes the queue immediately rather
|
|
6363
|
+
// than waiting out whatever backoff the last failure earned.
|
|
6364
|
+
ipcMain.handle("relay:networkOnline", () => {
|
|
6365
|
+
outbox.reload();
|
|
6366
|
+
outbox.start();
|
|
6367
|
+
return { ok: true };
|
|
6368
|
+
});
|
|
6296
6369
|
ipcMain.handle("relay:react", async (_e, input = {}) => {
|
|
6297
6370
|
const id = String(input.id || "").trim();
|
|
6298
6371
|
const emoji = String(input.emoji || "").trim();
|
package/overlay/preload.cjs
CHANGED
|
@@ -70,7 +70,16 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
70
70
|
scheduleSave: (input) => ipcRenderer.invoke("relay:scheduleSave", input || {}),
|
|
71
71
|
ack: (id) => ipcRenderer.send("relay:ack", id),
|
|
72
72
|
// A REAL send: {text, inReplyToRelayId?, recipient?, files:[{path}|{name,contentBase64,contentType}]}
|
|
73
|
+
// Answers as soon as the message is committed to this device's outbox, not
|
|
74
|
+
// when the server has it — the composer never waits on the network.
|
|
73
75
|
sendReply: (payload) => ipcRenderer.invoke("relay:sendReply", payload),
|
|
76
|
+
// Deliberate acts on a message the server refused: send it again, or bin it.
|
|
77
|
+
outboxRetry: (id) => ipcRenderer.invoke("relay:outboxRetry", id),
|
|
78
|
+
outboxDiscard: (id) => ipcRenderer.invoke("relay:outboxDiscard", id),
|
|
79
|
+
// `window.online` is the only connectivity signal either process actually
|
|
80
|
+
// has; the renderer forwards it so a regained network flushes the queue at
|
|
81
|
+
// once instead of waiting out the last failure's backoff.
|
|
82
|
+
networkOnline: () => ipcRenderer.invoke("relay:networkOnline"),
|
|
74
83
|
react: (id, emoji, action) => ipcRenderer.invoke("relay:react", { id, emoji, action }),
|
|
75
84
|
// Electron>=32 removed File.path — the preload's webUtils is the only way
|
|
76
85
|
// a renderer file picker/drop can yield a real path for the send pipeline.
|
|
@@ -45,7 +45,17 @@
|
|
|
45
45
|
const readers = members
|
|
46
46
|
.filter((member) => member.seen)
|
|
47
47
|
.sort((a, b) => (timestamp(a.readAt) ?? Infinity) - (timestamp(b.readAt) ?? Infinity) || a.name.localeCompare(b.name));
|
|
48
|
-
if (!readers.length)
|
|
48
|
+
if (!readers.length) {
|
|
49
|
+
// THE LADDER, and the rungs before anyone has read it. The queue owns
|
|
50
|
+
// what happens on this device; from the moment the server has the
|
|
51
|
+
// message these two are what is true, and the sender is owed them —
|
|
52
|
+
// "Sent" alone cannot distinguish a message sitting in the API from one
|
|
53
|
+
// already on the recipient's device. `delivered` is the server's own
|
|
54
|
+
// relay state, not a guess: it is set when the relay is routed to the
|
|
55
|
+
// recipient (their device, or the email that stands in for it).
|
|
56
|
+
const label = message.delivered ? "Delivered" : "Sent";
|
|
57
|
+
return { label, readers: [], unread: members, expandable: false };
|
|
58
|
+
}
|
|
49
59
|
const unread = members.filter((member) => !member.seen);
|
|
50
60
|
const showTime = (value) => (value && typeof formatTime === "function" ? formatTime(value) : "");
|
|
51
61
|
const isGroup = Boolean(message.isGroup) && members.length > 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-companion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.321",
|
|
4
4
|
"description": "Companion CLI for Relay (sendrelays.com): pairs this machine with your Relay account so coding agents like Claude Code and Codex can send and receive Relay messages.",
|
|
5
5
|
"homepage": "https://sendrelays.com",
|
|
6
6
|
"repository": {
|
package/src/outbox.cjs
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// The send outbox: a message you have typed is COMMITTED, not attempted.
|
|
2
|
+
//
|
|
3
|
+
// Before this, the composer awaited the network. `sendReplyFromPill` made one
|
|
4
|
+
// HTTP attempt with a 15s abort, and on any failure the renderer deleted the
|
|
5
|
+
// optimistic bubble and pushed the words back into the textarea. On a weak or
|
|
6
|
+
// absent connection that is exactly what a person sees: the message says
|
|
7
|
+
// "Sending…" for fifteen seconds, disappears, and reappears as an unsent draft
|
|
8
|
+
// under the cursor (David, Granular group, 2026-08-20). Nothing was lost, but
|
|
9
|
+
// the message did not send itself when the connection came back — the human had
|
|
10
|
+
// to notice and press Send again.
|
|
11
|
+
//
|
|
12
|
+
// Every messenger a person has ever used works the other way. Pressing Send
|
|
13
|
+
// hands the words to the device, the device owes you the delivery, and the
|
|
14
|
+
// ladder of states — waiting on this device, accepted by the server, routed to
|
|
15
|
+
// the recipient, read — is reported honestly while that happens. This module is
|
|
16
|
+
// the "device owes you the delivery" half.
|
|
17
|
+
//
|
|
18
|
+
// WHY THIS IS SAFE TO RETRY. Every entry carries the idempotency key minted when
|
|
19
|
+
// the human pressed Send, and the API keys sends on it — including group fan-out,
|
|
20
|
+
// where each sibling derives `<key>:<targetKey>`. A retry after a lost response
|
|
21
|
+
// therefore converges on the relay the first attempt already created instead of
|
|
22
|
+
// posting a second copy. Retrying without that guarantee would trade a bounced
|
|
23
|
+
// draft for duplicate messages, which is worse.
|
|
24
|
+
//
|
|
25
|
+
// CommonJS so the CJS Electron main process requires it directly, the same way
|
|
26
|
+
// it already shares atomic-json.cjs and state-lock.cjs with the ESM daemon.
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
const fs = require("node:fs");
|
|
31
|
+
const path = require("node:path");
|
|
32
|
+
const { atomicWriteJsonSync } = require("./atomic-json.cjs");
|
|
33
|
+
const { withJsonLock } = require("./state-lock.cjs");
|
|
34
|
+
|
|
35
|
+
const OUTBOX_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
// Attempt spacing. The first retry is quick because the commonest failure is a
|
|
38
|
+
// two-second wifi hiccup; the tail is slow because the next commonest is a
|
|
39
|
+
// closed laptop lid in a tunnel, and a queue that retries a dead network every
|
|
40
|
+
// second is a battery bug. There is deliberately NO attempt ceiling: "it sends
|
|
41
|
+
// when you are back online" has to hold for an overnight flight too.
|
|
42
|
+
const BACKOFF_MS = Object.freeze([1_000, 3_000, 8_000, 20_000, 45_000, 90_000, 180_000]);
|
|
43
|
+
const BACKOFF_CAP_MS = 300_000;
|
|
44
|
+
|
|
45
|
+
// How long a delivered-but-unconfirmed entry is kept before the queue lets it
|
|
46
|
+
// go. Generous enough that the Sent listing has had every chance to name it.
|
|
47
|
+
const RETIRE_SENT_AFTER_MS = 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
// The error vocabulary of a connection that is down rather than a request that
|
|
50
|
+
// is wrong, measured against undici (which is what both `fetch` and the
|
|
51
|
+
// companion's keep-alive client use): no DNS gives TypeError "fetch failed"
|
|
52
|
+
// with cause.code ENOTFOUND, a black-holed route gives a TimeoutError from
|
|
53
|
+
// AbortSignal.timeout, and a refused port gives TypeError "fetch failed" with
|
|
54
|
+
// no cause code at all.
|
|
55
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
56
|
+
"ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH",
|
|
57
|
+
"ENETUNREACH", "ENETDOWN", "EPIPE", "ETIMEDOUT", "EAGAIN",
|
|
58
|
+
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
|
|
59
|
+
"UND_ERR_SOCKET",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// HTTP answers that mean "not now" rather than "not ever".
|
|
63
|
+
const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 507, 522, 524]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Is this failure worth waiting out?
|
|
67
|
+
*
|
|
68
|
+
* The default is PERMANENT, which looks backwards for a queue whose promise is
|
|
69
|
+
* "keep trying" — but it is the honest default. A connection problem announces
|
|
70
|
+
* itself in the small, closed vocabulary above; anything else carrying an HTTP
|
|
71
|
+
* status is the server declining this exact payload, and retrying a declined
|
|
72
|
+
* payload forever wedges the chat behind a message that can never leave. A
|
|
73
|
+
* permanent verdict does not throw the words away: the entry stays in the
|
|
74
|
+
* outbox as `failed`, still holding its text, and the human is told.
|
|
75
|
+
*/
|
|
76
|
+
function classifySendError(error) {
|
|
77
|
+
if (!error) return "permanent";
|
|
78
|
+
const status = Number(error.status || error.statusCode || 0);
|
|
79
|
+
if (status) return TRANSIENT_STATUS.has(status) ? "transient" : "permanent";
|
|
80
|
+
const name = String(error.name || "");
|
|
81
|
+
if (name === "TimeoutError" || name === "AbortError") return "transient";
|
|
82
|
+
if (NETWORK_ERROR_CODES.has(String(error.code || ""))) return "transient";
|
|
83
|
+
const cause = error.cause;
|
|
84
|
+
if (cause && NETWORK_ERROR_CODES.has(String(cause.code || ""))) return "transient";
|
|
85
|
+
// Every undici connect failure surfaces as this exact TypeError, with the
|
|
86
|
+
// real reason demoted to `cause`. Some of those causes carry no code.
|
|
87
|
+
if (name === "TypeError" && /fetch failed|network|socket/i.test(String(error.message || ""))) return "transient";
|
|
88
|
+
return "permanent";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The delay before attempt number `attempts + 1`. */
|
|
92
|
+
function backoffMs(attempts) {
|
|
93
|
+
const index = Math.max(0, Math.trunc(attempts) - 1);
|
|
94
|
+
return index < BACKOFF_MS.length ? BACKOFF_MS[index] : BACKOFF_CAP_MS;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A wall-clock ISO stamp that keeps its local offset.
|
|
99
|
+
*
|
|
100
|
+
* Same rule as every other *At field the companion writes: a bare Z timestamp
|
|
101
|
+
* read back on a machine in another zone silently retimes the message.
|
|
102
|
+
*/
|
|
103
|
+
function localIso(ms) {
|
|
104
|
+
const date = new Date(ms);
|
|
105
|
+
const offset = -date.getTimezoneOffset();
|
|
106
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
107
|
+
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, "0");
|
|
108
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
109
|
+
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
110
|
+
+ `.${String(date.getMilliseconds()).padStart(3, "0")}`
|
|
111
|
+
+ `${sign}${pad(offset / 60)}:${pad(offset % 60)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Age of a stamp the queue wrote, or Infinity if it never wrote one. */
|
|
115
|
+
function staleSince(stamp, nowMs = Date.now()) {
|
|
116
|
+
const at = Date.parse(stamp || "");
|
|
117
|
+
return Number.isFinite(at) ? nowMs - at : Infinity;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeName(name) {
|
|
121
|
+
return String(name || "file").replace(/[\\/:*?"<>|]/g, "_").slice(0, 120) || "file";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The conversation an entry belongs to, used only for ORDER. Two messages typed
|
|
126
|
+
* into the same room must land in the order they were typed, so a queued one
|
|
127
|
+
* holds back the ones behind it; a stall in one room must not hold up another.
|
|
128
|
+
*/
|
|
129
|
+
function chatKeyOf(entry) {
|
|
130
|
+
const r = (entry && entry.recipient) || {};
|
|
131
|
+
return String(
|
|
132
|
+
r.groupId || r.chatId || r.contactId || r.relayUserId || r.email
|
|
133
|
+
|| (entry && entry.chat && entry.chat.threadId) || "unaddressed",
|
|
134
|
+
).trim().toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readStore(file) {
|
|
138
|
+
try {
|
|
139
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
140
|
+
const entries = Array.isArray(raw && raw.entries) ? raw.entries : [];
|
|
141
|
+
return { version: OUTBOX_VERSION, entries: entries.filter((e) => e && e.id) };
|
|
142
|
+
} catch {
|
|
143
|
+
return { version: OUTBOX_VERSION, entries: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A durable, ordered, idempotent send queue.
|
|
149
|
+
*
|
|
150
|
+
* `send` is injected rather than imported so this file never reaches for a
|
|
151
|
+
* network client, a config file or an Electron API: the pill passes the real
|
|
152
|
+
* transport, and tests pass a fake one.
|
|
153
|
+
*/
|
|
154
|
+
function createOutbox({
|
|
155
|
+
file,
|
|
156
|
+
send,
|
|
157
|
+
now = () => Date.now(),
|
|
158
|
+
spoolDir,
|
|
159
|
+
onChange = () => {},
|
|
160
|
+
log = () => {},
|
|
161
|
+
scheduleTimer = setTimeout,
|
|
162
|
+
cancelTimer = clearTimeout,
|
|
163
|
+
} = {}) {
|
|
164
|
+
if (!file) throw new Error("createOutbox requires a file path");
|
|
165
|
+
if (typeof send !== "function") throw new Error("createOutbox requires a send function");
|
|
166
|
+
const spool = spoolDir || path.join(path.dirname(file), "outbox-files");
|
|
167
|
+
|
|
168
|
+
let store = readStore(file);
|
|
169
|
+
let flushing = null;
|
|
170
|
+
let timer = null;
|
|
171
|
+
let stopped = false;
|
|
172
|
+
|
|
173
|
+
function persist() {
|
|
174
|
+
withJsonLock(file, () => {
|
|
175
|
+
atomicWriteJsonSync(file, { version: OUTBOX_VERSION, entries: store.entries });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function changed() {
|
|
180
|
+
persist();
|
|
181
|
+
try { onChange(list()); } catch (error) { log("outbox onChange failed", error); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function list() {
|
|
185
|
+
return store.entries.map((entry) => ({ ...entry }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function entryDir(id) {
|
|
189
|
+
return path.join(spool, String(id).replace(/[^A-Za-z0-9_.-]/g, "_"));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Copy the attachment bytes somewhere the queue owns.
|
|
194
|
+
*
|
|
195
|
+
* A queued send may outlive the temp file a paste wrote, the Downloads item a
|
|
196
|
+
* drag pointed at, or the app itself. The queue cannot promise to deliver a
|
|
197
|
+
* message whose payload it does not hold, so it takes its own copy and
|
|
198
|
+
* deletes it when the entry retires.
|
|
199
|
+
*/
|
|
200
|
+
function spoolFiles(id, files) {
|
|
201
|
+
const staged = [];
|
|
202
|
+
const dir = entryDir(id);
|
|
203
|
+
files.forEach((f, index) => {
|
|
204
|
+
if (!f) return;
|
|
205
|
+
const name = safeName(f.name);
|
|
206
|
+
const target = path.join(dir, `${index}-${name}`);
|
|
207
|
+
try {
|
|
208
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
209
|
+
if (f.path) fs.copyFileSync(String(f.path), target);
|
|
210
|
+
else if (f.contentBase64) fs.writeFileSync(target, Buffer.from(String(f.contentBase64), "base64"), { mode: 0o600 });
|
|
211
|
+
else return;
|
|
212
|
+
staged.push({
|
|
213
|
+
name: f.name || name,
|
|
214
|
+
size: Number(f.size || 0) || fs.statSync(target).size,
|
|
215
|
+
...(f.contentType ? { contentType: String(f.contentType) } : {}),
|
|
216
|
+
spoolPath: target,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
log(`outbox could not spool ${name}`, error);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return staged;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function releaseFiles(entry) {
|
|
226
|
+
if (!entry || !Array.isArray(entry.files) || !entry.files.length) return;
|
|
227
|
+
try { fs.rmSync(entryDir(entry.id), { recursive: true, force: true }); } catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Commit a message to the device. Returns the entry AFTER it is on disk, so a
|
|
232
|
+
* caller that paints from the return value is painting something that already
|
|
233
|
+
* survives a crash.
|
|
234
|
+
*/
|
|
235
|
+
function enqueue(input = {}) {
|
|
236
|
+
const idempotencyKey = String(input.idempotencyKey || "").trim();
|
|
237
|
+
if (!idempotencyKey) throw new Error("enqueue requires an idempotencyKey");
|
|
238
|
+
const existing = store.entries.find((e) => e.idempotencyKey === idempotencyKey);
|
|
239
|
+
if (existing) return { ...existing };
|
|
240
|
+
const at = now();
|
|
241
|
+
const entry = {
|
|
242
|
+
id: idempotencyKey,
|
|
243
|
+
idempotencyKey,
|
|
244
|
+
state: "queued",
|
|
245
|
+
createdAt: localIso(at),
|
|
246
|
+
attempts: 0,
|
|
247
|
+
nextAttemptAt: at,
|
|
248
|
+
lastError: "",
|
|
249
|
+
text: String(input.text || ""),
|
|
250
|
+
recipient: input.recipient && typeof input.recipient === "object" ? input.recipient : {},
|
|
251
|
+
inReplyToRelayId: String(input.inReplyToRelayId || ""),
|
|
252
|
+
title: String(input.title || ""),
|
|
253
|
+
files: spoolFiles(idempotencyKey, Array.isArray(input.files) ? input.files : []),
|
|
254
|
+
chat: input.chat && typeof input.chat === "object" ? input.chat : {},
|
|
255
|
+
relayId: "",
|
|
256
|
+
groupSendId: "",
|
|
257
|
+
threadId: String((input.chat && input.chat.threadId) || ""),
|
|
258
|
+
};
|
|
259
|
+
store.entries.push(entry);
|
|
260
|
+
changed();
|
|
261
|
+
return { ...entry };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function update(id, patch) {
|
|
265
|
+
const entry = store.entries.find((e) => e.id === id);
|
|
266
|
+
if (!entry) return null;
|
|
267
|
+
Object.assign(entry, patch);
|
|
268
|
+
return entry;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Forget an entry (and its spooled bytes) once its canonical relay exists. */
|
|
272
|
+
function retire(id) {
|
|
273
|
+
const index = store.entries.findIndex((e) => e.id === id);
|
|
274
|
+
if (index < 0) return false;
|
|
275
|
+
releaseFiles(store.entries[index]);
|
|
276
|
+
store.entries.splice(index, 1);
|
|
277
|
+
changed();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Retire every entry whose canonical relay is now in the Sent projection.
|
|
283
|
+
*
|
|
284
|
+
* Retirement is deliberately driven by the SERVER's view rather than by our
|
|
285
|
+
* own success return: that is the same evidence the renderer uses to retire
|
|
286
|
+
* an optimistic bubble, so the queued row and the real row can never both be
|
|
287
|
+
* on screen, and a success we recorded but the server did not keep cannot
|
|
288
|
+
* quietly vanish from the queue.
|
|
289
|
+
*/
|
|
290
|
+
function retireConfirmed({ relayIds = [], groupSendIds = [] } = {}) {
|
|
291
|
+
const ids = new Set([...relayIds].map(String).filter(Boolean));
|
|
292
|
+
const sends = new Set([...groupSendIds].map(String).filter(Boolean));
|
|
293
|
+
const survivors = [];
|
|
294
|
+
let dropped = 0;
|
|
295
|
+
for (const entry of store.entries) {
|
|
296
|
+
const confirmed = entry.state === "sent"
|
|
297
|
+
&& ((entry.relayId && ids.has(String(entry.relayId)))
|
|
298
|
+
|| (entry.groupSendId && sends.has(String(entry.groupSendId))))
|
|
299
|
+
// A `sent` entry the Sent projection never names has still been
|
|
300
|
+
// ACCEPTED by the server — that is what earned it the state. Holding it
|
|
301
|
+
// forever on the chance the listing catches up is how a queue file
|
|
302
|
+
// grows without bound; the room already stops painting it the moment
|
|
303
|
+
// the canonical row lands.
|
|
304
|
+
|| (entry.state === "sent" && staleSince(entry.sentAt, now()) > RETIRE_SENT_AFTER_MS);
|
|
305
|
+
if (!confirmed) { survivors.push(entry); continue; }
|
|
306
|
+
releaseFiles(entry);
|
|
307
|
+
dropped += 1;
|
|
308
|
+
}
|
|
309
|
+
if (!dropped) return 0;
|
|
310
|
+
store.entries = survivors;
|
|
311
|
+
changed();
|
|
312
|
+
return dropped;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Put a failed entry back in line — the human pressed Retry. */
|
|
316
|
+
function retry(id) {
|
|
317
|
+
const entry = store.entries.find((e) => e.id === id);
|
|
318
|
+
if (!entry || entry.state === "sent") return false;
|
|
319
|
+
entry.state = "queued";
|
|
320
|
+
entry.attempts = 0;
|
|
321
|
+
entry.nextAttemptAt = now();
|
|
322
|
+
entry.lastError = "";
|
|
323
|
+
changed();
|
|
324
|
+
kick(0);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function attempt(entry) {
|
|
329
|
+
entry.attempts += 1;
|
|
330
|
+
try {
|
|
331
|
+
const result = await send({ ...entry });
|
|
332
|
+
update(entry.id, {
|
|
333
|
+
state: "sent",
|
|
334
|
+
sentAt: localIso(now()),
|
|
335
|
+
lastError: "",
|
|
336
|
+
relayId: String((result && (result.relayId || result.id)) || ""),
|
|
337
|
+
groupSendId: String((result && result.groupSendId) || ""),
|
|
338
|
+
threadId: String((result && result.threadId) || entry.threadId || ""),
|
|
339
|
+
});
|
|
340
|
+
changed();
|
|
341
|
+
return "sent";
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const kind = classifySendError(error);
|
|
344
|
+
const message = (error && error.message) || String(error);
|
|
345
|
+
if (kind === "permanent") {
|
|
346
|
+
update(entry.id, { state: "failed", lastError: message, failedAt: localIso(now()) });
|
|
347
|
+
changed();
|
|
348
|
+
log(`outbox send rejected (${message})`);
|
|
349
|
+
return "failed";
|
|
350
|
+
}
|
|
351
|
+
update(entry.id, {
|
|
352
|
+
state: "queued",
|
|
353
|
+
lastError: message,
|
|
354
|
+
nextAttemptAt: now() + backoffMs(entry.attempts),
|
|
355
|
+
});
|
|
356
|
+
changed();
|
|
357
|
+
return "waiting";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One pass over everything due. Serialized: a burst of five messages typed
|
|
363
|
+
* offline must reach the server in the order they were typed, not in whatever
|
|
364
|
+
* order five parallel sockets happen to finish.
|
|
365
|
+
*/
|
|
366
|
+
function flush() {
|
|
367
|
+
if (flushing) return flushing;
|
|
368
|
+
flushing = (async () => {
|
|
369
|
+
const blocked = new Set();
|
|
370
|
+
// Snapshot the ids, not the objects: an attempt writes the store.
|
|
371
|
+
for (const id of store.entries.map((e) => e.id)) {
|
|
372
|
+
if (stopped) break;
|
|
373
|
+
const entry = store.entries.find((e) => e.id === id);
|
|
374
|
+
if (!entry || entry.state !== "queued") continue;
|
|
375
|
+
const key = chatKeyOf(entry);
|
|
376
|
+
// A message still waiting on the network holds back only the messages
|
|
377
|
+
// behind it IN ITS OWN ROOM. A permanently failed one blocks nothing:
|
|
378
|
+
// one rejected message must never wedge a conversation forever.
|
|
379
|
+
if (blocked.has(key)) continue;
|
|
380
|
+
if (entry.nextAttemptAt > now()) { blocked.add(key); continue; }
|
|
381
|
+
const outcome = await attempt(entry);
|
|
382
|
+
if (outcome === "waiting") blocked.add(key);
|
|
383
|
+
}
|
|
384
|
+
})().finally(() => {
|
|
385
|
+
flushing = null;
|
|
386
|
+
if (!stopped) arm();
|
|
387
|
+
});
|
|
388
|
+
return flushing;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Wake exactly when the earliest waiting entry comes due — no idle polling. */
|
|
392
|
+
function arm() {
|
|
393
|
+
if (timer) { cancelTimer(timer); timer = null; }
|
|
394
|
+
if (stopped) return;
|
|
395
|
+
const due = store.entries
|
|
396
|
+
.filter((e) => e.state === "queued")
|
|
397
|
+
.map((e) => Number(e.nextAttemptAt) || 0);
|
|
398
|
+
if (!due.length) return;
|
|
399
|
+
const wait = Math.max(0, Math.min(...due) - now());
|
|
400
|
+
timer = scheduleTimer(() => { timer = null; flush().catch(() => {}); }, wait);
|
|
401
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function kick(delay = 0) {
|
|
405
|
+
if (stopped) return;
|
|
406
|
+
if (delay > 0) { arm(); return; }
|
|
407
|
+
flush().catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Fresh evidence that the network is back — drop the backoff and try now.
|
|
412
|
+
*
|
|
413
|
+
* Backoff is a guess about a connection nobody can see. Once something else
|
|
414
|
+
* has just succeeded against the API, the guess is superseded: a message must
|
|
415
|
+
* not sit for another five minutes because its last attempt happened to fail
|
|
416
|
+
* during the outage.
|
|
417
|
+
*/
|
|
418
|
+
function resume() {
|
|
419
|
+
if (stopped) return 0;
|
|
420
|
+
let woken = 0;
|
|
421
|
+
for (const entry of store.entries) {
|
|
422
|
+
if (entry.state !== "queued") continue;
|
|
423
|
+
entry.nextAttemptAt = now();
|
|
424
|
+
woken += 1;
|
|
425
|
+
}
|
|
426
|
+
if (woken) kick(0);
|
|
427
|
+
return woken;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function start() {
|
|
431
|
+
stopped = false;
|
|
432
|
+
// Anything left queued by a previous run is due immediately: the commonest
|
|
433
|
+
// reason the app restarted is that the machine woke up somewhere else.
|
|
434
|
+
resume();
|
|
435
|
+
kick(0);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function stop() {
|
|
439
|
+
stopped = true;
|
|
440
|
+
if (timer) { cancelTimer(timer); timer = null; }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Reload from disk (another process, or a test, wrote the file). */
|
|
444
|
+
function reload() {
|
|
445
|
+
store = readStore(file);
|
|
446
|
+
return list();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function pendingCount() {
|
|
450
|
+
return store.entries.filter((e) => e.state === "queued").length;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
enqueue, list, flush, retire, retireConfirmed, retry, start, stop, reload,
|
|
455
|
+
resume, pendingCount, kick,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
createOutbox,
|
|
461
|
+
classifySendError,
|
|
462
|
+
backoffMs,
|
|
463
|
+
chatKeyOf,
|
|
464
|
+
localIso,
|
|
465
|
+
BACKOFF_MS,
|
|
466
|
+
BACKOFF_CAP_MS,
|
|
467
|
+
OUTBOX_VERSION,
|
|
468
|
+
};
|