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.
@@ -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.relayId = String((res.result && (res.result.relayId || res.result.id)) || "");
6038
- if (optimistic.relayId) optimistic.id = optimistic.relayId;
6039
- // Same rule as the room composer: a reply into a group room fans
6040
- // out, and only the shared groupSendId reliably matches a
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(`<span class="th-seen">Sending…</span>`);
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 (sendReplyFromPill sends no
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({ text, recipient, ...(inReplyToRelayId ? { inReplyToRelayId } : {}), files, idempotencyKey });
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 words never left this device. Keep them editable so retrying
9119
- // cannot silently turn a failed send into a lost draft.
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.relayId = String((res.result && (res.result.relayId || res.result.id)) || "");
9140
- if (optimistic.relayId) optimistic.id = optimistic.relayId;
9141
- // A group send's response id names one sibling of the fan-out; the
9142
- // shared groupSendId is what reconciliation retires the bubble by.
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
- // pasted bytes are written to a temp file so ONE pipeline prepares them all.
6248
- async function sendReplyFromPill(input = {}) {
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 client = await relayClient();
6251
- const attachmentsUrl = pathToFileURL(path.join(__dirname, "..", "src", "attachments.js")).href;
6252
- const { prepareOrdinaryRelayAttachments } = await import(attachmentsUrl);
6253
- const os = require("os");
6254
- const crypto = require("crypto");
6255
- const text = String(input.text || "").trim();
6256
- const replyIdempotencyKey = String(input.idempotencyKey || "").trim() || crypto.randomUUID();
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
- return { ok: true, result: res };
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) => sendReplyFromPill(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();
@@ -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) return null;
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.320",
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
+ };