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 }) { `; 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} - + ${unread ? `` : ''}
  • `; }; /** * 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',