v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The notifications page.
|
||||
* Renders the notifications page, `/notifications`, for a signed-in user. It
|
||||
* reads them from `GET /api/notifications` and adds the ones `WSContext`
|
||||
* receives while it is open.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -33,12 +35,9 @@ import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
|
||||
/**
|
||||
* The page's state: loading, failed, or loaded with a page of notifications.
|
||||
*/
|
||||
type State =
|
||||
// `arrived`: sent over the socket before the list came back — the list may
|
||||
// predate them, and the read-all that follows the load covers them anyway.
|
||||
// `arrived` holds what the socket sent before the list came back, which the
|
||||
// list may predate.
|
||||
| { status: "loading"; arrived: Notification[] }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
@@ -49,7 +48,6 @@ type State =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** The icon a notification shows. */
|
||||
type NotifIconKind =
|
||||
| "upvote"
|
||||
| "follow"
|
||||
@@ -59,7 +57,6 @@ type NotifIconKind =
|
||||
| "comment"
|
||||
| "like";
|
||||
|
||||
/** Returns the icon for a notification of `type`. */
|
||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
switch (type) {
|
||||
case "dump_upvoted":
|
||||
@@ -81,28 +78,24 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the upvote glyph. */
|
||||
const UpvoteSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
|
||||
<polygon points="5,1 9,9 1,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the follow glyph. */
|
||||
const FollowSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||
<polygon points="2,1 9,5 2,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the like glyph. */
|
||||
const HeartSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||
<path d="M5 9 C2 6.5 0 5 0 2.8 C0 1.2 1.2 0 2.6 0 C3.6 0 4.5 0.6 5 1.5 C5.5 0.6 6.4 0 7.4 0 C8.8 0 10 1.2 10 2.8 C10 5 8 6.5 5 9 Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the icon of a notification of `type`. */
|
||||
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
const kind = notifIconKind(type);
|
||||
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
||||
@@ -121,7 +114,10 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns where notification `n` leads. */
|
||||
/**
|
||||
* Returns where notification `n` leads. A notification about a comment links
|
||||
* to its `#comment-<id>` anchor on the dump's page.
|
||||
*/
|
||||
function notificationLink(n: Notification): string {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
@@ -149,14 +145,14 @@ function notificationLink(n: Notification): string {
|
||||
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||
}
|
||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||
// Search only: the chat opens over whatever page you're on.
|
||||
// A query string alone keeps this page, and `ChatProvider` opens the
|
||||
// chat over it.
|
||||
if (d.contextType === "chat") return `?chat=open`;
|
||||
return `/playlists/${d.contextId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the sentence notification `n` shows. */
|
||||
function notificationContent(n: Notification): React.ReactNode {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
@@ -248,7 +244,10 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns how long ago `date` was within a week, else its day. */
|
||||
/**
|
||||
* Returns how long ago `date` was when it was within a week, else its day and
|
||||
* month.
|
||||
*/
|
||||
function timeAgo(date: Date): string {
|
||||
const abs = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
if (abs < 7 * 86400) return relativeTime(date);
|
||||
@@ -260,7 +259,10 @@ function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
/** Returns `items` grouped under Today, Yesterday and Earlier. */
|
||||
/**
|
||||
* Returns `items` grouped under Today, Yesterday and Earlier, in that order,
|
||||
* without the empty groups. The labels are keys, which the page translates.
|
||||
*/
|
||||
function groupByDate(
|
||||
items: Notification[],
|
||||
): { label: string; items: Notification[] }[] {
|
||||
@@ -284,9 +286,9 @@ function groupByDate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user: their notifications by day,
|
||||
* new ones as they arrive, loading more on demand. Opening the page marks them
|
||||
* all read.
|
||||
* 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.
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
@@ -326,20 +328,18 @@ export function Notifications(): React.JSX.Element {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Marking them read is a separate matter: if it fails, the list that
|
||||
// loaded is still worth showing, and they stay unread for next time.
|
||||
// If this fails, the list is still worth showing, and they stay unread
|
||||
// for next time.
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. The list keeps showing which ones were new
|
||||
// when the page opened: flipping them to read here, a moment after
|
||||
// they appear, would take the dots and the "new" count away before
|
||||
// anyone could see them.
|
||||
// Only the bell clears. Marking the list read too would take its dots
|
||||
// and "new" count away a moment after they appear.
|
||||
clearUnreadNotifications();
|
||||
} catch {
|
||||
// Offline: see above.
|
||||
// Offline: they stay unread for next time.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -347,6 +347,8 @@ export function Notifications(): React.JSX.Element {
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
// Each new `lastNotification` is added once, during render rather than in an
|
||||
// effect, so it shows in the same frame.
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
@@ -363,7 +365,7 @@ export function Notifications(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell shouldn't count it.
|
||||
// highlighted here, but the bell must not count it.
|
||||
const loaded = state.status === "loaded";
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
|
||||
Reference in New Issue
Block a user