relay-companion 0.1.41 → 0.1.43
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 +33 -1
- package/overlay/main.cjs +27 -0
- package/overlay/preload.cjs +1 -0
- package/package.json +1 -1
- package/src/client.js +12 -0
- package/src/desktop-migration.js +5 -1
- package/src/install.js +58 -9
- package/src/mcp.js +45 -0
- package/src/notifications.js +14 -3
- package/src/task-daemon.js +1 -0
package/overlay/inbox.html
CHANGED
|
@@ -190,6 +190,16 @@
|
|
|
190
190
|
transition:background-color .14s var(--settle);
|
|
191
191
|
}
|
|
192
192
|
.row:hover { background:rgba(31,26,23,.025); }
|
|
193
|
+
.row-delete {
|
|
194
|
+
appearance:none; position:absolute; top:9px; right:9px; width:25px; height:25px;
|
|
195
|
+
display:flex; align-items:center; justify-content:center; border:0; border-radius:7px;
|
|
196
|
+
background:rgba(255,255,255,.86); color:var(--muted-3); cursor:pointer; opacity:0;
|
|
197
|
+
transition:opacity .14s var(--settle), color .14s var(--settle), background .14s var(--settle);
|
|
198
|
+
}
|
|
199
|
+
.row:hover .row-delete, .row-delete:focus-visible { opacity:1; }
|
|
200
|
+
.row-delete:hover { color:#a83a32; background:#fff1ef; }
|
|
201
|
+
.row-delete:disabled { opacity:.45; cursor:default; }
|
|
202
|
+
.row.deletable .rk-time { margin-right:22px; }
|
|
193
203
|
@keyframes rowEnter { from { opacity:0; transform:translateY(-12px); } to { opacity:1; transform:none; } }
|
|
194
204
|
.row.enter { animation:rowEnter .52s var(--soft) both; }
|
|
195
205
|
|
|
@@ -1248,8 +1258,11 @@
|
|
|
1248
1258
|
const body = r.bodyMarkdown || r.body || "";
|
|
1249
1259
|
const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
|
|
1250
1260
|
const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
|
|
1261
|
+
const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
|
|
1262
|
+
const deleteButton = canDelete ? `<button class="row-delete" type="button" data-delete="${esc(r.id)}" data-stop="1" aria-label="Move to Recently Deleted" title="Move to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : "";
|
|
1251
1263
|
return `
|
|
1252
|
-
<div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
|
|
1264
|
+
<div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}${canDelete ? " deletable" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
|
|
1265
|
+
${deleteButton}
|
|
1253
1266
|
<div class="rk-top">
|
|
1254
1267
|
<span class="rk-dot"></span>
|
|
1255
1268
|
<span class="rk-from">From</span>
|
|
@@ -1354,6 +1367,25 @@
|
|
|
1354
1367
|
ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(84, ta.scrollHeight) + "px"; });
|
|
1355
1368
|
ta.addEventListener("mousedown", () => setInteractive(true));
|
|
1356
1369
|
}
|
|
1370
|
+
for (const btn of relaysListEl.querySelectorAll("[data-delete]")) {
|
|
1371
|
+
const id = btn.getAttribute("data-delete");
|
|
1372
|
+
btn.addEventListener("click", async (event) => {
|
|
1373
|
+
event.stopPropagation();
|
|
1374
|
+
if (btn.disabled) return;
|
|
1375
|
+
btn.disabled = true;
|
|
1376
|
+
showRowError(id, "");
|
|
1377
|
+
try {
|
|
1378
|
+
const res = await window.relay.deleteRelay(id);
|
|
1379
|
+
if (!res || res.ok === false) {
|
|
1380
|
+
showRowError(id, (res && res.error) || "Could not move this relay to Recently Deleted.");
|
|
1381
|
+
btn.disabled = false;
|
|
1382
|
+
}
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
showRowError(id, (err && err.message) || "Could not move this relay to Recently Deleted.");
|
|
1385
|
+
btn.disabled = false;
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1357
1389
|
// accept/reject are hidden with the Tasks surface. Only connector_reauth keeps a
|
|
1358
1390
|
// [data-review] "Reconnect" affordance here.
|
|
1359
1391
|
for (const btn of relaysListEl.querySelectorAll("[data-review]")) {
|
package/overlay/main.cjs
CHANGED
|
@@ -656,6 +656,32 @@ function ackPacket(packetId) {
|
|
|
656
656
|
pushInbox(true).catch(() => {});
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
+
async function deletePacket(packetId) {
|
|
660
|
+
if (!packetId) return { ok: false, error: "Missing relay id." };
|
|
661
|
+
const row = rowById(packetId);
|
|
662
|
+
if (!row) return { ok: true };
|
|
663
|
+
try {
|
|
664
|
+
// Server first: a transient network/API error must never make a recoverable
|
|
665
|
+
// item disappear locally without actually entering Recently Deleted.
|
|
666
|
+
const client = await relayClient();
|
|
667
|
+
await client.deleteInboxItem(packetId, { idempotencyKey: idempotencyKey("inbox_delete") });
|
|
668
|
+
withJsonLock(STATE_PATH, () => {
|
|
669
|
+
const store = readStore();
|
|
670
|
+
if (store.packets) delete store.packets[packetId];
|
|
671
|
+
writeStateAtomic(store);
|
|
672
|
+
});
|
|
673
|
+
presentedRelayIds.delete(packetId);
|
|
674
|
+
activeAttentionIds.delete(packetId);
|
|
675
|
+
writeOverlayPrefs();
|
|
676
|
+
await pushInbox(true);
|
|
677
|
+
return { ok: true };
|
|
678
|
+
} catch (error) {
|
|
679
|
+
const message = error && error.message ? error.message : String(error);
|
|
680
|
+
console.error("[overlay] relay delete failed:", message);
|
|
681
|
+
return { ok: false, error: message };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
659
685
|
// ---- mutations -----------------------------------------------------------
|
|
660
686
|
|
|
661
687
|
function idempotencyKey(prefix) {
|
|
@@ -1598,6 +1624,7 @@ ipcMain.on("relay:openSent", (_e, id) => {
|
|
|
1598
1624
|
ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
|
|
1599
1625
|
ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
|
|
1600
1626
|
ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
|
|
1627
|
+
ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
|
|
1601
1628
|
ipcMain.handle("relay:refreshSent", async () => {
|
|
1602
1629
|
await refreshSent();
|
|
1603
1630
|
await pushInbox(true);
|
package/overlay/preload.cjs
CHANGED
|
@@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
15
15
|
open: (id) => ipcRenderer.send("relay:open", id),
|
|
16
16
|
openSent: (id) => ipcRenderer.send("relay:openSent", id),
|
|
17
17
|
ack: (id) => ipcRenderer.send("relay:ack", id),
|
|
18
|
+
deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
|
|
18
19
|
openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
|
|
19
20
|
refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
|
|
20
21
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -73,6 +73,18 @@ export class RelayClient {
|
|
|
73
73
|
return this.#req("POST", `/v1/relays/${encodeURIComponent(id)}/read`, payload);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
deleteInboxItem(itemId, payload = {}) {
|
|
77
|
+
return this.#req("POST", `/v1/inbox-items/${encodeURIComponent(itemId)}/delete`, payload);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
recentlyDeleted() {
|
|
81
|
+
return this.#req("GET", "/v1/recently-deleted");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
restoreInboxItem(itemId, payload = {}) {
|
|
85
|
+
return this.#req("POST", `/v1/recently-deleted/${encodeURIComponent(itemId)}/restore`, payload);
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
openRelay(token) {
|
|
77
89
|
return this.#req("GET", `/v1/open/${encodeURIComponent(token)}`, undefined, { auth: false });
|
|
78
90
|
}
|
package/src/desktop-migration.js
CHANGED
|
@@ -43,7 +43,11 @@ function readMarker(markerPath) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function markerSurfacePaths(marker) {
|
|
46
|
-
|
|
46
|
+
const appExecutablePath =
|
|
47
|
+
typeof marker?.appPath === "string" && marker.appPath
|
|
48
|
+
? path.join(marker.appPath, "Contents", "MacOS", "applet")
|
|
49
|
+
: null;
|
|
50
|
+
return [marker?.appPath, appExecutablePath, marker?.pillPlistPath, marker?.daemonPlistPath];
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export function desktopMigrationMarkerIsCurrent(
|
package/src/install.js
CHANGED
|
@@ -194,21 +194,44 @@ export function installRelayMacApp({
|
|
|
194
194
|
appPaths: [RELAY_MAC_APP_NAME, RELAY_MAC_APP_FALLBACK_NAME].map((name) => path.join(applicationsDir, name)),
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
|
-
|
|
197
|
+
// Keep the staging bundle's final extension as .app. osacompile decides whether
|
|
198
|
+
// to produce an application bundle from the output extension; a generic .tmp
|
|
199
|
+
// path would produce a flat compiled script instead. Nest it under a hidden,
|
|
200
|
+
// same-volume directory so Spotlight cannot transiently expose a duplicate app.
|
|
201
|
+
const stagingDir = path.join(applicationsDir, `.relay-app-staging-${process.pid}-${Date.now()}`);
|
|
202
|
+
const tmpPath = path.join(stagingDir, RELAY_MAC_APP_NAME);
|
|
198
203
|
const contentsDir = path.join(tmpPath, "Contents");
|
|
199
204
|
const macosDir = path.join(contentsDir, "MacOS");
|
|
200
205
|
const resourcesDir = path.join(contentsDir, "Resources");
|
|
201
|
-
const
|
|
206
|
+
const appletExecutablePath = path.join(macosDir, "applet");
|
|
207
|
+
const launcherPath = path.join(resourcesDir, "relay-launcher.sh");
|
|
202
208
|
const logPath = path.join(homeDir, ".relay", "pill.log");
|
|
203
209
|
const pillPlistPath = path.join(homeDir, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
|
|
204
210
|
const version = packageVersion(packageRoot);
|
|
205
211
|
|
|
206
212
|
try {
|
|
207
|
-
fs.rmSync(
|
|
208
|
-
fs.mkdirSync(
|
|
209
|
-
fs.mkdirSync(
|
|
213
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
214
|
+
fs.mkdirSync(applicationsDir, { recursive: true });
|
|
215
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
210
216
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
211
217
|
|
|
218
|
+
// LaunchServices rejects a shell script used directly as CFBundleExecutable
|
|
219
|
+
// (NSOSStatusErrorDomain -10669 on current macOS), even when it has a valid
|
|
220
|
+
// shebang and executable mode. Generate the system AppleScript applet wrapper
|
|
221
|
+
// so the bundle has a native universal Mach-O executable. The applet only runs
|
|
222
|
+
// our resource shell script; it does not automate or inspect another app.
|
|
223
|
+
const appletSource = [
|
|
224
|
+
"on run",
|
|
225
|
+
' set launcherPath to POSIX path of (path to resource "relay-launcher.sh")',
|
|
226
|
+
' do shell script "/bin/sh " & quoted form of launcherPath',
|
|
227
|
+
"end run",
|
|
228
|
+
].join("\n");
|
|
229
|
+
const compiled = runCommand("/usr/bin/osacompile", ["-e", appletSource, "-o", tmpPath]);
|
|
230
|
+
if (!compiled.ok || !fs.existsSync(appletExecutablePath)) {
|
|
231
|
+
throw new Error(`could not compile native Relay app launcher${compiled.out ? `: ${compiled.out}` : ""}`);
|
|
232
|
+
}
|
|
233
|
+
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
234
|
+
|
|
212
235
|
const launcher = [
|
|
213
236
|
"#!/bin/sh",
|
|
214
237
|
'nonce="app-$$-$(date +%s)"',
|
|
@@ -247,35 +270,61 @@ export function installRelayMacApp({
|
|
|
247
270
|
<dict>
|
|
248
271
|
<key>CFBundleName</key><string>Relay</string>
|
|
249
272
|
<key>CFBundleDisplayName</key><string>Relay</string>
|
|
250
|
-
<key>CFBundleExecutable</key><string>
|
|
273
|
+
<key>CFBundleExecutable</key><string>applet</string>
|
|
251
274
|
<key>CFBundleIdentifier</key><string>${RELAY_MAC_BUNDLE_IDENTIFIER}</string>
|
|
275
|
+
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
|
252
276
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
277
|
+
<key>CFBundleSignature</key><string>aplt</string>
|
|
253
278
|
<key>CFBundleShortVersionString</key><string>${plistEscape(version)}</string>
|
|
254
279
|
<key>CFBundleVersion</key><string>${plistEscape(version)}</string>
|
|
255
280
|
${iconEntry} <key>LSUIElement</key><true/>
|
|
256
281
|
<key>NSHighResolutionCapable</key><true/>
|
|
282
|
+
<key>OSAAppletShowStartupScreen</key><false/>
|
|
257
283
|
</dict>
|
|
258
284
|
</plist>
|
|
259
285
|
`;
|
|
260
286
|
fs.writeFileSync(path.join(contentsDir, "Info.plist"), info);
|
|
261
287
|
fs.writeFileSync(path.join(contentsDir, "PkgInfo"), "APPL????");
|
|
262
288
|
|
|
289
|
+
// osacompile signs the initial applet, so adding our launcher, icon, and final
|
|
290
|
+
// Info.plist invalidates that temporary signature. Re-sign the completed bundle
|
|
291
|
+
// and verify it before exposing it to LaunchServices; otherwise a bundle can be
|
|
292
|
+
// indexed by Spotlight yet still fail when the user clicks it.
|
|
293
|
+
const signed = runCommand("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", tmpPath]);
|
|
294
|
+
if (!signed.ok) {
|
|
295
|
+
throw new Error(`could not sign native Relay app launcher${signed.out ? `: ${signed.out}` : ""}`);
|
|
296
|
+
}
|
|
297
|
+
const verified = runCommand("/usr/bin/codesign", ["--verify", "--deep", "--strict", tmpPath]);
|
|
298
|
+
if (!verified.ok) {
|
|
299
|
+
throw new Error(`could not verify native Relay app launcher${verified.out ? `: ${verified.out}` : ""}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
// Replace the Relay-owned bundle atomically enough that Spotlight never indexes
|
|
264
303
|
// a half-written app. LaunchServices/Spotlight refreshes are best-effort; merely
|
|
265
304
|
// placing a valid .app under ~/Applications is sufficient on a normal macOS host.
|
|
266
|
-
|
|
305
|
+
// Re-check ownership at the destructive boundary in case another process placed
|
|
306
|
+
// or replaced an unrelated app at the chosen path while the bundle was compiling.
|
|
307
|
+
if (fs.existsSync(appPath) && !isRelayOwnedMacApp(appPath)) {
|
|
308
|
+
throw new Error("Relay app target changed during installation");
|
|
309
|
+
}
|
|
267
310
|
fs.rmSync(appPath, { recursive: true, force: true });
|
|
268
311
|
fs.renameSync(tmpPath, appPath);
|
|
312
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
269
313
|
runCommand("/usr/bin/touch", [appPath]);
|
|
270
314
|
runCommand(
|
|
271
315
|
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
|
272
316
|
["-f", appPath]
|
|
273
317
|
);
|
|
274
318
|
runCommand("/usr/bin/mdimport", [appPath]);
|
|
275
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
appPath,
|
|
322
|
+
launcherPath: path.join(appPath, "Contents", "Resources", "relay-launcher.sh"),
|
|
323
|
+
appletExecutablePath: path.join(appPath, "Contents", "MacOS", "applet"),
|
|
324
|
+
};
|
|
276
325
|
} catch (error) {
|
|
277
326
|
try {
|
|
278
|
-
fs.rmSync(
|
|
327
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
279
328
|
} catch {}
|
|
280
329
|
return {
|
|
281
330
|
ok: false,
|
package/src/mcp.js
CHANGED
|
@@ -94,6 +94,45 @@ export const TOOLS = [
|
|
|
94
94
|
"List ordinary Relay messages addressed to this human. This does not list task coordination relays; use relay_task_status for task state.",
|
|
95
95
|
inputSchema: { type: "object", properties: {} },
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
name: "relay_inbox_delete",
|
|
99
|
+
description:
|
|
100
|
+
"Move one Relay inbox item to Recently Deleted for this human. This immediately removes it from the Relay website inbox and companion pill, but does not erase the sender's Sent history and does not cancel, answer, or otherwise change an underlying Relay task. The item remains recoverable for exactly 30 days, then its recovery snapshot is permanently erased and restoration is impossible. For an ordinary relay, pass the relayId returned by relay_inbox_list. For a task question shown in the pill/site, pass human_question:<messageId>. For a terminal task notification, pass task_completed:<taskId>, task_cancelled:<taskId>, or task_failed:<taskId>. Do not guess an id or delete merely to acknowledge/mark handled; use relay_acknowledge for that ordinary-relay workflow. A pending human question may leave its agent task paused even after the inbox item is deleted, because deletion is only inbox organization. This operation is idempotent for a given item while it remains in Recently Deleted.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
itemId: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description:
|
|
107
|
+
"Exact stable inbox item id. Use relayId for ordinary relays, human_question:<messageId> for a task question, or task_<terminal-state>:<taskId> for a terminal task notification.",
|
|
108
|
+
},
|
|
109
|
+
idempotencyKey: { type: "string", description: "A unique key of at least 8 characters for this deletion request." },
|
|
110
|
+
},
|
|
111
|
+
required: ["itemId", "idempotencyKey"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "relay_recently_deleted_list",
|
|
116
|
+
description:
|
|
117
|
+
"Fetch this human's complete Recently Deleted Relay inbox. Use this before attempting a restore whenever the human describes an item by sender, title, content, or approximate time instead of giving an exact itemId. Results are ordered newest deletion first and include the exact itemId required by relay_recently_deleted_restore, source type, sender/title/body snapshot, deletion time, permanentlyDeletesAt, and daysRemaining. Recently Deleted exists only on the Relay website and through these MCP tools; it is intentionally never shown in the companion pill. Items remain here for 30 days from their most recent deletion. After permanentlyDeletesAt the recovery snapshot is erased, the item no longer appears here, and neither an agent nor Relay support can restore it. Listing is read-only and does not extend the 30-day deadline.",
|
|
118
|
+
inputSchema: { type: "object", properties: {} },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "relay_recently_deleted_restore",
|
|
122
|
+
description:
|
|
123
|
+
"Restore one exact inbox item from this human's Recently Deleted folder. First call relay_recently_deleted_list unless the human supplied an exact current itemId; match cautiously using sender, title, body, sourceType, and timestamps, and ask the human if multiple items could match. Never invent or infer an id. Restoration is only possible before permanentlyDeletesAt. A successful restore removes the item from Recently Deleted and makes it eligible to reappear in the website inbox and companion pill; the companion treats the restore as fresh delivery so an older item can notify again. Restoring a task question does not answer it, and may re-expose a still-paused run; restoring a task-status notification does not reopen the completed/cancelled/failed task. Restoring an ordinary relay does not alter the sender's Sent record. This operation is idempotent for the same already-restored item.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
itemId: {
|
|
128
|
+
type: "string",
|
|
129
|
+
description: "The exact itemId returned by relay_recently_deleted_list. Do not use a title, relay subject, or guessed task/message id.",
|
|
130
|
+
},
|
|
131
|
+
idempotencyKey: { type: "string", description: "A unique key of at least 8 characters for this restore request." },
|
|
132
|
+
},
|
|
133
|
+
required: ["itemId", "idempotencyKey"],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
97
136
|
{
|
|
98
137
|
name: "relay_acknowledge",
|
|
99
138
|
description:
|
|
@@ -456,6 +495,12 @@ export async function handleCall(client, name, args) {
|
|
|
456
495
|
);
|
|
457
496
|
case "relay_inbox_list":
|
|
458
497
|
return text(await client.inbox());
|
|
498
|
+
case "relay_inbox_delete":
|
|
499
|
+
return text(await client.deleteInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
|
|
500
|
+
case "relay_recently_deleted_list":
|
|
501
|
+
return text(await client.recentlyDeleted());
|
|
502
|
+
case "relay_recently_deleted_restore":
|
|
503
|
+
return text(await client.restoreInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
|
|
459
504
|
case "relay_acknowledge":
|
|
460
505
|
return text(await client.acknowledge(args.relayId, { idempotencyKey: args.idempotencyKey }));
|
|
461
506
|
case "relay_task_create":
|
package/src/notifications.js
CHANGED
|
@@ -113,7 +113,7 @@ function taskNotification(task, kind, title, body, url, extra = {}) {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [] } = {}) {
|
|
116
|
+
export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [], inboxState = {} } = {}) {
|
|
117
117
|
const notifications = [];
|
|
118
118
|
for (const task of tasks) {
|
|
119
119
|
const participant = participantForUser(task, user);
|
|
@@ -193,12 +193,22 @@ export function buildHumanNotifications({ user, tasks = [], relays = [], connect
|
|
|
193
193
|
};
|
|
194
194
|
notifications.push({ ...notification, action: actionForNotification(notification) });
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
const suppressed = new Set(inboxState.suppressedItemIds || []);
|
|
197
|
+
const restoredAtByItemId = inboxState.restoredAtByItemId || {};
|
|
198
|
+
return notifications
|
|
199
|
+
.filter((notification) => !suppressed.has(notification.id))
|
|
200
|
+
.map((notification) => ({
|
|
201
|
+
...notification,
|
|
202
|
+
deliveryVersion: restoredAtByItemId[notification.id] || "",
|
|
203
|
+
}));
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
export function freshNotifications(ledger, notifications) {
|
|
200
207
|
const seen = ledger.notifications || {};
|
|
201
|
-
return notifications.filter((notification) =>
|
|
208
|
+
return notifications.filter((notification) => {
|
|
209
|
+
const previous = seen[notification.id];
|
|
210
|
+
return !previous || (notification.deliveryVersion || "") !== (previous.deliveryVersion || "");
|
|
211
|
+
});
|
|
202
212
|
}
|
|
203
213
|
|
|
204
214
|
export function markNotificationsProcessed(ledger, notifications) {
|
|
@@ -207,6 +217,7 @@ export function markNotificationsProcessed(ledger, notifications) {
|
|
|
207
217
|
for (const notification of notifications) {
|
|
208
218
|
ledger.notifications[notification.id] = {
|
|
209
219
|
kind: notification.kind,
|
|
220
|
+
deliveryVersion: notification.deliveryVersion || "",
|
|
210
221
|
processedAt,
|
|
211
222
|
};
|
|
212
223
|
}
|
package/src/task-daemon.js
CHANGED
|
@@ -176,6 +176,7 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
|
|
|
176
176
|
tasks: tasks.tasks || [],
|
|
177
177
|
relays: relays.relays || [],
|
|
178
178
|
connectors: connectors.connectors || [],
|
|
179
|
+
inboxState: relays.inboxState || {},
|
|
179
180
|
});
|
|
180
181
|
const fresh = freshNotifications(ledger, notifications);
|
|
181
182
|
const staged = [];
|