Keep read notifications in the dashboard panel

The panel listed only what the user hadn't dismissed, so acting on a
notification made it vanish. It now shows the 30 most recent regardless
of state, with read/unread as a visual flag: unread rows carry a dot and
a check to mark them read; reading one (check, click-through, toast
close, mark-all, or an ack from another tab) leaves it listed but
quieter. The badge, tab title, and header count follow the unread count.
This commit is contained in:
Nariman Jelveh committed 2026-08-27 13:31:39 -07:00
1 parent c6ecc0c786
commit 167e281cc8
6 files changed
+225 -118

No files matched your search

@@ -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 = `<svg ${STROKE_ICON}><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`;
const closeIcon = `<svg ${STROKE_ICON} stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>`;
const checkIcon = `<svg ${STROKE_ICON} stroke-width="2"><path d="M5 13l4 4L19 7"/></svg>`;
const checkAllIcon = `<svg ${STROKE_ICON} stroke-width="2"><path d="M2 13l4 4L14 7"/><path d="M12 17l10-10"/></svg>`;
/** 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<void>, isOpen: () => boolean }}
@@ -252,30 +258,37 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
<time class="dashboard-notification-time" datetime="${new Date(entry.createdAt).toISOString()}"
title="${html_encode(formatAbsoluteTime(entry.createdAt, window.locale))}">${html_encode(formatRelativeTime(entry.createdAt, Date.now(), window.locale))}</time>`;
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 `
<li class="dashboard-notification${justAdded.has(entry.uid) ? ' dashboard-notification-new' : ''}" data-uid="${html_encode(entry.uid)}">
<li class="${classes}" data-uid="${html_encode(entry.uid)}">
<${mainTag} class="dashboard-notification-main${actionable ? ' is-actionable' : ''}">
<span class="dashboard-notification-icon dashboard-notification-icon-${html_encode(source)}">${glyphFor(notification)}</span>
<span class="dashboard-notification-icon dashboard-notification-icon-${html_encode(source)}">${glyphFor(notification)}<span class="dashboard-notification-unread-dot" aria-hidden="true"></span></span>
<span class="dashboard-notification-content">
<span class="dashboard-notification-title">${html_encode(title)}</span>
<span class="dashboard-notification-title">${unread ? `<span class="dashboard-notification-sr">${i18n('notification_unread')}: </span>` : ''}${html_encode(title)}</span>
${text ? `<span class="dashboard-notification-text">${html_encode(text)}</span>` : ''}
${time}
</span>
</${actionable ? 'button' : 'div'}>
<button type="button" class="dashboard-notification-dismiss"
aria-label="${i18n('notification_dismiss_named', { title })}" title="${i18n('notification_dismiss')}">${closeIcon}</button>
${unread ? `<button type="button" class="dashboard-notification-read"
aria-label="${i18n('notification_mark_read_named', { title })}" title="${i18n('notification_mark_read')}">${checkIcon}</button>` : ''}
</li>`;
};
/**
* 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', () => {
+35 -12
View File
@@ -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<string>} [dismissed]
* @param {Set<string>} [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']);
@@ -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);
});
});
+49 -20
View File
@@ -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;
}
+4 -3
View File
@@ -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<NotificationRow[]>}
*/
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 ) {
+5 -4
View File
@@ -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',