v4: the notifications page restores its list and scroll on Back, and shows what it missed — it now saves its loaded pages and scroll position as it is left and restores them on Back, through useFeedCache, useScrollSave and useScrollRestore like the feeds and the profile's tabs; the saved list keeps the ones the server confirmed read as read, so coming back doesn't highlight them again. The page never refetched after a reconnect, so notifications sent while the socket was down never showed, and the next read-all marked them read unseen; a reconnect, and every restored list (after a reload the socket hasn't said yet whether anything is new), now fetch the first page again, and what is new goes on top with the socket's arrivals, those alone sorted newest first so the server's order stays. Marking read now follows the unread notifications shown, each sent once, instead of each socket message, and a list with none unread sends nothing. The arrivals shift the server's offset pages, so a next page could repeat the last one's tail: each notification shows once. The next page loads as the end scrolls into view, with FeedFooter, as every other page list does, and the loading and error states go through ListState. The "N new" pill was a number before a separately translated "new", which French made "3 nouveau"; it is one plural message, "3 nouvelles". Dates past a week and the tooltip use the app's locale rather than the browser's. The mention, comment and like icons had no colored circle; they have one now, and the decorative emoji are hidden from screen readers. 670 frontend tests pass (new: loading on scroll, the save on leaving, the restore with what came meanwhile, the reconnect backfill), and tsc, deno fmt, deno lint, doc:lint and i18n:compile --strict are clean; the restore was checked in the running app at phone width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,18 +6,19 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
|
||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { ListState } from "../components/shared/ListState.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { LoadMoreButton } from "../components/shared/LoadMoreButton.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import type {
|
||||
CommentLikedData,
|
||||
@@ -33,9 +34,18 @@ import type {
|
||||
UserFollowedData,
|
||||
UserMentionedData,
|
||||
} from "../../shared/notification.ts";
|
||||
import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import {
|
||||
deserializeNotification,
|
||||
hydrateNotification,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
useScrollSave,
|
||||
} from "../hooks/feed/useScrollSave.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { dumpPath, playlistPath, userPath } from "../../shared/paths.ts";
|
||||
|
||||
@@ -99,7 +109,7 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
like: <HeartSvg />,
|
||||
};
|
||||
return (
|
||||
<span className={`notif-icon notif-icon--${kind}`}>
|
||||
<span className={`notif-icon notif-icon--${kind}`} aria-hidden="true">
|
||||
{glyphs[kind]}
|
||||
</span>
|
||||
);
|
||||
@@ -265,7 +275,10 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
function timeAgo(date: Date): string {
|
||||
const abs = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
if (abs < 7 * 86400) return relativeTime(date);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the local midnight that starts the day of `d`, in epoch ms. */
|
||||
@@ -275,42 +288,102 @@ function startOfDay(d: Date): number {
|
||||
|
||||
/**
|
||||
* Returns `items` grouped under Today, Yesterday and Earlier, in that order,
|
||||
* without the empty groups. The labels are keys, which the page translates.
|
||||
* without the empty groups, each with its translated label.
|
||||
*/
|
||||
function groupByDate(
|
||||
items: Notification[],
|
||||
): { label: string; items: Notification[] }[] {
|
||||
const todayTs = startOfDay(new Date());
|
||||
const yesterdayTs = todayTs - 86_400_000;
|
||||
|
||||
const buckets: Record<string, Notification[]> = {};
|
||||
const groups = [
|
||||
{ label: t`Today`, items: [] as Notification[] },
|
||||
{ label: t`Yesterday`, items: [] as Notification[] },
|
||||
{ label: t`Earlier`, items: [] as Notification[] },
|
||||
];
|
||||
for (const n of items) {
|
||||
const ts = startOfDay(n.createdAt);
|
||||
const key = ts >= todayTs
|
||||
? "Today"
|
||||
: ts >= yesterdayTs
|
||||
? "Yesterday"
|
||||
: "Earlier";
|
||||
(buckets[key] ??= []).push(n);
|
||||
groups[ts >= todayTs ? 0 : ts >= yesterdayTs ? 1 : 2].items.push(n);
|
||||
}
|
||||
|
||||
return (["Today", "Yesterday", "Earlier"] as const)
|
||||
.filter((k) => buckets[k]?.length)
|
||||
.map((label) => ({ label, items: buckets[label] }));
|
||||
return groups.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user. Opening the page marks every
|
||||
* notification read on the server, and so does each that arrives while it is
|
||||
* open. The unread ones stay highlighted until the page is left.
|
||||
* Returns `added` and `items` together, each once and newest first, the
|
||||
* version in `added` winning.
|
||||
*/
|
||||
function newestFirst(
|
||||
added: Notification[],
|
||||
items: Notification[],
|
||||
): Notification[] {
|
||||
const ids = new Set(added.map((n) => n.id));
|
||||
return [...added, ...items.filter((n) => !ids.has(n.id))].sort((a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders notification `n` as a row, a link to what it is about unless that
|
||||
* is gone.
|
||||
*/
|
||||
function NotificationItem({ n }: { n: Notification }) {
|
||||
const link = notificationLink(n);
|
||||
const inner = (
|
||||
<>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<Tooltip text={n.createdAt.toLocaleString(i18n.locale)}>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!n.read && <span className="notif-dot" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={`notification-item${
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
{link
|
||||
? (
|
||||
<Link to={link} className="notification-item-link">
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
: <div className="notification-item-link">{inner}</div>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user, a page at a time as the list
|
||||
* scrolls. Opening the page marks every notification read on the server, and
|
||||
* so does each that arrives while it is open. The unread ones stay highlighted
|
||||
* until the page is left. Going back restores the list and the scroll, with
|
||||
* what came in meanwhile on top.
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
const { user, authFetch } = useAuth();
|
||||
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
(page, signal) =>
|
||||
const {
|
||||
clearUnreadNotifications,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
} = useWS();
|
||||
const { cached, saveState } = useFeedCache<Notification>(
|
||||
"feed:notifications",
|
||||
hydrateNotification,
|
||||
);
|
||||
const fetchPage = useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(
|
||||
pageUrl(
|
||||
`${API_URL}/api/me/notifications`,
|
||||
@@ -319,39 +392,98 @@ export function Notifications(): React.JSX.Element {
|
||||
),
|
||||
{ signal },
|
||||
).then((res) => readPage(res, deserializeNotification)),
|
||||
[authFetch],
|
||||
);
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
fetchPage,
|
||||
cached,
|
||||
);
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
|
||||
// Each new `lastNotification` is kept once, during render rather than in an
|
||||
// effect, so it shows in the same frame. The list may predate it or not.
|
||||
// What arrives while the page is open goes on top, newest first. Each new
|
||||
// `lastNotification` is kept once, during render rather than in an effect,
|
||||
// so it shows in the same frame. The list may predate it or not.
|
||||
const [arrived, setArrived] = useState<Notification[]>([]);
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
||||
setPrevLastNotification(lastNotification);
|
||||
setArrived((a) => [lastNotification, ...a]);
|
||||
setArrived((a) => newestFirst([lastNotification], a));
|
||||
}
|
||||
const items = useMemo(() => {
|
||||
if (state.status !== "loaded") return [];
|
||||
const listed = new Set(state.data.items.map((n) => n.id));
|
||||
return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items];
|
||||
}, [state, arrived]);
|
||||
|
||||
// Every notification is marked read on the server once the list shows, and
|
||||
// again for each that arrives while the page is open. Only the bell clears:
|
||||
// clearing the list too would take its dots and "new" count away a moment
|
||||
// after they appear. A failure leaves them unread for next time.
|
||||
const loaded = state.status === "loaded";
|
||||
const markedReadRef = useRef<Notification | null | undefined>(undefined);
|
||||
// What came while the page wasn't listening, on a reconnect and on a
|
||||
// restored list, is fetched as the first page again and arrives with the
|
||||
// rest. A restored list always asks, since after a reload the socket hasn't
|
||||
// said yet whether anything is new. The epoch is marked done once that page
|
||||
// is in, so StrictMode's aborted first run doesn't skip it.
|
||||
const backfilledEpochRef = useRef<number | null>(
|
||||
cached ? null : connectionEpoch,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!loaded || markedReadRef.current === lastNotification) return;
|
||||
markedReadRef.current = lastNotification;
|
||||
authFetch(`${API_URL}/api/me/notifications/read`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.ok) clearUnreadNotifications();
|
||||
if (connectionEpoch === backfilledEpochRef.current) return;
|
||||
const controller = new AbortController();
|
||||
fetchPage(1, controller.signal)
|
||||
.then(({ items }) => {
|
||||
backfilledEpochRef.current = connectionEpoch;
|
||||
setArrived((a) => newestFirst(items, a));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, fetchPage]);
|
||||
|
||||
// Each once: the arrivals may be in the list too, and they shift the
|
||||
// server's pages, so a next page repeats the tail of the last.
|
||||
const items = useMemo(() => {
|
||||
if (!loaded) return [];
|
||||
const byId = new Map<string, Notification>();
|
||||
for (const n of [...arrived, ...loaded.items]) {
|
||||
if (!byId.has(n.id)) byId.set(n.id, n);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [loaded, arrived]);
|
||||
|
||||
// Each unread notification shown is marked read on the server, by a request
|
||||
// that marks them all: once the list shows, and again for those that arrive
|
||||
// or are backfilled while the page is open. Only the bell clears: clearing
|
||||
// the list too would take its dots and "new" count away a moment after they
|
||||
// appear. A failure leaves them unread for next time.
|
||||
const sentReadRef = useRef(new Set<string>());
|
||||
// Those the server confirmed, which the saved list keeps as read.
|
||||
const confirmedReadRef = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
const ids = items
|
||||
.filter((n) => !n.read && !sentReadRef.current.has(n.id))
|
||||
.map((n) => n.id);
|
||||
if (ids.length === 0) return;
|
||||
for (const id of ids) sentReadRef.current.add(id);
|
||||
authFetch(`${API_URL}/api/me/notifications/read`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
for (const id of ids) confirmedReadRef.current.add(id);
|
||||
clearUnreadNotifications();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [items, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
// The arrivals are saved with the list, and the ones marked read as read,
|
||||
// so that coming back shows them as the server has them.
|
||||
useScrollSave(
|
||||
!!loaded,
|
||||
useCallback((y) => {
|
||||
if (!loaded) return;
|
||||
const read = confirmedReadRef.current;
|
||||
saveState({
|
||||
...loaded,
|
||||
items: items.map((n) =>
|
||||
!n.read && read.has(n.id) ? { ...n, read: true } : n
|
||||
),
|
||||
}, y);
|
||||
}, [loaded, items, saveState]),
|
||||
);
|
||||
useScrollRestore(cached?.scrollY, !!loaded);
|
||||
|
||||
const totalUnread = items.filter((n) => !n.read).length;
|
||||
|
||||
@@ -360,101 +492,58 @@ export function Notifications(): React.JSX.Element {
|
||||
<div className="notifications-page">
|
||||
<div className="notifications-header">
|
||||
<h1 className="notifications-title">
|
||||
<span className="notifications-title-bell">🔔</span>
|
||||
<span className="notifications-title-bell" aria-hidden="true">
|
||||
🔔
|
||||
</span>
|
||||
<Trans>Notifications</Trans>
|
||||
</h1>
|
||||
{state.status === "loaded" && totalUnread > 0 && (
|
||||
{totalUnread > 0 && (
|
||||
<span className="notifications-unread-pill">
|
||||
{totalUnread} <Trans>new</Trans>
|
||||
<Plural value={totalUnread} one="# new" other="# new" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p>
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
<p className="notifications-empty-hint">
|
||||
<Trans>
|
||||
You'll be notified when someone follows your playlists, upvotes
|
||||
your dumps, or posts new content.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && items.length > 0 &&
|
||||
groupByDate(items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">
|
||||
{label === "Today"
|
||||
? t`Today`
|
||||
: label === "Yesterday"
|
||||
? t`Yesterday`
|
||||
: t`Earlier`}
|
||||
</h2>
|
||||
<ul className="notification-list">
|
||||
{items.map((n) => {
|
||||
const link = notificationLink(n);
|
||||
const inner = (
|
||||
<>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<Tooltip text={n.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="notif-dot" aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={n.id}
|
||||
className={`notification-item${
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
{link
|
||||
? (
|
||||
<Link to={link} className="notification-item-link">
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
: <div className="notification-item-link">{inner}</div>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{state.status === "loaded" && state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={() => items.length === 0}
|
||||
empty={
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon" aria-hidden="true">
|
||||
🔕
|
||||
</span>
|
||||
<p>
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
<p className="notifications-empty-hint">
|
||||
<Trans>
|
||||
You'll be notified when someone follows your playlists,
|
||||
upvotes your dumps, or posts new content.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
{groupByDate(items).map((group) => (
|
||||
<section key={group.label} className="notif-group">
|
||||
<h2 className="notif-group-label">{group.label}</h2>
|
||||
<ul className="notification-list">
|
||||
{group.items.map((n) => (
|
||||
<NotificationItem key={n.id} n={n} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user