diff --git a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js
index 27cedfb10..31f33ee5f 100644
--- a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js
+++ b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js
@@ -25,13 +25,15 @@ import {
formatAbsoluteTime,
formatRelativeTime,
glyphKey,
+ isUnread,
mergeEntries,
notificationTarget,
planBurstToasts,
reconcileWithServer,
- removeEntry,
+ setReadAt,
titleWithBadge,
toEntry,
+ unreadCount,
} from './notificationCenter.js';
const { html_encode } = window;
@@ -45,9 +47,12 @@ const RELATIVE_TIME_TICK_MS = 60_000;
/** Below this the panel is a bottom sheet; matches the sidebar's drawer breakpoint. */
const SHEET_BREAKPOINT = '(max-width: 768px)';
-/** Acknowledgements in flight at once when clearing the whole list. */
+/** Acknowledgements in flight at once when marking the whole list read. */
const ACK_CONCURRENCY = 6;
+/** How many of the most recent notifications the panel lists, read or not. */
+const HISTORY_LIMIT = 30;
+
/** Matches the panel's CSS transition; `hidden` is set once it has run. */
const CLOSE_ANIMATION_MS = 180;
@@ -59,6 +64,7 @@ const STROKE_ICON = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" strok
const bellIcon = ` `;
const closeIcon = ` `;
+const checkIcon = ` `;
const checkAllIcon = ` `;
/** The glyph in front of an entry, by who sent it. */
@@ -133,15 +139,15 @@ const runLimited = async (items, limit, task) => {
/**
* The dashboard's notification center: a bell in the sidebar with the unread
- * count, a panel listing everything not yet dismissed, and toasts for what
- * arrives while the dashboard is open. The list is what the server holds —
- * it survives a reload, unlike the desktop's toasts — with socket pushes
- * folded in as they come.
+ * count, a panel listing the most recent notifications read or not, and
+ * toasts for what arrives while the dashboard is open. The list is what the
+ * server holds — it survives a reload, unlike the desktop's toasts — with
+ * socket pushes folded in as they come.
*
- * Entries are what the user hasn't dismissed. Dismissing one (its ✕, or
- * acting on it) acknowledges it on the server, which also clears it from
- * every other open tab; a toast timing out does not, so nothing is lost
- * while someone is looking away.
+ * 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.
*
* @param {{ $el_window: JQuery, socket: import('socket.io-client').Socket }} opts
* @returns {{ open: () => void, close: (opts?: { restoreFocus?: boolean }) => void, refresh: () => Promise, isOpen: () => boolean }}
@@ -252,30 +258,37 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
${html_encode(formatRelativeTime(entry.createdAt, Date.now(), window.locale))} `;
const title = titleFor(notification);
+ const unread = isUnread(entry);
// Only an entry with somewhere to go is a control; the rest is text,
- // with the ✕ as its one action.
+ // with the check to mark it read as its one action while unread.
const actionable = notificationTarget(notification) !== null;
const mainTag = actionable ? 'button type="button"' : 'div';
+ const classes = [
+ 'dashboard-notification',
+ unread ? 'is-unread' : '',
+ justAdded.has(entry.uid) ? 'dashboard-notification-new' : '',
+ ].filter(Boolean).join(' ');
return `
-
+
<${mainTag} class="dashboard-notification-main${actionable ? ' is-actionable' : ''}">
- ${glyphFor(notification)}
+ ${glyphFor(notification)}
- ${html_encode(title)}
+ ${unread ? `${i18n('notification_unread')}: ` : ''}${html_encode(title)}
${text ? `${html_encode(text)} ` : ''}
${time}
${actionable ? 'button' : 'div'}>
- ${closeIcon}
+ ${unread ? `${checkIcon} ` : ''}
`;
};
/**
* 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, keeping to the same control
- * so Enter on a ✕ can clear entries one after another.
+ * 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.
*/
const focusedRow = () => {
const active = document.activeElement;
@@ -284,12 +297,12 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
return {
uid: row.getAttribute('data-uid'),
index: $list.children().index(row),
- dismiss: active.classList.contains('dashboard-notification-dismiss'),
+ read: active.classList.contains('dashboard-notification-read'),
};
};
- const rowControl = (row, dismiss) => (
- (dismiss ? row.querySelector('.dashboard-notification-dismiss') : null)
+ const rowControl = (row, read) => (
+ (read ? row.querySelector('.dashboard-notification-read') : null)
?? row.querySelector('button')
);
@@ -298,12 +311,12 @@ 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.dismiss) : $panel[0];
- target?.focus({ preventScroll: true });
+ const target = (row && rowControl(row, was.read)) ?? $panel[0];
+ target.focus({ preventScroll: true });
};
const render = () => {
- const count = entries.length;
+ const count = unreadCount(entries);
const label = badgeLabel(count);
$badge.text(label).attr('hidden', label ? null : '');
@@ -320,7 +333,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
const showError = ! loaded && loadFailed && loading === null;
$panel.find('.dashboard-notifications-loading').attr('hidden', showLoading ? null : '');
$panel.find('.dashboard-notifications-error').attr('hidden', showError ? null : '');
- $panel.find('.dashboard-notifications-empty').attr('hidden', loaded && count === 0 ? null : '');
+ $panel.find('.dashboard-notifications-empty').attr('hidden', loaded && entries.length === 0 ? null : '');
$panel.attr('aria-busy', showLoading ? 'true' : null);
const was = isOpen ? focusedRow() : null;
@@ -346,18 +359,18 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
loadFailed = false;
if ( ! loaded ) render();
listingRequestedAt = Date.now();
- loading = listNotifications({ predicate: 'unacknowledged' })
+ loading = listNotifications({ predicate: 'all', limit: HISTORY_LIMIT })
.then((rows) => {
const now = Date.now();
const server = rows.map((row) => toEntry(row, now)).filter(Boolean);
- // Acked since this listing was requested: not in its snapshot.
+ // Acked since this listing was requested: unread in its snapshot.
// Anything acked before it is settled and can be forgotten.
- const dismissed = new Set();
+ const readSince = new Set();
for ( const [uid, at] of acked ) {
- if ( at >= listingRequestedAt ) dismissed.add(uid);
+ if ( at >= listingRequestedAt ) readSince.add(uid);
else acked.delete(uid);
}
- entries = reconcileWithServer(entries, server, now, undefined, dismissed);
+ entries = reconcileWithServer(entries, server, now, undefined, readSince);
loaded = true;
})
.catch((err) => {
@@ -372,7 +385,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
return loading;
};
- /** Record the server's copy of `uid` as dismissed; resolves once it is. */
+ /** Record the server's copy of `uid` as read; resolves once it is. */
const acknowledge = async (uid) => {
acked.set(uid, Infinity);
try {
@@ -384,34 +397,29 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
}
};
- /** Take an entry off the list now and tell the server; put it back if that fails. */
- const dismiss = async (uid) => {
+ /** Show an entry as read now and tell the server; revert if that fails. */
+ const markRead = async (uid) => {
const entry = entries.find((e) => e.uid === uid);
- if ( ! entry ) return;
- entries = removeEntry(entries, uid);
- const pending = acknowledge(uid);
- const $row = $list.find(`.dashboard-notification[data-uid="${uid}"]`);
- if ( $row.length && ! reducedMotion() ) {
- $row.addClass('dashboard-notification-leaving');
- await new Promise((resolve) => setTimeout(resolve, 160));
- }
+ if ( ! entry || ! isUnread(entry) ) return;
+ entries = setReadAt(entries, uid, Date.now());
render();
try {
- await pending;
+ await acknowledge(uid);
} catch (err) {
console.warn('Could not acknowledge notification:', err);
- entries = mergeEntries(entries, [entry]).entries;
+ entries = setReadAt(entries, uid, null);
render();
- setStatus(i18n('notifications_dismiss_failed', [], false));
+ setStatus(i18n('notifications_mark_read_failed', [], false));
}
};
- const dismissAll = async () => {
- const cleared = entries;
- if ( cleared.length === 0 ) return;
- entries = [];
+ const markAllRead = async () => {
+ const unread = entries.filter(isUnread);
+ if ( unread.length === 0 ) return;
+ const now = Date.now();
+ entries = entries.map((entry) => (isUnread(entry) ? { ...entry, readAt: now } : entry));
render();
- const failed = await runLimited(cleared, ACK_CONCURRENCY, (entry) => acknowledge(entry.uid));
+ const failed = await runLimited(unread, ACK_CONCURRENCY, (entry) => acknowledge(entry.uid));
if ( failed.length > 0 ) {
setStatus(i18n('notifications_mark_all_failed', [], false));
await refresh();
@@ -435,8 +443,8 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
const target = notificationTarget(entry.notification);
close({ restoreFocus: false });
// Acknowledged first: what the click leads to may take a moment, and
- // the entry should be gone by the time it lands.
- void dismiss(entry.uid);
+ // the entry should read as seen by the time it lands.
+ void markRead(entry.uid);
if ( target?.kind === 'shared-item' ) {
void goToShared([target.path]);
} else if ( target?.kind === 'shared' ) {
@@ -460,9 +468,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
timeout: TOAST_TIMEOUT_MS,
click: () => void reveal_dashboard().then(() => actOn(entry)),
// The ✕ is a dismissal; timing out is not, so `close` alone acks.
- close: () => {
- if ( entries.some((e) => e.uid === entry.uid) ) void dismiss(entry.uid);
- },
+ close: () => void markRead(entry.uid),
});
};
@@ -629,13 +635,11 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
$trigger.on('click', () => (isOpen ? close() : open()));
$panel.on('click', '.dashboard-notifications-close', () => close());
$scrim.on('click', () => close({ restoreFocus: false }));
- $panel.on('click', '.dashboard-notifications-mark-all', () => void dismissAll());
+ $panel.on('click', '.dashboard-notifications-mark-all', () => void markAllRead());
$panel.on('click', '.dashboard-notifications-retry', () => void refresh());
- // The re-render after a dismiss moves the keyboard to the entry that
- // takes the dismissed one's place (see restoreFocus).
- $panel.on('click', '.dashboard-notification-dismiss', function (e) {
+ $panel.on('click', '.dashboard-notification-read', function (e) {
e.stopPropagation();
- void dismiss($(this).closest('.dashboard-notification').attr('data-uid'));
+ void markRead($(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');
@@ -660,7 +664,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
const titleEl = document.querySelector('title');
if ( titleEl ) {
new MutationObserver(() => {
- const wanted = titleWithBadge(document.title, entries.length);
+ const wanted = titleWithBadge(document.title, unreadCount(entries));
if ( document.title !== wanted ) document.title = wanted;
}).observe(titleEl, { childList: true, characterData: true, subtree: true });
}
@@ -672,14 +676,15 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
socket.on('notif.ack', ({ uid }) => {
if ( ! uid ) return;
$(`.notification[data-uid="${html_encode(uid)}"]`).closest('.notification-wrapper').remove();
- if ( ! entries.some((e) => e.uid === uid) ) return;
- // Dismissed in another tab: a listing already in flight predates it too.
- acked.set(uid, Date.now());
- entries = removeEntry(entries, uid);
+ if ( ! entries.some((e) => e.uid === uid && isUnread(e)) ) return;
+ // Read in another tab: a listing already in flight predates it too.
+ const now = Date.now();
+ acked.set(uid, now);
+ entries = setReadAt(entries, uid, now);
render();
});
- // Every (re)connection: anything dismissed elsewhere while this tab was
- // offline is gone from the server's list, and anything new is in it.
+ // 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());
// Coming back to the tab: same reasoning, cheaper than waiting to be told.
document.addEventListener('visibilitychange', () => {
diff --git a/src/gui/src/UI/Dashboard/notificationCenter.js b/src/gui/src/UI/Dashboard/notificationCenter.js
index 9395c8bc4..59380a0fa 100644
--- a/src/gui/src/UI/Dashboard/notificationCenter.js
+++ b/src/gui/src/UI/Dashboard/notificationCenter.js
@@ -38,6 +38,7 @@
* @property {string} uid
* @property {NotificationPayload} notification
* @property {number|null} createdAt - Epoch milliseconds, `null` when unknown
+ * @property {number|null} readAt - When the user acknowledged it (epoch ms); `null` while unread
* @property {number} receivedAt - When this client first saw it (epoch ms)
*/
@@ -77,9 +78,10 @@ export const parseCreatedAt = (value) => {
/**
* One entry from whatever the server or the socket hands over: a driver row
- * (`{ uid, value, created_at }`) or a socket item (`{ uid, notification,
- * created_at? }`). `null` for anything without a uid — there is nothing to
- * dismiss it by.
+ * (`{ uid, value, created_at, acknowledged }`) or a socket item (`{ uid,
+ * notification, created_at? }`). A push carries no `acknowledged` and is
+ * unread — the server only pushes what the user hasn't dismissed. `null`
+ * for anything without a uid — there is nothing to acknowledge it by.
*
* @param {Object} raw
* @param {number} now - Epoch ms; stands in for `created_at` when absent
@@ -97,6 +99,7 @@ export const toEntry = (raw, now) => {
// A push arrives before its row is written, so "now" is accurate for
// it; a listed row always carries its own time.
createdAt: createdAt ?? (raw.created_at === undefined ? now : null),
+ readAt: parseCreatedAt(raw.acknowledged),
receivedAt: now,
};
};
@@ -153,30 +156,50 @@ export const mergeEntries = (entries, incoming) => {
* Replace the list with what the server holds, keeping anything this client
* was pushed in the last `graceMs` that the server hasn't listed yet — a
* push goes out before its row is written, and a listing racing it must not
- * make the toast's entry vanish. `dismissed` names what was acknowledged
+ * make the toast's entry vanish. `readSince` names what was acknowledged
* after the listing was requested; its snapshot predates those, so they
- * are left out rather than brought back.
+ * stay read rather than flipping back to unread.
*
* @param {NotificationEntry[]} local
* @param {NotificationEntry[]} server
* @param {number} now
* @param {number} [graceMs]
- * @param {Set} [dismissed]
+ * @param {Set} [readSince]
* @returns {NotificationEntry[]}
*/
-export const reconcileWithServer = (local, server, now, graceMs = 15_000, dismissed = new Set()) => {
- const kept = server.filter((entry) => ! dismissed.has(entry.uid));
- const listed = new Set(kept.map((entry) => entry.uid));
- const recent = local.filter((entry) => ! listed.has(entry.uid) && ! dismissed.has(entry.uid) && now - entry.receivedAt < graceMs);
- return sortEntries([...kept, ...recent]);
+export const reconcileWithServer = (local, server, now, graceMs = 15_000, readSince = new Set()) => {
+ const known = new Map(local.map((entry) => [entry.uid, entry]));
+ const listed = server.map((entry) => (
+ entry.readAt === null && readSince.has(entry.uid)
+ ? { ...entry, readAt: known.get(entry.uid)?.readAt ?? now }
+ : entry
+ ));
+ const listedUids = new Set(listed.map((entry) => entry.uid));
+ const recent = local.filter((entry) => ! listedUids.has(entry.uid) && now - entry.receivedAt < graceMs);
+ return sortEntries([...listed, ...recent]);
};
/**
+ * The list with one entry's read state changed; `null` makes it unread
+ * again, for rolling back an acknowledgement the server refused.
+ *
* @param {NotificationEntry[]} entries
* @param {string} uid
+ * @param {number|null} readAt
* @returns {NotificationEntry[]}
*/
-export const removeEntry = (entries, uid) => entries.filter((entry) => entry.uid !== uid);
+export const setReadAt = (entries, uid, readAt) => entries.map((entry) => (
+ entry.uid === uid && entry.readAt !== readAt ? { ...entry, readAt } : entry
+));
+
+/** @param {NotificationEntry} entry */
+export const isUnread = (entry) => entry.readAt === null;
+
+/**
+ * @param {NotificationEntry[]} entries
+ * @returns {number}
+ */
+export const unreadCount = (entries) => entries.filter(isUnread).length;
/** The senders that have a glyph of their own; anything else gets the bell. */
const GLYPH_SOURCES = new Set(['sharing', 'worker']);
diff --git a/src/gui/src/UI/Dashboard/notificationCenter.test.js b/src/gui/src/UI/Dashboard/notificationCenter.test.js
index d7dad77e6..840e92c3e 100644
--- a/src/gui/src/UI/Dashboard/notificationCenter.test.js
+++ b/src/gui/src/UI/Dashboard/notificationCenter.test.js
@@ -23,15 +23,17 @@ import {
formatAbsoluteTime,
formatRelativeTime,
glyphKey,
+ isUnread,
mergeEntries,
notificationTarget,
parseCreatedAt,
planBurstToasts,
reconcileWithServer,
- removeEntry,
+ setReadAt,
sortEntries,
titleWithBadge,
toEntry,
+ unreadCount,
} from './notificationCenter.js';
const NOW = Date.UTC(2026, 7, 27, 12, 0, 0);
@@ -43,6 +45,7 @@ const entry = (uid, createdAt, extra = {}) => ({
uid,
notification: { title: uid },
createdAt,
+ readAt: null,
receivedAt: NOW,
...extra,
});
@@ -83,13 +86,23 @@ describe('toEntry', () => {
uid: 'a',
notification: { title: 'hi' },
createdAt: NOW - HOUR,
+ readAt: null,
receivedAt: NOW,
});
});
- it('reads a socket push and dates it now when the payload carries no time', () => {
+ it('reads when a row was acknowledged, in epoch seconds', () => {
+ const readAt = NOW - 5 * MIN;
+ const out = toEntry({ uid: 'a', value: {}, created_at: null, acknowledged: readAt / 1000 }, NOW);
+ expect(out.readAt).toBe(readAt);
+ expect(isUnread(out)).toBe(false);
+ expect(isUnread(toEntry({ uid: 'b', value: {}, acknowledged: null }, NOW))).toBe(true);
+ });
+
+ it('reads a socket push as unread and dates it now when the payload carries no time', () => {
const out = toEntry({ uid: 'a', notification: { title: 'hi' } }, NOW);
expect(out.createdAt).toBe(NOW);
+ expect(out.readAt).toBeNull();
expect(out.notification).toEqual({ title: 'hi' });
});
@@ -148,6 +161,12 @@ describe('mergeEntries', () => {
expect(entries[1].createdAt).toBe(NOW - HOUR);
});
+ it('keeps the read state of an entry a push rewrites', () => {
+ const before = [entry('a', NOW - HOUR, { readAt: NOW - MIN })];
+ const { entries } = mergeEntries(before, [entry('a', NOW)]);
+ expect(entries[0].readAt).toBe(NOW - MIN);
+ });
+
it('fills in a time it did not have', () => {
const { entries } = mergeEntries([entry('a', null)], [entry('a', NOW - DAY)]);
expect(entries[0].createdAt).toBe(NOW - DAY);
@@ -184,22 +203,51 @@ describe('reconcileWithServer', () => {
expect(out.map((e) => e.uid)).toEqual(['a', 'b']);
});
- it('does not bring back what was dismissed while it was in flight', () => {
- const local = [entry('kept', NOW - HOUR), entry('fresh', NOW, { receivedAt: NOW - 1_000 })];
- const server = [entry('gone', NOW - MIN), entry('kept', NOW - HOUR)];
- const out = reconcileWithServer(local, server, NOW, 15_000, new Set(['gone', 'fresh']));
- expect(out.map((e) => e.uid)).toEqual(['kept']);
+ it('takes the read state the server reports', () => {
+ const local = [entry('a', NOW - HOUR)];
+ const out = reconcileWithServer(local, [entry('a', NOW - HOUR, { readAt: NOW - MIN })], NOW);
+ expect(out[0].readAt).toBe(NOW - MIN);
+ });
+
+ it('does not unread what was read while the listing was in flight', () => {
+ const local = [
+ entry('listed', NOW - HOUR, { readAt: NOW - 2_000 }),
+ entry('fresh', NOW, { receivedAt: NOW - 1_000, readAt: NOW - 500 }),
+ ];
+ const server = [entry('elsewhere', NOW - MIN), entry('listed', NOW - HOUR)];
+ const out = reconcileWithServer(local, server, NOW, 15_000, new Set(['listed', 'fresh', 'elsewhere']));
+ expect(out.map((e) => [e.uid, e.readAt])).toEqual([
+ ['fresh', NOW - 500],
+ ['elsewhere', NOW],
+ ['listed', NOW - 2_000],
+ ]);
});
});
-describe('removeEntry', () => {
- it('removes by uid and leaves others', () => {
- expect(removeEntry([entry('a', NOW), entry('b', NOW)], 'a').map((e) => e.uid)).toEqual(['b']);
+describe('setReadAt', () => {
+ it('marks one entry read and leaves the others', () => {
+ const out = setReadAt([entry('a', NOW), entry('b', NOW)], 'a', NOW);
+ expect(out.map((e) => e.readAt)).toEqual([NOW, null]);
});
- it('is a no-op for an unknown uid', () => {
- const input = [entry('a', NOW)];
- expect(removeEntry(input, 'zzz')).toEqual(input);
+ it('can make an entry unread again', () => {
+ const out = setReadAt([entry('a', NOW, { readAt: NOW })], 'a', null);
+ expect(out[0].readAt).toBeNull();
+ });
+
+ it('does not mutate its input and keeps untouched entries by identity', () => {
+ const input = [entry('a', NOW), entry('b', NOW)];
+ const out = setReadAt(input, 'a', NOW);
+ expect(input[0].readAt).toBeNull();
+ expect(out[1]).toBe(input[1]);
+ expect(setReadAt(input, 'zzz', NOW)).toEqual(input);
+ });
+});
+
+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);
+ expect(unreadCount([])).toBe(0);
});
});
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index f93ea7e57..b61b1c06b 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -7083,6 +7083,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
}
.dashboard-notification-icon {
+ position: relative;
flex-shrink: 0;
display: inline-flex;
align-items: center;
@@ -7092,6 +7093,27 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
border-radius: 50%;
background: var(--dashboard-hover);
color: var(--dashboard-text-secondary);
+ transition: opacity 0.2s;
+}
+
+.dashboard-notification:not(.is-unread) .dashboard-notification-icon {
+ opacity: 0.7;
+}
+
+.dashboard-notification-unread-dot {
+ display: none;
+ position: absolute;
+ top: -1px;
+ right: -1px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--select-color);
+ box-shadow: 0 0 0 2px var(--dashboard-card-background);
+}
+
+.dashboard-notification.is-unread .dashboard-notification-unread-dot {
+ display: block;
}
.dashboard-notification-icon svg {
@@ -7123,23 +7145,34 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
font-size: 13.5px;
font-weight: 500;
line-height: 1.35;
- color: var(--dashboard-text-primary);
+ color: var(--dashboard-text-secondary);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
+ transition: color 0.2s;
+}
+
+.dashboard-notification.is-unread .dashboard-notification-title {
+ font-weight: 600;
+ color: var(--dashboard-text-primary);
}
.dashboard-notification-text {
font-size: 12.5px;
line-height: 1.35;
- color: var(--dashboard-text-secondary);
+ color: var(--dashboard-text-tertiary);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
+ transition: color 0.2s;
+}
+
+.dashboard-notification.is-unread .dashboard-notification-text {
+ color: var(--dashboard-text-secondary);
}
.dashboard-notification-time {
@@ -7148,7 +7181,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin-top: 2px;
}
-.dashboard-notification-dismiss {
+.dashboard-notification-read {
position: absolute;
top: 8px;
right: 8px;
@@ -7167,18 +7200,18 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
transition: opacity 0.12s, background-color 0.15s, color 0.15s;
}
-.dashboard-notification-dismiss svg {
+.dashboard-notification-read svg {
width: 14px;
height: 14px;
}
-.dashboard-notification:hover .dashboard-notification-dismiss,
-.dashboard-notification:focus-within .dashboard-notification-dismiss {
+.dashboard-notification:hover .dashboard-notification-read,
+.dashboard-notification:focus-within .dashboard-notification-read {
opacity: 1;
}
@media (hover: hover) {
- .dashboard-notification-dismiss:hover {
+ .dashboard-notification-read:hover {
background: var(--dashboard-border);
color: var(--dashboard-text);
}
@@ -7186,13 +7219,13 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
/* Touch has no hover to reveal it */
@media (hover: none) {
- .dashboard-notification-dismiss {
+ .dashboard-notification-read {
opacity: 1;
}
}
.dashboard-notification-main:focus-visible,
-.dashboard-notification-dismiss:focus-visible,
+.dashboard-notification-read:focus-visible,
.dashboard-notifications-mark-all:focus-visible,
.dashboard-notifications-close:focus-visible,
.dashboard-notifications-retry:focus-visible {
@@ -7209,13 +7242,6 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
animation: dashboard-notification-enter 0.22s ease-out;
}
-.dashboard-notification-leaving {
- opacity: 0;
- transform: translateX(12px);
- transition: opacity 0.16s ease, transform 0.16s ease;
- pointer-events: none;
-}
-
/* -- States -- */
.dashboard-notifications-loading {
@@ -7316,8 +7342,9 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin-bottom: 10px;
}
-/* Screen-reader-only live region for arrivals */
-.dashboard-notifications-live {
+/* Screen-reader-only: the live region for arrivals, and a row's unread state */
+.dashboard-notifications-live,
+.dashboard-notification-sr {
position: absolute;
width: 1px;
height: 1px;
@@ -7468,8 +7495,10 @@ body.dashboard-mode .notifications-close-all {
.dashboard-notifications-panel,
.dashboard-notifications-scrim,
.dashboard-notification,
- .dashboard-notification-dismiss,
- .dashboard-notification-leaving {
+ .dashboard-notification-read,
+ .dashboard-notification-icon,
+ .dashboard-notification-title,
+ .dashboard-notification-text {
transition: none;
}
diff --git a/src/gui/src/helpers/notificationApi.js b/src/gui/src/helpers/notificationApi.js
index 0030ab72f..9cf1fdd84 100644
--- a/src/gui/src/helpers/notificationApi.js
+++ b/src/gui/src/helpers/notificationApi.js
@@ -57,9 +57,10 @@ export async function markNotificationAcknowledged (uid) {
*/
/**
- * The user's notifications, newest first.
+ * The user's notifications, newest first. `'all'` lists them regardless of
+ * state; the driver treats no predicate as everything.
*
- * @param {{ predicate?: 'unacknowledged'|'unseen'|'acknowledged', limit?: number }} [opts]
+ * @param {{ predicate?: 'unacknowledged'|'unseen'|'acknowledged'|'all', limit?: number }} [opts]
* @returns {Promise}
*/
export async function listNotifications ({ predicate = 'unacknowledged', limit = 200 } = {}) {
@@ -70,7 +71,7 @@ export async function listNotifications ({ predicate = 'unacknowledged', limit =
interface: 'puter-notifications',
driver: 'es:notification',
method: 'select',
- args: { predicate, limit },
+ args: predicate === 'all' ? { limit } : { predicate, limit },
}),
});
if ( ! res.ok ) {
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 99d1cda3b..ede010a43 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -238,14 +238,15 @@ const en = {
no_dir_associated_with_site: 'No directory associated with this address.',
no_websites_published: 'You have not published any websites yet. Right click on a folder to get started.',
notification: 'Notification',
- notification_dismiss: 'Dismiss',
- notification_dismiss_named: 'Dismiss notification: {{title}}',
+ notification_mark_read: 'Mark as read',
+ notification_mark_read_named: 'Mark as read: {{title}}',
+ notification_unread: 'Unread',
notifications: 'Notifications',
- notifications_dismiss_failed: "Couldn't dismiss that notification. Please try again.",
- notifications_empty_title: "You're all caught up",
+ notifications_empty_title: 'No notifications yet',
notifications_load_failed: "Couldn't load notifications.",
notifications_mark_all_failed: "Couldn't mark everything as read. Please try again.",
notifications_mark_all_read: 'Mark all as read',
+ 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_unread_count: '{{count}} unread',