mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-15 18:33:52 +00:00
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:
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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user