mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-08 15:04:33 +00:00
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:
14 files changed
+376
-85
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 )
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user