From 8d3a1ccc2bda2cf26d7373eb3ad6a478a61b3b42 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Fri, 30 Oct 2026 22:52:27 -0700 Subject: [PATCH] feat(notifications): remove a notification from the dashboard history (#4412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Daniel Salazar --- src/backend/clients/event/types.ts | 3 +- .../NotificationController.http.test.ts | 36 ++++++- .../NotificationController.test.ts | 71 ++++++++++++++ .../notification/NotificationController.ts | 95 +++++++++---------- .../notification/NotificationService.test.ts | 43 +++++++++ .../notification/NotificationService.ts | 13 +++ .../notification/notificationSocket.ts | 20 +++- .../UI/Dashboard/UIDashboardNotifications.js | 88 +++++++++++++---- .../src/UI/Dashboard/notificationCenter.js | 19 ++++ .../UI/Dashboard/notificationCenter.test.js | 14 +++ src/gui/src/UI/UIDesktop.js | 6 ++ src/gui/src/css/dashboard.css | 31 ++++-- src/gui/src/helpers/notificationApi.js | 19 ++++ src/gui/src/i18n/translations/en.js | 3 + 14 files changed, 376 insertions(+), 85 deletions(-) diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index e20d5fda1..03fa20e55 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -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 }[]; }>; diff --git a/src/backend/controllers/notification/NotificationController.http.test.ts b/src/backend/controllers/notification/NotificationController.http.test.ts index 0a9e07fdd..059e164a8 100644 --- a/src/backend/controllers/notification/NotificationController.http.test.ts +++ b/src/backend/controllers/notification/NotificationController.http.test.ts @@ -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); + }); }); diff --git a/src/backend/controllers/notification/NotificationController.test.ts b/src/backend/controllers/notification/NotificationController.test.ts index 6f49e6f60..c58a42a57 100644 --- a/src/backend/controllers/notification/NotificationController.test.ts +++ b/src/backend/controllers/notification/NotificationController.test.ts @@ -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); + }); +}); diff --git a/src/backend/controllers/notification/NotificationController.ts b/src/backend/controllers/notification/NotificationController.ts index 1a145826f..3790c1ea5 100644 --- a/src/backend/controllers/notification/NotificationController.ts +++ b/src/backend/controllers/notification/NotificationController.ts @@ -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 { - 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 as { - notification: { - markAcknowledged: ( - uid: string, - userId: number, - ) => Promise; - }; - } - ).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 { + const { uid, userId } = markTarget(req); + await this.services.notification.remove(uid, userId); res.json({}); } } diff --git a/src/backend/services/notification/NotificationService.test.ts b/src/backend/services/notification/NotificationService.test.ts index 2dac482a1..87147e812 100644 --- a/src/backend/services/notification/NotificationService.test.ts +++ b/src/backend/services/notification/NotificationService.test.ts @@ -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(); diff --git a/src/backend/services/notification/NotificationService.ts b/src/backend/services/notification/NotificationService.ts index f0bb72007..d58fa0dcd 100644 --- a/src/backend/services/notification/NotificationService.ts +++ b/src/backend/services/notification/NotificationService.ts @@ -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 { + 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 diff --git a/src/backend/services/notification/notificationSocket.ts b/src/backend/services/notification/notificationSocket.ts index 4371a65c8..83cdd5da8 100644 --- a/src/backend/services/notification/notificationSocket.ts +++ b/src/backend/services/notification/notificationSocket.ts @@ -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 diff --git a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js index 84bddaa0e..9c089ab02 100644 --- a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js +++ b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js @@ -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, 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} - ${unread ? `` : ''} + + ${unread ? `` : ''} + + `; }; + /** 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()); diff --git a/src/gui/src/UI/Dashboard/notificationCenter.js b/src/gui/src/UI/Dashboard/notificationCenter.js index 38a578767..1ae343793 100644 --- a/src/gui/src/UI/Dashboard/notificationCenter.js +++ b/src/gui/src/UI/Dashboard/notificationCenter.js @@ -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} marks + * @param {number} requestedAt - Epoch ms + * @returns {Set} + */ +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. diff --git a/src/gui/src/UI/Dashboard/notificationCenter.test.js b/src/gui/src/UI/Dashboard/notificationCenter.test.js index a8f822dfc..9136120c8 100644 --- a/src/gui/src/UI/Dashboard/notificationCenter.test.js +++ b/src/gui/src/UI/Dashboard/notificationCenter.test.js @@ -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); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 20819e1a4..ebac9b7c8 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -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 ) diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index c43eb30ab..0b527bca4 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -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 { diff --git a/src/gui/src/helpers/notificationApi.js b/src/gui/src/helpers/notificationApi.js index f42b5abe1..25d3e9167 100644 --- a/src/gui/src/helpers/notificationApi.js +++ b/src/gui/src/helpers/notificationApi.js @@ -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} + */ +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 diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 95f1bdd58..b6fb1963b 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -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',