feat(notifications): remove a notification from the dashboard history (#4412)

Each notification in the dashboard list gets a remove (✕) control next to
mark-as-read. It calls the new POST /notif/delete, which deletes the user's
own row and sends `notif.removed` to their other tabs so the entry and any
toast for it disappear everywhere.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
authored and GitHub committed 2026-10-30 22:52:27 -07:00
1 parent a1fe14cbd5
commit 8d3a1ccc2b
14 files changed
+376 -85

No files matched your search

+2 -1
View File
@@ -586,7 +586,7 @@ export type EventMap = {
*/
'outer.notif.delivery': {
userId: number;
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
wire: 'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
response: unknown;
};
/**
@@ -752,6 +752,7 @@ export type EventMap = {
'outer.gui.item.renamed': GuiEvent;
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
'outer.gui.notif.removed': GuiEvent<{ uid: string }>;
'outer.gui.notif.unreads': GuiEvent<{
unreads: { uid: string; notification: unknown }[];
}>;
@@ -21,9 +21,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Route-level coverage for the notification endpoint. Dismissing is the only
* mark the desktop performs over HTTP, so this suite is what catches the route
* going missing or a gate turning a legitimate dismiss away.
* Route-level coverage for the notification endpoints the desktop calls over
* HTTP, so this suite is what catches a route going missing or a gate turning
* a legitimate dismiss or removal away.
*/
describe('notification endpoints over HTTP', () => {
let env: PuterTestEnv;
@@ -85,4 +85,34 @@ describe('notification endpoints over HTTP', () => {
});
expect(after?.acknowledged).toBeFalsy();
});
it('removes a notification', async () => {
const owner = env.users.user;
const { userId, uid } = await seedNotification(owner.username);
const res = await post('/notif/delete', owner.token, { uid });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({});
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after).toBeNull();
});
it('refuses an anonymous removal', async () => {
const { userId, uid } = await seedNotification(env.users.other.username);
const res = await fetch(new URL('/notif/delete', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uid }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after?.uid).toBe(uid);
});
});
@@ -183,3 +183,74 @@ describe('NotificationController.markAck', () => {
expect(after?.acknowledged).toBeFalsy();
});
});
// ── /notif/delete ───────────────────────────────────────────────────
describe('NotificationController.remove', () => {
it('deletes the underlying notification row', async () => {
const { actor, userId } = await makeUser();
const created = await server.stores.notification.create({
userId,
value: { title: 't' },
});
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor }),
res,
);
expect(captured.body).toEqual({});
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId },
);
expect(after).toBeNull();
});
it('succeeds for a uid that names no row', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: 'no-such-uid' }, actor }),
res,
);
expect(captured.body).toEqual({});
});
it('rejects a missing uid with 400', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: {}, actor }), res),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 401 when there is no actor on the request', async () => {
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: { uid: 'whatever' } }), res),
).rejects.toMatchObject({ statusCode: 401 });
});
it("does not delete another user's notification", async () => {
const a = await makeUser();
const b = await makeUser();
const created = await server.stores.notification.create({
userId: a.userId,
value: {},
});
const { res } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor: b.actor }),
res,
);
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId: a.userId },
);
expect(after?.uid).toBe(created.uid);
});
});
@@ -20,16 +20,44 @@
import type { Request, Response } from 'express';
import { Controller, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { NotificationService } from '../../services/notification/NotificationService.js';
import { PuterController } from '../types.js';
/** Both routes fire per interaction; the ceiling catches a loop, not a user. */
const MARK_ROUTE_OPTIONS = {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
} as const;
/** The uid and owner a mark names, or the 400/401 the request earns. */
const markTarget = (req: Request): { uid: string; userId: number } => {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return { uid, userId };
};
/**
* GUI-facing notification endpoint. Supplements the `puter-notifications`
* driver (which handles CRUD via `/drivers/call`) with the one mutation route
* the puter desktop client calls directly.
* GUI-facing notification endpoints. Supplement the `puter-notifications`
* driver (which handles reads via `/drivers/call`) with the mutations the puter
* desktop client calls directly.
*
* It emits `outer.gui.notif.ack` via the NotificationService so other open tabs
* for the same user see the state change immediately.
* Each goes through the NotificationService, which tells the user's other open
* tabs (`notif.ack`, `notif.removed`) so they update immediately.
*/
@Controller('/notif')
export class NotificationController extends PuterController {
@@ -37,50 +65,21 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification. Sets `acknowledged`
* timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
// Fires per notification interaction, so the ceiling stays
// generous — it is here to catch a loop, not to pace a user.
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
})
@Post('/mark-ack', MARK_ROUTE_OPTIONS)
async markAck(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
const { uid, userId } = markTarget(req);
await this.services.notification.markAcknowledged(uid, userId);
res.json({});
}
const notifService = this.services.notification as unknown as
| NotificationService
| undefined;
if (notifService?.markAcknowledged) {
await notifService.markAcknowledged(uid, userId);
} else {
// Fallback: direct store call if service isn't wired
await (
this.stores as Record<string, unknown> as {
notification: {
markAcknowledged: (
uid: string,
userId: number,
) => Promise<boolean>;
};
}
).notification.markAcknowledged(uid, userId);
}
/**
* POST /notif/delete — user removed a notification from their history. A
* uid naming none of their rows succeeds too: it is gone either way.
*/
@Post('/delete', MARK_ROUTE_OPTIONS)
async remove(req: Request, res: Response): Promise<void> {
const { uid, userId } = markTarget(req);
await this.services.notification.remove(uid, userId);
res.json({});
}
}
@@ -545,6 +545,49 @@ describe('NotificationService.markAcknowledged', () => {
});
});
describe('NotificationService.remove', () => {
it("deletes the row and tells the user's other tabs", async () => {
const user = await makeUser();
const row = await server.stores.notification.create({
userId: user.id,
value: { source: 'test', title: 'remove me' },
});
const removals = collect('outer.gui.notif.removed');
const removed = await notifications.remove(row.uid, user.id);
expect(removed).toBe(true);
expect(removals.seen).toEqual([
{ user_id_list: [user.id], response: { uid: row.uid } },
]);
const fresh = await server.stores.notification.getByUid(row.uid, {
userId: user.id,
});
expect(fresh).toBeNull();
removals.stop();
});
it("leaves another user's row alone and sends nothing", async () => {
const owner = await makeUser();
const other = await makeUser();
const row = await server.stores.notification.create({
userId: owner.id,
value: { source: 'test', title: 'not yours' },
});
const removals = collect('outer.gui.notif.removed');
const removed = await notifications.remove(row.uid, other.id);
expect(removed).toBe(false);
expect(removals.seen).toEqual([]);
const fresh = await server.stores.notification.getByUid(row.uid, {
userId: owner.id,
});
expect(fresh?.uid).toBe(row.uid);
removals.stop();
});
});
describe('NotificationService — unread delivery on connect', () => {
it('sends unseen notifications once per burst of tab connects and marks them shown', async () => {
vi.useFakeTimers();
@@ -277,6 +277,19 @@ export class NotificationService extends PuterService {
return acknowledged;
}
/**
* Delete one of the user's notifications and tell their other tabs, so it
* leaves every open list at once.
*
* @returns Whether a row was deleted; false for a uid naming none of the
* user's rows, which also sends nothing.
*/
async remove(uid: string, userId: number): Promise<boolean> {
const removed = await this.stores.notification.deleteByUid(uid, userId);
if (removed) this.#socket().removed(userId, uid);
return removed;
}
/**
* Deliver one dispatched notification over the desktop's wire. The events
* layer decides who is owed a delivery; this turns that decision into the
@@ -32,9 +32,9 @@ import type { IConfig } from '../../types.js';
/**
* The desktop's notification wire, kept byte for byte while its delivery moves
* underneath it. `notif.message`, `notif.unreads` and `notif.ack` are a live
* GUI contract, so the adapter is the one place they are shaped, whichever path
* produced the notification.
* underneath it. `notif.message`, `notif.unreads`, `notif.ack` and
* `notif.removed` are a live GUI contract, so the adapter is the one place they
* are shaped, whichever path produced the notification.
*
* Two routes, one wire:
*
@@ -52,7 +52,8 @@ import type { IConfig } from '../../types.js';
* working. It goes when the flag is on everywhere.
*/
export type NotifWire = 'notif.message' | 'notif.unreads' | 'notif.ack';
export type NotifWire =
'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
export const NOTIF_DELIVERY_EVENT = 'outer.notif.delivery';
@@ -207,6 +208,17 @@ export class NotificationSocketAdapter {
);
}
/** "This is gone", to the recipient's other tabs. */
removed(userId: number, uid: string): void {
const response = { uid };
if (this.#dispatched(userId, 'notif.removed', response)) return;
this.#deps.event.emit(
'outer.gui.notif.removed',
{ user_id_list: [userId], response },
{},
);
}
/**
* Asks the desktop's sockets, or with `appUid` the app's own, whether the
* live copy of `uid` reached a page, and settles that as
@@ -19,7 +19,7 @@
import UINotification from '../UINotification.js';
import { reveal_dashboard } from '../UIWindow.js';
import { getNotification, listNotifications, markNotificationAcknowledged } from '../../helpers/notificationApi.js';
import { deleteNotification, getNotification, listNotifications, markNotificationAcknowledged } from '../../helpers/notificationApi.js';
import { applyToastMark, createNotificationFeed, createReplayLog, missedAfterReconnect } from '../../helpers/notificationFeed.js';
import { formatAbsoluteTime, formatRelativeTime } from '../../i18n/formatTime.js';
import {
@@ -45,6 +45,7 @@ import {
reconcileWithServer,
senderNameFor,
setReadAt,
takeMarksSince,
titleWithBadge,
toEntry,
unreadCount,
@@ -174,7 +175,7 @@ const runLimited = async (items, limit, task) => {
* Reading one (its check, or acting on it) acknowledges it on the server,
* which also marks it read in every other open tab; it stays listed, just
* quieter. A toast timing out does not, so nothing is lost while someone is
* looking away.
* looking away. Removing one (its ✕) deletes it, from every open tab too.
*
* @param {{ $el_window: JQuery, socket: import('socket.io-client').Socket }} opts
* @returns {{ open: () => void, close: (opts?: { restoreFocus?: boolean }) => void, refresh: () => Promise<void>, isOpen: () => boolean }}
@@ -204,6 +205,8 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
* ack finished, `Infinity` while it is still out.
*/
const acked = new Map();
/** Deletions a listing in flight can't know about; same shape as `acked`. */
const removed = new Map();
let listingRequestedAt = 0;
/** Uids of the first listing, which a reconnect's catch-up never raises. */
let knownAtLoad = null;
@@ -313,17 +316,24 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
${time}
</span>
</${actionable ? 'button' : 'div'}>
${unread ? `<button type="button" class="dashboard-notification-read"
aria-label="${i18n('notification_mark_read_named', { title })}" title="${i18n('notification_mark_read')}">${checkIcon}</button>` : ''}
<span class="dashboard-notification-actions">
${unread ? `<button type="button" class="dashboard-notification-read"
aria-label="${i18n('notification_mark_read_named', { title })}" title="${i18n('notification_mark_read')}">${checkIcon}</button>` : ''}
<button type="button" class="dashboard-notification-remove"
aria-label="${i18n('notification_remove_named', { title })}" title="${i18n('notification_remove')}">${closeIcon}</button>
</span>
</li>`;
};
/** A row's secondary controls, by the class each one carries. */
const ROW_CONTROLS = ['dashboard-notification-read', 'dashboard-notification-remove'];
/**
* Where the keyboard is in the list, so a re-render — which rebuilds
* every row — can put it back: on the same entry, or if that one is
* gone, on the entry that took its place. A check that was just pressed
* is gone with the unread state; the row's own control, or the panel,
* takes the focus instead.
* every row — can put it back: on the same control of the same entry, or
* if that entry is gone, of the one that took its place. A check that was
* just pressed is gone with the unread state; the row's own control, or
* the panel, takes the focus instead.
*/
const focusedRow = () => {
const active = document.activeElement;
@@ -332,12 +342,12 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
return {
uid: row.getAttribute('data-uid'),
index: $list.children().index(row),
read: active.classList.contains('dashboard-notification-read'),
control: ROW_CONTROLS.find((name) => active.classList.contains(name)) ?? null,
};
};
const rowControl = (row, read) => (
(read ? row.querySelector('.dashboard-notification-read') : null)
const rowControl = (row, control) => (
(control ? row.querySelector(`.${control}`) : null)
?? row.querySelector('button')
);
@@ -346,7 +356,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
const rows = $list.children().get();
const same = rows.find((row) => row.getAttribute('data-uid') === was.uid);
const row = same ?? rows[Math.min(was.index, rows.length - 1)];
const target = (row && rowControl(row, was.read)) ?? $panel[0];
const target = (row && rowControl(row, was.control)) ?? $panel[0];
target.focus({ preventScroll: true });
};
@@ -411,14 +421,12 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
missed = missedAfterReconnect(rows, { known: knownAtLoad, log: replayLog });
}
const now = Date.now();
const server = listedEntries(rows, now, HISTORY_LIMIT);
// Acked since this listing was requested: unread in its snapshot.
// Anything acked before it is settled and can be forgotten.
const readSince = new Set();
for ( const [uid, at] of acked ) {
if ( at >= listingRequestedAt ) readSince.add(uid);
else acked.delete(uid);
}
// Acked or removed since this listing was requested: its
// snapshot still has them unread, or at all.
const readSince = takeMarksSince(acked, listingRequestedAt);
const removedSince = takeMarksSince(removed, listingRequestedAt);
const server = listedEntries(rows, now, HISTORY_LIMIT)
.filter((entry) => ! removedSince.has(entry.uid));
entries = reconcileWithServer(entries, server, now, undefined, readSince);
loaded = true;
if ( missed.length ) receive(missed, { replay: true });
@@ -476,6 +484,31 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
}
};
/** Take an entry, and any toast for it, off screen. */
const dropEntry = (uid) => {
$(`.notification[data-uid="${html_encode(uid)}"]`).closest('.notification-wrapper').remove();
entries = entries.filter((e) => e.uid !== uid);
};
/** Take an entry off the list now and delete it on the server; put it back if that fails. */
const remove = async (uid) => {
const entry = entries.find((e) => e.uid === uid);
if ( ! entry ) return;
removed.set(uid, Infinity);
dropEntry(uid);
render();
try {
await deleteNotification(uid);
removed.set(uid, Date.now());
} catch (err) {
console.warn('Could not remove notification:', err);
removed.delete(uid);
entries = mergeEntries(entries, [entry]).entries;
render();
setStatus(i18n('notifications_remove_failed', [], false));
}
};
// -- Acting on an entry -------------------------------------------------
/** Show the Files tab on Shared, picking out `paths` if they are there. */
@@ -589,7 +622,8 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
*/
const receive = (rawItems, { replay = false } = {}) => {
const now = Date.now();
const incoming = rawItems.map((raw) => toEntry(raw, now)).filter(Boolean);
const incoming = rawItems.map((raw) => toEntry(raw, now))
.filter((entry) => entry && ! removed.has(entry.uid));
const result = mergeEntries(entries, incoming);
entries = result.entries;
@@ -741,6 +775,10 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
e.stopPropagation();
void markRead($(this).closest('.dashboard-notification').attr('data-uid'));
});
$panel.on('click', '.dashboard-notification-remove', function (e) {
e.stopPropagation();
void remove($(this).closest('.dashboard-notification').attr('data-uid'));
});
$panel.on('click', '.dashboard-notification-main.is-actionable', function () {
const uid = $(this).closest('.dashboard-notification').attr('data-uid');
const entry = entries.find((e) => e.uid === uid);
@@ -807,6 +845,14 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
entries = setReadAt(entries, uid, now);
render();
});
// Removed in another tab. Not gated, for the same reason as the ack.
socket.on('notif.removed', ({ uid }) => {
if ( ! uid ) return;
removed.set(uid, Date.now());
const listed = entries.some((e) => e.uid === uid);
dropEntry(uid);
if ( listed ) render();
});
// Every (re)connection: anything read elsewhere while this tab was
// offline is marked so in the server's list, and anything new is in it.
socket.on('connect', () => void refresh());
@@ -238,6 +238,25 @@ export const reconcileWithServer = (local, server, now, graceMs = 15_000, readSi
return sortEntries([...listed, ...recent]);
};
/**
* The uids changed after a listing was requested — its snapshot predates
* them — from a map of uid to when the change finished (`Infinity` while it
* is still out). Entries that finished before are settled in any listing
* from now on, so they are dropped from the map.
*
* @param {Map<string, number>} marks
* @param {number} requestedAt - Epoch ms
* @returns {Set<string>}
*/
export const takeMarksSince = (marks, requestedAt) => {
const since = new Set();
for ( const [uid, at] of marks ) {
if ( at >= requestedAt ) since.add(uid);
else marks.delete(uid);
}
return since;
};
/**
* The list with one entry's read state changed; `null` makes it unread
* again, for rolling back an acknowledgement the server refused.
@@ -34,6 +34,7 @@ import {
senderNameFor,
setReadAt,
sortEntries,
takeMarksSince,
titleWithBadge,
toEntry,
unreadCount,
@@ -295,6 +296,19 @@ describe('setReadAt', () => {
});
});
describe('takeMarksSince', () => {
it('returns what finished at or after the request, or is still out', () => {
const marks = new Map([['before', NOW - 1], ['at', NOW], ['after', NOW + 1], ['out', Infinity]]);
expect(takeMarksSince(marks, NOW)).toEqual(new Set(['at', 'after', 'out']));
});
it('forgets what finished before the request', () => {
const marks = new Map([['before', NOW - 1], ['out', Infinity]]);
takeMarksSince(marks, NOW);
expect([...marks.keys()]).toEqual(['out']);
});
});
describe('unreadCount', () => {
it('counts only what has no read time', () => {
expect(unreadCount([entry('a', NOW), entry('b', NOW, { readAt: NOW }), entry('c', NOW)])).toBe(2);
+6
View File
@@ -381,6 +381,12 @@ async function UIDesktop (options) {
update_tab_notif_count_badge();
});
// Removed from the notification history: its toast goes too.
window.socket.on('notif.removed', ({ uid }) => {
toasts_for(uid).remove();
update_tab_notif_count_badge();
});
window.socket.on('app.opened', async (app) => {
// don't update if this is the original client that initiated the action
if ( app.original_client_socket_id === window.socket.id )
+23 -8
View File
@@ -8794,10 +8794,14 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
display: flex;
flex-direction: column;
gap: 2px;
/* Room for the ✕ that appears on hover, so text never sits under it */
/* Room for the controls that appear on hover, so text never sits under them */
padding-right: 22px;
}
.dashboard-notification.is-unread .dashboard-notification-content {
padding-right: 50px;
}
.dashboard-notification-sender {
font-size: 12px;
color: var(--dashboard-text-secondary);
@@ -8844,10 +8848,16 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin-top: 2px;
}
.dashboard-notification-read {
.dashboard-notification-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
}
.dashboard-notification-read,
.dashboard-notification-remove {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -8863,32 +8873,36 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
transition: opacity 0.12s, background-color 0.15s, color 0.15s;
}
.dashboard-notification-read svg {
.dashboard-notification-read svg,
.dashboard-notification-remove svg {
width: 14px;
height: 14px;
}
.dashboard-notification:hover .dashboard-notification-read,
.dashboard-notification:focus-within .dashboard-notification-read {
.dashboard-notification:hover :is(.dashboard-notification-read, .dashboard-notification-remove),
.dashboard-notification:focus-within :is(.dashboard-notification-read, .dashboard-notification-remove) {
opacity: 1;
}
@media (hover: hover) {
.dashboard-notification-read:hover {
.dashboard-notification-read:hover,
.dashboard-notification-remove:hover {
background: var(--dashboard-border);
color: var(--dashboard-text);
}
}
/* Touch has no hover to reveal it */
/* Touch has no hover to reveal them */
@media (hover: none) {
.dashboard-notification-read {
.dashboard-notification-read,
.dashboard-notification-remove {
opacity: 1;
}
}
.dashboard-notification-main:focus-visible,
.dashboard-notification-read:focus-visible,
.dashboard-notification-remove:focus-visible,
.dashboard-notifications-mark-all:focus-visible,
.dashboard-notifications-close:focus-visible,
.dashboard-notifications-retry:focus-visible {
@@ -9168,6 +9182,7 @@ body.dashboard-mode .notifications-close-all {
.dashboard-notifications-scrim,
.dashboard-notification,
.dashboard-notification-read,
.dashboard-notification-remove,
.dashboard-notification-icon,
.dashboard-notification-title,
.dashboard-notification-text {
+19
View File
@@ -47,6 +47,25 @@ export async function markNotificationAcknowledged (uid) {
}
}
/**
* Delete one of the user's notifications. Resolves once the server has done
* it, including when it was already gone; rejects on a network or HTTP
* failure so callers can put the entry back.
*
* @param {string} uid
* @returns {Promise<void>}
*/
export async function deleteNotification (uid) {
const res = await fetch(`${window.api_origin}/notif/delete`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ uid }),
});
if ( ! res.ok ) {
throw new Error(`notification delete failed (${res.status})`);
}
}
/**
* Claim a notification as shown. Distinct from dismissing it: the mailbox
* records that it reached someone, which is what keeps a replay from raising
+3
View File
@@ -335,6 +335,8 @@ const en = {
notification: 'Notification',
notification_mark_read: 'Mark as read',
notification_mark_read_named: 'Mark as read: {{title}}',
notification_remove: 'Remove',
notification_remove_named: 'Remove: {{title}}',
notification_unread: 'Unread',
notifications: 'Notifications',
notifications_empty_title: 'No notifications yet',
@@ -344,6 +346,7 @@ const en = {
notifications_mark_read_failed: "Couldn't mark that notification as read. Please try again.",
notifications_new_count: '{{count}} new notifications',
notifications_open_hint: 'Open Notifications to see them',
notifications_remove_failed: "Couldn't remove that notification. Please try again.",
notifications_unread_count: '{{count}} unread',
ok: 'OK',
or: 'or',