relay-companion 0.1.44 → 0.1.46
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 +29 -3
- package/overlay/main.cjs +43 -4
- package/overlay/preload.cjs +1 -0
- package/package.json +1 -1
- package/src/client.js +4 -0
- package/src/inbox-item-id.cjs +25 -0
- package/src/notifications.js +44 -9
- package/src/task-daemon.js +3 -0
package/overlay/inbox.html
CHANGED
|
@@ -152,6 +152,14 @@
|
|
|
152
152
|
}
|
|
153
153
|
.tab.active .tab-badge { color:var(--accent); }
|
|
154
154
|
.tab-badge.gone { display:none; }
|
|
155
|
+
.mark-all-read {
|
|
156
|
+
appearance:none; margin-left:auto; border:0; background:transparent; cursor:pointer;
|
|
157
|
+
padding:0 2px; color:var(--muted-2); font-family:var(--sans); font-size:10.5px; font-weight:500;
|
|
158
|
+
transition:color .15s var(--settle), opacity .15s var(--settle);
|
|
159
|
+
}
|
|
160
|
+
.mark-all-read:hover { color:var(--accent); }
|
|
161
|
+
.mark-all-read:disabled { opacity:.45; cursor:default; color:var(--muted-3); }
|
|
162
|
+
.mark-all-read.gone { visibility:hidden; pointer-events:none; }
|
|
155
163
|
|
|
156
164
|
/* scrolling content layer (host for each view) */
|
|
157
165
|
.scroll {
|
|
@@ -562,6 +570,7 @@
|
|
|
562
570
|
</button>
|
|
563
571
|
<button class="tab" type="button" data-view="sent">Sent</button>
|
|
564
572
|
<button class="tab" type="button" data-view="contacts">Contacts</button>
|
|
573
|
+
<button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
|
|
565
574
|
</nav>
|
|
566
575
|
|
|
567
576
|
<div class="scroll" id="scroll">
|
|
@@ -636,6 +645,7 @@
|
|
|
636
645
|
const relaysListEl = document.getElementById("relaysList");
|
|
637
646
|
const relaysEmptyEl = document.getElementById("relaysEmpty");
|
|
638
647
|
const relaysBadgeEl = document.getElementById("relaysBadge");
|
|
648
|
+
const markAllReadEl = document.getElementById("markAllRead");
|
|
639
649
|
const sentViewEl = document.getElementById("sentView");
|
|
640
650
|
const sentListEl = document.getElementById("sentList");
|
|
641
651
|
const sentEmptyEl = document.getElementById("sentEmpty");
|
|
@@ -661,7 +671,7 @@
|
|
|
661
671
|
|
|
662
672
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
663
673
|
const EXPANDED = { w: 344, h: 524 };
|
|
664
|
-
const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N
|
|
674
|
+
const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unread" + ✕
|
|
665
675
|
const PEEK = { w: 344, h: 118 };
|
|
666
676
|
|
|
667
677
|
// ---------- zero-latency sound (Web Audio, pre-decoded) ----------
|
|
@@ -858,7 +868,7 @@
|
|
|
858
868
|
}
|
|
859
869
|
// New relays always enter the notification stack, including from a fully expanded
|
|
860
870
|
// card. The main process has already pushed the new payload, so render/sizing uses
|
|
861
|
-
// the latest
|
|
871
|
+
// the latest unread rows rather than the previous inbox.
|
|
862
872
|
function notifyArrival(rows) {
|
|
863
873
|
if (peekTimer) clearTimeout(peekTimer);
|
|
864
874
|
cancelBye();
|
|
@@ -1199,7 +1209,7 @@
|
|
|
1199
1209
|
|
|
1200
1210
|
let unreadCount = 0;
|
|
1201
1211
|
function refreshCountLabel() {
|
|
1202
|
-
const label = collapsed ? `${unreadCount}
|
|
1212
|
+
const label = collapsed ? `${unreadCount} unread` : String(unreadCount);
|
|
1203
1213
|
if (countEl.textContent !== label) {
|
|
1204
1214
|
countEl.textContent = label;
|
|
1205
1215
|
if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
|
|
@@ -2155,11 +2165,27 @@
|
|
|
2155
2165
|
const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
|
|
2156
2166
|
setCount(unread);
|
|
2157
2167
|
setBadge(relaysBadgeEl, unread);
|
|
2168
|
+
markAllReadEl.classList.toggle("gone", activeView !== "relays" || unread === 0);
|
|
2169
|
+
markAllReadEl.disabled = unread === 0;
|
|
2158
2170
|
if (activeView === "relays") renderRelays();
|
|
2159
2171
|
else if (activeView === "sent") renderSent();
|
|
2160
2172
|
// contacts renders from its own load path
|
|
2161
2173
|
}
|
|
2162
2174
|
|
|
2175
|
+
markAllReadEl.addEventListener("click", async () => {
|
|
2176
|
+
if (markAllReadEl.disabled || unreadCount === 0) return;
|
|
2177
|
+
markAllReadEl.disabled = true;
|
|
2178
|
+
markAllReadEl.textContent = "Marking…";
|
|
2179
|
+
const result = await window.relay.markAllRead();
|
|
2180
|
+
markAllReadEl.textContent = "Mark all as read";
|
|
2181
|
+
if (!result || !result.ok) {
|
|
2182
|
+
markAllReadEl.disabled = false;
|
|
2183
|
+
markAllReadEl.title = (result && result.error) || "Could not mark relays as read.";
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
markAllReadEl.title = "";
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2163
2189
|
function syncDismissControl() {
|
|
2164
2190
|
const ui = (payload && payload.ui) || {};
|
|
2165
2191
|
const canDismiss = ui.canDismiss !== false;
|
package/overlay/main.cjs
CHANGED
|
@@ -44,6 +44,7 @@ const {
|
|
|
44
44
|
recoverInterruptedAttentionPrefs,
|
|
45
45
|
} = require("./visibility.cjs");
|
|
46
46
|
const { withJsonLock } = require("../src/state-lock.cjs");
|
|
47
|
+
const { canonicalInboxItemId, packetIdsForCanonicalItem } = require("../src/inbox-item-id.cjs");
|
|
47
48
|
const {
|
|
48
49
|
reinforceSpacePresence,
|
|
49
50
|
resetWindowZoom,
|
|
@@ -373,6 +374,38 @@ function visibleRelayRows(rows) {
|
|
|
373
374
|
return rows.filter((row) => !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind));
|
|
374
375
|
}
|
|
375
376
|
|
|
377
|
+
async function markAllVisibleRelaysRead() {
|
|
378
|
+
try {
|
|
379
|
+
// Server first so the website and other devices share the same read cutoff.
|
|
380
|
+
const client = await relayClient();
|
|
381
|
+
const result = await client.markAllRead({ idempotencyKey: idempotencyKey("inbox_read_all") });
|
|
382
|
+
const cutoffMs = new Date(result.readAllAt || "").getTime();
|
|
383
|
+
let marked = 0;
|
|
384
|
+
withJsonLock(STATE_PATH, () => {
|
|
385
|
+
const store = readStore();
|
|
386
|
+
for (const [id, row] of Object.entries(store.packets || {})) {
|
|
387
|
+
if (!row || row.direction !== "inbound" || row.state === "read") continue;
|
|
388
|
+
if (RELAY_HIDDEN_KINDS.has(row.relayNotificationKind)) continue;
|
|
389
|
+
const reference = row.deliveryReferenceAt ||
|
|
390
|
+
(row.relayNotificationKind === "plain_relay" ? row.updatedAt : row.createdAt);
|
|
391
|
+
const referenceMs = new Date(reference || "").getTime();
|
|
392
|
+
if (Number.isFinite(cutoffMs) && Number.isFinite(referenceMs) && referenceMs > cutoffMs) continue;
|
|
393
|
+
row.state = "read";
|
|
394
|
+
activeAttentionIds.delete(id);
|
|
395
|
+
marked += 1;
|
|
396
|
+
}
|
|
397
|
+
writeStateAtomic(store);
|
|
398
|
+
});
|
|
399
|
+
writeOverlayPrefs();
|
|
400
|
+
await pushInbox(true);
|
|
401
|
+
return { ok: true, marked, readAllAt: result.readAllAt };
|
|
402
|
+
} catch (error) {
|
|
403
|
+
const message = error && error.message ? error.message : String(error);
|
|
404
|
+
console.error("[overlay] mark all read failed:", message);
|
|
405
|
+
return { ok: false, error: message };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
376
409
|
// ---- sent relays (live) --------------------------------------------------
|
|
377
410
|
|
|
378
411
|
let sentCache = [];
|
|
@@ -660,18 +693,23 @@ async function deletePacket(packetId) {
|
|
|
660
693
|
if (!packetId) return { ok: false, error: "Missing relay id." };
|
|
661
694
|
const row = rowById(packetId);
|
|
662
695
|
if (!row) return { ok: true };
|
|
696
|
+
const itemId = canonicalInboxItemId(packetId, row);
|
|
663
697
|
try {
|
|
664
698
|
// Server first: a transient network/API error must never make a recoverable
|
|
665
699
|
// item disappear locally without actually entering Recently Deleted.
|
|
666
700
|
const client = await relayClient();
|
|
667
|
-
await client.deleteInboxItem(
|
|
701
|
+
await client.deleteInboxItem(itemId, { idempotencyKey: idempotencyKey("inbox_delete") });
|
|
702
|
+
let removedPacketIds = [];
|
|
668
703
|
withJsonLock(STATE_PATH, () => {
|
|
669
704
|
const store = readStore();
|
|
670
|
-
|
|
705
|
+
removedPacketIds = packetIdsForCanonicalItem(store.packets, itemId);
|
|
706
|
+
for (const id of removedPacketIds) delete store.packets[id];
|
|
671
707
|
writeStateAtomic(store);
|
|
672
708
|
});
|
|
673
|
-
|
|
674
|
-
|
|
709
|
+
for (const id of removedPacketIds) {
|
|
710
|
+
presentedRelayIds.delete(id);
|
|
711
|
+
activeAttentionIds.delete(id);
|
|
712
|
+
}
|
|
675
713
|
writeOverlayPrefs();
|
|
676
714
|
await pushInbox(true);
|
|
677
715
|
return { ok: true };
|
|
@@ -1625,6 +1663,7 @@ ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
|
|
|
1625
1663
|
ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
|
|
1626
1664
|
ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
|
|
1627
1665
|
ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
|
|
1666
|
+
ipcMain.handle("relay:markAllRead", () => markAllVisibleRelaysRead());
|
|
1628
1667
|
ipcMain.handle("relay:refreshSent", async () => {
|
|
1629
1668
|
await refreshSent();
|
|
1630
1669
|
await pushInbox(true);
|
package/overlay/preload.cjs
CHANGED
|
@@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
16
16
|
openSent: (id) => ipcRenderer.send("relay:openSent", id),
|
|
17
17
|
ack: (id) => ipcRenderer.send("relay:ack", id),
|
|
18
18
|
deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
|
|
19
|
+
markAllRead: () => ipcRenderer.invoke("relay:markAllRead"),
|
|
19
20
|
openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
|
|
20
21
|
refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
|
|
21
22
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Old companion releases staged task notifications with a timestamp suffix.
|
|
5
|
+
* The API intentionally uses the stable condition id, so normalize both old
|
|
6
|
+
* and current rows before deleting, suppressing, or comparing them.
|
|
7
|
+
*/
|
|
8
|
+
function canonicalInboxItemId(packetId, row = {}) {
|
|
9
|
+
const kind = String(row.relayNotificationKind || "");
|
|
10
|
+
if (kind === "human_question" && row.messageId) {
|
|
11
|
+
return `human_question:${row.messageId}`;
|
|
12
|
+
}
|
|
13
|
+
if (["task_completed", "task_cancelled", "task_failed"].includes(kind) && row.taskId) {
|
|
14
|
+
return `${kind}:${row.taskId}`;
|
|
15
|
+
}
|
|
16
|
+
return String(packetId || "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function packetIdsForCanonicalItem(packets, canonicalItemId) {
|
|
20
|
+
return Object.entries(packets || {})
|
|
21
|
+
.filter(([packetId, row]) => canonicalInboxItemId(packetId, row) === canonicalItemId)
|
|
22
|
+
.map(([packetId]) => packetId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { canonicalInboxItemId, packetIdsForCanonicalItem };
|
package/src/notifications.js
CHANGED
|
@@ -4,6 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { currentUser, readConfig, webUrl } from "./config.js";
|
|
5
5
|
import { relayBinPath } from "./install.js";
|
|
6
6
|
import { withJsonLock } from "./state-lock.cjs";
|
|
7
|
+
import inboxItemIds from "./inbox-item-id.cjs";
|
|
8
|
+
|
|
9
|
+
const { canonicalInboxItemId } = inboxItemIds;
|
|
7
10
|
|
|
8
11
|
const DEFAULT_COMPANION_STATE = {
|
|
9
12
|
version: 1,
|
|
@@ -195,12 +198,20 @@ export function buildHumanNotifications({ user, tasks = [], relays = [], connect
|
|
|
195
198
|
}
|
|
196
199
|
const suppressed = new Set(inboxState.suppressedItemIds || []);
|
|
197
200
|
const restoredAtByItemId = inboxState.restoredAtByItemId || {};
|
|
201
|
+
const readAllAtMs = inboxState.readAllAt ? new Date(inboxState.readAllAt).getTime() : Number.NaN;
|
|
198
202
|
return notifications
|
|
199
203
|
.filter((notification) => !suppressed.has(notification.id))
|
|
200
|
-
.map((notification) =>
|
|
201
|
-
|
|
202
|
-
deliveryVersion
|
|
203
|
-
|
|
204
|
+
.map((notification) => {
|
|
205
|
+
const deliveryVersion = restoredAtByItemId[notification.id] || "";
|
|
206
|
+
const deliveryReferenceAt = deliveryVersion || notification.createdAt || "";
|
|
207
|
+
const deliveryMs = new Date(deliveryReferenceAt).getTime();
|
|
208
|
+
return {
|
|
209
|
+
...notification,
|
|
210
|
+
deliveryVersion,
|
|
211
|
+
deliveryReferenceAt,
|
|
212
|
+
read: Number.isFinite(readAllAtMs) && Number.isFinite(deliveryMs) && deliveryMs <= readAllAtMs,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
export function freshNotifications(ledger, notifications) {
|
|
@@ -257,8 +268,8 @@ export function removeSuppressedCompanionItems(itemIds, { statePath = companionS
|
|
|
257
268
|
const state = readCompanionState(statePath);
|
|
258
269
|
state.packets ||= {};
|
|
259
270
|
let removed = 0;
|
|
260
|
-
for (const id of
|
|
261
|
-
if (!
|
|
271
|
+
for (const [id, row] of Object.entries(state.packets)) {
|
|
272
|
+
if (!suppressed.has(canonicalInboxItemId(id, row))) continue;
|
|
262
273
|
delete state.packets[id];
|
|
263
274
|
removed += 1;
|
|
264
275
|
}
|
|
@@ -267,6 +278,28 @@ export function removeSuppressedCompanionItems(itemIds, { statePath = companionS
|
|
|
267
278
|
});
|
|
268
279
|
}
|
|
269
280
|
|
|
281
|
+
/** Reconcile an account-wide mark-all-read watermark onto already-staged rows. */
|
|
282
|
+
export function markCompanionItemsReadThrough(readAllAt, { statePath = companionStatePath() } = {}) {
|
|
283
|
+
const cutoffMs = new Date(readAllAt || "").getTime();
|
|
284
|
+
if (!Number.isFinite(cutoffMs)) return 0;
|
|
285
|
+
return withJsonLock(statePath, () => {
|
|
286
|
+
const state = readCompanionState(statePath);
|
|
287
|
+
state.packets ||= {};
|
|
288
|
+
let changed = 0;
|
|
289
|
+
for (const row of Object.values(state.packets)) {
|
|
290
|
+
if (!row || row.direction !== "inbound" || row.state === "read") continue;
|
|
291
|
+
const reference = row.deliveryReferenceAt ||
|
|
292
|
+
(row.relayNotificationKind === "plain_relay" ? row.updatedAt : row.createdAt);
|
|
293
|
+
const referenceMs = new Date(reference || "").getTime();
|
|
294
|
+
if (!Number.isFinite(referenceMs) || referenceMs > cutoffMs) continue;
|
|
295
|
+
row.state = "read";
|
|
296
|
+
changed += 1;
|
|
297
|
+
}
|
|
298
|
+
if (changed) writeCompanionState(statePath, state);
|
|
299
|
+
return changed;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
270
303
|
function accountMarker({ user = currentUser(), deviceId = readConfig().deviceId || "" } = {}) {
|
|
271
304
|
return {
|
|
272
305
|
userId: user?.id || "",
|
|
@@ -410,7 +443,7 @@ function relayPacketFromNotification(notification, { now = new Date().toISOStrin
|
|
|
410
443
|
id: notification.id,
|
|
411
444
|
createdAt: notification.createdAt || now,
|
|
412
445
|
updatedAt: now,
|
|
413
|
-
state: "unread",
|
|
446
|
+
state: notification.read ? "read" : "unread",
|
|
414
447
|
sender,
|
|
415
448
|
recipient: notificationRecipient(),
|
|
416
449
|
source: {
|
|
@@ -445,7 +478,7 @@ export function relayCompanionRowFromNotification(
|
|
|
445
478
|
return {
|
|
446
479
|
id: notification.id,
|
|
447
480
|
direction: "inbound",
|
|
448
|
-
state: "unread",
|
|
481
|
+
state: notification.read ? "read" : "unread",
|
|
449
482
|
kind: "message",
|
|
450
483
|
// Default unknown notification kinds to a neutral "message", never "task_completed"
|
|
451
484
|
// (which would falsely mark an unknown row as a finished task). main.cjs readRelays
|
|
@@ -458,6 +491,7 @@ export function relayCompanionRowFromNotification(
|
|
|
458
491
|
briefingMarkdown: packetMarkdownForNotification(notification),
|
|
459
492
|
bodyMarkdown: notification.body || notification.title || "",
|
|
460
493
|
createdAt: notification.createdAt || now,
|
|
494
|
+
deliveryReferenceAt: notification.deliveryReferenceAt || notification.createdAt || now,
|
|
461
495
|
updatedAt: now,
|
|
462
496
|
stagedAt: now,
|
|
463
497
|
actionUrl,
|
|
@@ -487,7 +521,7 @@ export function stageRelayCompanionItem(notification, { statePath = companionSta
|
|
|
487
521
|
const item = {
|
|
488
522
|
...existing,
|
|
489
523
|
...relayCompanionRowFromNotification(notification, { contentPath }),
|
|
490
|
-
state: existing.state === "read" ? "read" : "unread",
|
|
524
|
+
state: existing.state === "read" || packet.state === "read" ? "read" : "unread",
|
|
491
525
|
};
|
|
492
526
|
state.packets[item.id] = item;
|
|
493
527
|
writeCompanionState(statePath, state);
|
|
@@ -599,6 +633,7 @@ export function stagePlainRelayItem(
|
|
|
599
633
|
bodyMarkdown: packet?.bodyMarkdown || item.preview || "",
|
|
600
634
|
createdAt: item.createdAt || packet?.createdAt || now,
|
|
601
635
|
updatedAt: item.updatedAt || now,
|
|
636
|
+
deliveryReferenceAt: item.updatedAt || item.createdAt || now,
|
|
602
637
|
stagedAt: now,
|
|
603
638
|
actionUrl: "/app/relays",
|
|
604
639
|
action: { type: "open", url: "/app/relays" },
|
package/src/task-daemon.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
defaultStageRelayCompanionItem,
|
|
15
15
|
freshNotifications,
|
|
16
16
|
markNotificationsProcessed,
|
|
17
|
+
markCompanionItemsReadThrough,
|
|
17
18
|
removeSuppressedCompanionItems,
|
|
18
19
|
reconcileAnsweredQuestions,
|
|
19
20
|
stagePlainRelayItem as defaultStagePlainRelayItem,
|
|
@@ -174,6 +175,8 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
|
|
|
174
175
|
const events = await pollVisibleTaskEvents({ client, ledger, tasks, log });
|
|
175
176
|
const removed = removeSuppressedCompanionItems(relays.inboxState?.suppressedItemIds || []);
|
|
176
177
|
if (removed) log(`removed ${removed} Relay item(s) moved to Recently Deleted elsewhere`);
|
|
178
|
+
const markedRead = markCompanionItemsReadThrough(relays.inboxState?.readAllAt);
|
|
179
|
+
if (markedRead) log(`marked ${markedRead} Relay item(s) read from another surface`);
|
|
177
180
|
const notifications = buildHumanNotifications({
|
|
178
181
|
user: me.user,
|
|
179
182
|
tasks: tasks.tasks || [],
|