v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 07:23:03 +00:00
parent b85a71d86e
commit 563daa720c
171 changed files with 3921 additions and 851 deletions

View File

@@ -1,6 +1,13 @@
/**
* What the signed-in user may do, as the UI asks it.
*
* @module
*/
import { hasPermission, type Permission } from "../../../shared/permissions.ts";
import type { PublicUser } from "../../../shared/user.ts";
/** Checks whether `user` has `permission`; nobody signed in has none. */
export function can(
user: Pick<PublicUser, "role"> | null | undefined,
permission: Permission,

View File

@@ -1,7 +1,13 @@
/**
* The stored session. The session is shared by every tab through one
* localStorage entry.
*
* @module
*/
import type { AuthResponse } from "../../../shared/user.ts";
import { deserializeAuthResponse } from "../shared/deserialize.ts";
// The session is shared by every tab through this one localStorage entry.
/**
* The signed-in session as the app holds it. Sessions stored before media
* tokens existed have none; AuthProvider fetches one for them.
@@ -10,8 +16,13 @@ export type Session = Omit<AuthResponse, "mediaToken"> & {
mediaToken?: string;
};
/** The localStorage key of the session. */
export const AUTH_STORAGE_KEY = "authResponse";
/**
* Checks whether `token`, a JWT, has expired; an unreadable one counts as
* expired.
*/
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
@@ -21,7 +32,10 @@ export function isTokenExpired(token: string): boolean {
}
}
/** The stored session, or null — discarding one that can no longer be used. */
/**
* Returns the stored session, or null — discarding one that can no longer be
* used.
*/
export function readStoredSession(): Session | null {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (!stored) return null;
@@ -47,12 +61,13 @@ export function readStoredSession(): Session | null {
return parsed;
}
/** Stores `session`, for every tab. */
export function storeSession(session: Session): void {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
}
/**
* Forget the stored session, but only if it is still the one holding `token`.
* Forgets the stored session, but only if it is still the one holding `token`.
* Another tab may already have replaced it — a password change there hands out
* fresh tokens — and a 401 for the old token here must not wipe those.
*/

View File

@@ -1,16 +1,30 @@
/**
* The chat's unsent message, kept per user in sessionStorage. The draft and
* reply target outlive the chat — closing it, or reloading the tab, keeps a
* half-written message.
*
* @module
*/
import type { ChatReplyTarget } from "../../contexts/ws/WSContext.ts";
// The draft and reply target outlive the chat — closing it, or reloading the
// tab, keeps a half-written message.
/** An unsent chat message. */
export interface StoredDraft {
/** The message as typed so far. */
draft: string;
/** The message it replies to, if any. */
replyTarget: ChatReplyTarget | null;
}
/** Returns the sessionStorage key of the draft of `userId`. */
function draftKey(userId: string): string {
return `chat-draft:${userId}`;
}
/**
* Returns the stored draft of `userId`, empty when there is none or no one is
* signed in.
*/
export function readDraft(userId: string | undefined): StoredDraft {
if (userId) {
try {
@@ -26,6 +40,7 @@ export function readDraft(userId: string | undefined): StoredDraft {
return { draft: "", replyTarget: null };
}
/** Stores `value` as the draft of `userId`, or removes it when it is empty. */
export function writeDraft(
userId: string | undefined,
value: StoredDraft,

View File

@@ -1,25 +1,41 @@
/**
* Rules for a chat message: when it can be sent, how it is shown, and its
* one-line preview.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { markdownToPlainText } from "./text.ts";
import { isEmojiOnly } from "../editor/text.ts";
import { VALIDATION } from "../../../shared/validation.ts";
// The counter only shows once a message is getting near the limit.
/**
* The length from which the character counter shows — only once a message is
* getting near the limit.
*/
export const COUNT_FROM = 1800;
// What the server holds a message to: its length once trimmed.
/**
* Checks whether `draft` can be sent, by what the server holds a message to:
* its length once trimmed.
*/
export function sendable(draft: string): boolean {
const body = draft.trim();
return body.length > 0 && body.length <= VALIDATION.CHAT_MESSAGE_MAX;
}
// A message of nothing but a few emoji is shown large, as chats do.
/**
* Returns the class of a message's text: a message of nothing but a few emoji
* is shown large, as chats do.
*/
export function messageTextClass(body: string): string {
return isEmojiOnly(body)
? "chat-message-text chat-message-text--jumbo"
: "chat-message-text";
}
// One line of plain text, for reply previews.
/** Returns `body` as one line of plain text, for reply previews. */
export function snippet(body: string): string {
return markdownToPlainText(body, t`image`);
}

View File

@@ -1,9 +1,15 @@
/**
* Text helpers for the live chat.
*
* @module
*/
import { MENTION_RE } from "../../../shared/mention.ts";
// Text helpers for the live chat.
/** Whether `body` @-mentions `username` (case-insensitively), by the same rule
* the server uses to send mention notifications. */
/**
* Checks whether `body` @-mentions `username` (case-insensitively), by the same
* rule the server uses to send mention notifications.
*/
export function mentionsUser(body: string, username: string): boolean {
const target = username.toLowerCase();
for (const m of body.matchAll(MENTION_RE)) {
@@ -13,10 +19,10 @@ export function mentionsUser(body: string, username: string): boolean {
}
/**
* Flattens chat markdown to one line of plain text, for the reply previews:
* images become a label, links their text, and markup characters go. Also
* copes with a body the server cut short mid-link, which would otherwise show
* a dangling half-URL.
* Returns the chat markdown `md` flattened to one line of plain text, for the
* reply previews: images become their alt text or `imageLabel`, links their
* text, and markup characters go. Also copes with a body the server cut short
* mid-link, which would otherwise show a dangling half-URL.
*/
export function markdownToPlainText(md: string, imageLabel: string): string {
return md

View File

@@ -1,16 +1,23 @@
// Timestamps in the chat: day separators and the grouped-message gutter.
/**
* Timestamps in the chat: day separators and the grouped-message gutter.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { i18n } from "../../i18n.ts";
import { i18n } from "@lingui/core";
/** Checks whether `a` and `b` fall on the same local day. */
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
// days, otherwise a localized full date.
/**
* Returns the label of the day separator for `date`: "Today" or "Yesterday" for
* the two most recent days, otherwise a localized full date.
*/
export function dayLabel(date: Date): string {
const now = new Date();
if (isSameDay(date, now)) return t`Today`;
@@ -25,8 +32,11 @@ export function dayLabel(date: Date): string {
});
}
// Hours and minutes only, for the narrow gutter beside a grouped message — the
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
/**
* Returns the hours and minutes of `date`, for the narrow gutter beside a
* grouped message — the day period ("PM") is dropped to fit; its tooltip has
* the full timestamp.
*/
export function clockTime(date: Date): string {
return new Intl.DateTimeFormat(i18n.locale, {
hour: "numeric",

View File

@@ -1,3 +1,20 @@
/**
* Persistence for the half-written dump in the create modal.
*
* The modal is dismissable by Escape, a backdrop click and the ✕, and it holds
* a URL, a title, a rich-text "why", categories and a visibility choice — so a
* stray keystroke used to destroy a few minutes of writing. Everything except
* the attached file (a `File` cannot survive a reload) is mirrored here on each
* change and restored the next time the modal opens; a successful post clears
* it.
*
* Deliberately a single draft rather than one per tab: someone who closed the
* modal by accident wants the thing they were just writing, wherever they
* reopen it.
*
* @module
*/
/**
* Persistence for the half-written dump in the create modal.
*
@@ -13,18 +30,29 @@
* reopen it.
*/
/** The localStorage key of the draft. */
const STORAGE_KEY = "gerbeur.dumpDraft";
/** A half-written dump. */
export interface DumpDraft {
/** The link, as typed. */
url: string;
/** The title. */
title: string;
/** Whether `title` was typed by the poster, rather than taken from the preview. */
/**
* Whether `title` was typed by the poster, rather than taken from the
* preview.
*/
titleFromUser: boolean;
/** The "why", in Markdown. */
comment: string;
/** Whether the dump will be public. */
isPublic: boolean;
/** The ids of the chosen categories. */
categoryIds: string[];
}
/** A draft with nothing in it. */
export const EMPTY_DUMP_DRAFT: DumpDraft = {
url: "",
title: "",
@@ -34,13 +62,14 @@ export const EMPTY_DUMP_DRAFT: DumpDraft = {
categoryIds: [],
};
/** Whether a draft holds anything worth restoring or warning about. */
/** Checks whether `draft` holds nothing worth restoring or warning about. */
export function isDumpDraftEmpty(draft: DumpDraft): boolean {
return !draft.url.trim() && !draft.comment.trim() &&
!(draft.titleFromUser && draft.title.trim()) &&
draft.categoryIds.length === 0 && draft.isPublic;
}
/** Returns the stored draft, or null when there is none worth restoring. */
export function loadDumpDraft(): DumpDraft | null {
let raw: string | null;
try {
@@ -70,6 +99,10 @@ export function loadDumpDraft(): DumpDraft | null {
}
}
/**
* Stores `draft`, or removes the stored one when `draft` is empty. A failure is
* silent: the draft is a convenience, never a precondition for posting.
*/
export function saveDumpDraft(draft: DumpDraft): void {
try {
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
@@ -80,6 +113,7 @@ export function saveDumpDraft(draft: DumpDraft): void {
}
}
/** Removes the stored draft. */
export function clearDumpDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);

View File

@@ -1,9 +1,15 @@
// The emoji picker's skin tone, remembered across pickers and reloads.
/**
* The emoji picker's skin tone, remembered across pickers and reloads.
*
* @module
*/
import type { SkinTone } from "frimousse";
/** The localStorage key of the skin tone. */
export const SKIN_TONE_KEY = "emoji-skin-tone";
/** Returns the stored skin tone, `none` when there is none. */
export function readSkinTone(): SkinTone {
try {
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";

View File

@@ -1,8 +1,13 @@
// Text helpers shared by the editor's autocompletes (mentions, emoji).
/**
* Text helpers shared by the editor's autocompletes (mentions, emoji) and the
* chat's emoji-only messages.
*
* @module
*/
/**
* Whether the caret at the end of `textBefore` sits inside markdown code — an
* open ``` fence, or an unclosed `span` on the current line — where `@bob`
* Checks whether the caret at the end of `textBefore` sits inside markdown code
* — an open ``` fence, or an unclosed `span` on the current line — where `@bob`
* and `:smile` are literal text, not something to complete.
*/
export function insideCode(textBefore: string): boolean {
@@ -13,12 +18,16 @@ export function insideCode(textBefore: string): boolean {
return (line.match(/`/g)?.length ?? 0) % 2 === 1;
}
/** Matches text made of emoji, their modifiers and whitespace only. */
const EMOJI_ONLY_RE =
/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\u{1F3FB}-\u{1F3FF}#*0-9]|\u200D|\uFE0F|\u20E3|\s)+$/u;
/** Matches one pictographic emoji or flag letter. */
const PICTOGRAPH_RE = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
/** Whether `text` is nothing but a few emoji (at most `max`), for the
* jumbo rendering chats give a message like "🎉" or "👍👍". */
/**
* Checks whether `text` is nothing but a few emoji (at most `max`), for the
* jumbo rendering chats give a message like "🎉" or "👍👍".
*/
export function isEmojiOnly(text: string, max = 3): boolean {
const trimmed = text.trim();
if (!trimmed || !EMOJI_ONLY_RE.test(trimmed)) return false;

View File

@@ -1,5 +1,12 @@
/**
* The ranking of the hot feed.
*
* @module
*/
import type { Dump } from "../../../shared/dump.ts";
/** Returns how hot `dump` is: its votes, decaying with its age in hours. */
export function hotScore(dump: Dump): number {
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);

View File

@@ -1,24 +1,41 @@
/**
* The journal feed's layout: dumps ranked by hotness and laid out as an
* editorial mosaic, cut into blocks.
*
* @module
*/
import type { Dump } from "../../../shared/dump.ts";
import { hotScore } from "./hotScore.ts";
/**
* Grid footprint of a journal entry in the editorial mosaic.
* feature — 2×2 hero tall — 1×2 portrait
* wide — 2×1 banner square — 1×1 unit
* The footprint only describes the grid span; how a card is *rendered*
* (image / quote / text) is derived separately from its content.
*
* - `feature` — 2×2 hero
* - `wide` — 2×1 banner
* - `tall` — 1×2 portrait
* - `square` — 1×1 unit
*
* The footprint only describes the grid span; how a card is *rendered* (image /
* quote / text) is derived separately from its content.
*/
export type JournalShape = "feature" | "wide" | "tall" | "square";
/** A dump placed in the mosaic. */
export interface JournalEntry {
/** The dump. */
dump: Dump;
/** The footprint it takes. */
shape: JournalShape;
}
/** A run of entries rendered as one grid, separated from the next by a break. */
/**
* A run of entries rendered as one grid, separated from the next by a break.
*/
export interface JournalBlock {
/** Stable list key — the id of the dump the block opens on. */
key: string;
/** The block's entries, in reading order. */
entries: JournalEntry[];
}
@@ -28,6 +45,7 @@ const COLUMNS = 4;
/** Cards a sub-block gathers before a break comes due. */
const BLOCK_CARDS = 12;
/** The columns and rows each shape spans. */
const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
feature: { cols: 2, rows: 2 },
wide: { cols: 2, rows: 1 },
@@ -35,7 +53,10 @@ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
square: { cols: 1, rows: 1 },
};
/** A dump that can carry a real preview image (file image or rich thumbnail). */
/**
* Checks whether `dump` can carry a real preview image (file image or rich
* thumbnail).
*/
export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file") {
const mime = dump.fileMime ?? "";
@@ -47,13 +68,16 @@ export function hasThumbnail(dump: Dump): boolean {
return !!dump.richContent?.thumbnailUrl;
}
/** A dump whose comment is substantial enough to feature as a pull-quote. */
/**
* Checks whether the comment of `dump` is substantial enough to feature as a
* pull-quote.
*/
export function hasQuote(dump: Dump): boolean {
return !!dump.comment && dump.comment.trim().length > 0;
}
/**
* Occupancy of the mosaic, walked in reading order.
* Returns the occupancy of a mosaic `columns` wide, walked in reading order.
*
* `next()` returns the first cell no card has claimed yet, along with the run
* of free cells to its right — the width the next card is allowed to take.
@@ -84,7 +108,8 @@ function createMosaic(columns: number) {
}
/**
* The footprint an entry would *like*, before it's clamped to the grid.
* Returns the footprint the entry at `index` would *like*, before it's clamped
* to the grid.
*
* Variety is composed, not tiered: a repeating 11-step rhythm spreads tall and
* wide accents through the feed instead of bucketing every card by score. The
@@ -108,7 +133,8 @@ function desiredShape(dump: Dump, index: number): JournalShape {
}
/**
* Shrink a footprint until it fits the cells actually on offer.
* Shrinks `shape` until it fits the `free` cells actually on offer, with
* `remaining` cards left to place.
*
* Two cuts. Horizontally, a 2-column card that meets a 1-column gap gives up a
* column rather than wrapping — wrapping is what leaves a hole behind. And a
@@ -128,10 +154,11 @@ function fit(
}
/**
* A section front leads with a picture. When the hottest dump is a bare note,
* promote the first piece of art from just behind it rather than opening the
* page on an empty banner — the top few are within noise of each other on
* score anyway, and which one leads is an editorial call, not a ranking one.
* Returns `sorted` with a picture in the lead. A section front leads with a
* picture: when the hottest dump is a bare note, the first piece of art from
* just behind it is promoted rather than opening the page on an empty banner —
* the top few are within noise of each other on score anyway, and which one
* leads is an editorial call, not a ranking one.
*/
function leadWithArt(sorted: Dump[]): Dump[] {
if (sorted.length < 2 || hasThumbnail(sorted[0])) return sorted;
@@ -141,7 +168,7 @@ function leadWithArt(sorted: Dump[]): Dump[] {
}
/**
* Sort dumps by hotness, then lay them out as an editorial mosaic: one hero
* Sorts `dumps` by hotness, then lays them out as an editorial mosaic: one hero
* feature followed by an asymmetric mix of banners, portraits, squares and
* pull-quotes, composed so the four columns close on every row — and cut into
* sub-blocks so the feed breathes instead of running on as one slab.
@@ -151,18 +178,18 @@ function leadWithArt(sorted: Dump[]): Dump[] {
* flush, so the only boundaries ruled out are the ones a portrait or a hero
* straddles. Past that, two things set the rhythm.
*
* It comes due on a count of *cards*, not of rows. A block is a set of
* stories, and a hero is one story however many cells it takes, so a stretch
* thick with big footprints reaches the count over more rows than a stretch
* of squares does. Block heights vary with the size mix rather than beating
* out a fixed measure — and because the mix is itself content-driven (only
* dumps with art may claim the big footprints), so is the rhythm.
* It comes due on a count of *cards*, not of rows. A block is a set of stories,
* and a hero is one story however many cells it takes, so a stretch thick with
* big footprints reaches the count over more rows than a stretch of squares
* does. Block heights vary with the size mix rather than beating out a fixed
* measure — and because the mix is itself content-driven (only dumps with art
* may claim the big footprints), so is the rhythm.
*
* And the break *makes* its own section front rather than waiting for one:
* the card that opens a new block is promoted to a hero. Waiting was the
* mistake — a banner almost never lands at the start of a row on its own, so
* a rule preferring one would almost never fire. Promotion always fits: the
* break only falls on a row with all four cells free.
* And the break *makes* its own section front rather than waiting for one: the
* card that opens a new block is promoted to a hero. Waiting was the mistake —
* a banner almost never lands at the start of a row on its own, so a rule
* preferring one would almost never fire. Promotion always fits: the break only
* falls on a row with all four cells free.
*
* The whole thing stays a pure function of the ranking, so no break shuffles
* under the reader when infinite scroll appends the next page.

View File

@@ -1,8 +1,16 @@
/**
* Turning a Bandcamp page into a queue for the site's own player.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
import type { Tralbum, TralbumTrack } from "../../../shared/player.ts";
/** What the queue's items carry besides the tracks. */
export interface BandcampContext {
/** The page of the dump the release was posted in. */
dumpHref?: string;
/** Kept on every item so playback can fall back to the iframe later. */
embedUrl?: string;
@@ -11,10 +19,12 @@ export interface BandcampContext {
}
/**
* Resolve a Bandcamp page into a playable queue.
* Resolves the Bandcamp page `pageUrl` into a playable queue, its items
* carrying `ctx`. `force` skips the server's cache, for re-resolving after a
* signature expires.
*
* Throws on any failure — callers treat that as "fall back to the embed".
* `force` skips the server's cache, for re-resolving after a signature expires.
* @throws {Error} On any failure, no streamable track included — callers treat
* that as "fall back to the embed".
*/
export async function resolveBandcampQueue(
pageUrl: string,

View File

@@ -1,4 +1,12 @@
/** Format seconds as m:ss. Shared by the media player and the player queue. */
/**
* Track lengths for display.
*
* @module
*/
/**
* Returns `s` seconds as m:ss. Shared by the media player and the player queue.
*/
export function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);

View File

@@ -1,10 +1,17 @@
/**
* Keeping a stream item's signed URL fresh.
*
* @module
*/
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
* Re-resolve the queue a stream item belongs to, for when its signed URL
* expires. Keeping the dispatch here is what lets PlayerProvider handle
* expiry without knowing that Bandcamp exists.
* Re-resolves the queue stream `item` belongs to, for when its signed URL
* expires. Keeping the dispatch here is what lets PlayerProvider handle expiry
* without knowing that Bandcamp exists. The promise rejects when the stream's
* type has no resolver, or the resolver fails.
*/
export function resolveStreamQueue(
item: Extract<PlayerItem, { kind: "stream" }>,
@@ -24,9 +31,16 @@ export function resolveStreamQueue(
return Promise.reject(new Error(`no resolver for stream type ${item.type}`));
}
/** Signed URLs live 24h; re-resolve before that to avoid a stall mid-play. */
/**
* How old a stream's signed URL may get. Signed URLs live 24h; re-resolve
* before that to avoid a stall mid-play.
*/
export const STREAM_STALE_MS = 20 * 60 * 60 * 1000;
/**
* Checks whether the signed URL of `item` is older than {@link
* STREAM_STALE_MS}.
*/
export function isStreamStale(
item: Extract<PlayerItem, { kind: "stream" }>,
): boolean {

View File

@@ -1,11 +1,18 @@
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
// which would otherwise snap the volume back to 100% on every track. Hold it
// outside the component and mirror it to localStorage so it also survives a
// reload.
/**
* The player's volume, remembered across tracks and reloads. Each queue advance
* remounts MediaPlayer (the element is keyed on the source), which would
* otherwise snap the volume back to 100% on every track. It is held outside the
* component and mirrored to localStorage so it also survives a reload.
*
* @module
*/
/** The localStorage key of the volume. */
const VOLUME_KEY = "player-volume";
/** The last volume, 0 to 1. */
let lastVolume = 1;
/** Whether the last player was muted. */
let lastMuted = false;
try {
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
@@ -17,6 +24,7 @@ try {
// Malformed or unavailable storage — the defaults above stand.
}
/** Remembers `volume` and `muted` for the next player. */
export function rememberVolume(volume: number, muted: boolean) {
lastVolume = volume;
lastMuted = muted;
@@ -27,7 +35,7 @@ export function rememberVolume(volume: number, muted: boolean) {
}
}
/** The volume and mute state the last player left behind. */
/** Returns the volume and mute state the last player left behind. */
export function storedVolume(): { volume: number; muted: boolean } {
return { volume: lastVolume, muted: lastMuted };
}

View File

@@ -1,12 +1,31 @@
/**
* The waveform's geometry, and the peaks it draws, decoded from the audio.
*
* @module
*/
/** How many bars the waveform draws. */
export const NUM_BARS = 150;
/** A bar's width, in viewBox units. */
export const BAR_W = 3;
/** The gap between bars, in viewBox units. */
export const BAR_GAP = 1;
/** The waveform's height, in viewBox units. */
export const WAVEFORM_H = 48;
/** The waveform's width, in viewBox units. */
export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP); // 600
// Module-level cache: survives StrictMode double-effect and re-renders
/**
* The peaks per URL, at module level: survives StrictMode double-effect and
* re-renders.
*/
const peaksCache = new Map<string, Float32Array>();
/**
* Returns `n` peaks of the audio at `url`, fetched and decoded once per URL.
*
* @throws {Error} When the fetch or the decoding fails.
*/
export async function extractPeaks(
url: string,
n: number,

View File

@@ -1,8 +1,15 @@
/**
* YouTube playlists in the player: the embed's list, its IFrame API parameters,
* and the tracklist.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
import type { YouTubePlaylistTrack } from "../../../shared/player.ts";
/**
* The playlist an embed URL plays, if any.
* Returns the playlist `embedUrl` plays, if any.
*
* Covers both embed shapes the provider produces: `/embed/videoseries?list=…`
* for a bare playlist, and `/embed/<id>?list=…` for a video opened in one.
@@ -16,7 +23,8 @@ export function embedListId(embedUrl: string): string | null {
}
/**
* Add the parameters the IFrame Player API needs to talk to the embed.
* Returns `url` with the parameters the IFrame Player API needs to talk to the
* embed.
*
* `enablejsapi` has to be in the src the iframe was created with — it cannot be
* turned on afterwards — so it is added for every playlist embed, whether or
@@ -33,7 +41,12 @@ export function withJsApi(url: string): string {
}
}
/** Titles and lengths for a playlist. Throws; callers fall back to no list. */
/**
* Returns the titles and lengths of the tracks of playlist `listId`.
*
* @throws {Error} When the fetch fails or finds no track; callers fall back to
* no list.
*/
export async function fetchPlaylistTracks(
listId: string,
): Promise<YouTubePlaylistTrack[]> {

View File

@@ -1,9 +1,17 @@
/**
* Reading the API's answers: the envelope, and the errors a request can end in.
*
* @module
*/
import type { APIResponse } from "../../../shared/api.ts";
/**
* Parses an unknown JSON payload into a typed APIResponse<T>.
* Performs a minimal runtime check on the `success` discriminant so the
* single internal `as` cast is safe; throws if the shape is unexpected.
* Checks that `raw` is an API envelope and returns it as one. The check is
* minimal — the `success` discriminant — so the single internal `as` cast is
* safe.
*
* @throws {Error} When `raw` isn't an envelope.
*/
export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
if (raw !== null && typeof raw === "object" && "success" in raw) {
@@ -13,8 +21,8 @@ export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
}
/**
* Convert a caught fetch error into a human-readable message.
* TypeError means the request never left the client (no network / CORS / etc.).
* Returns a human-readable message for `err`, an error caught around a fetch. A
* `TypeError` means the request never left the client (no network, CORS, …).
*/
export function friendlyFetchError(err: unknown): string {
if (err instanceof TypeError) {
@@ -24,10 +32,12 @@ export function friendlyFetchError(err: unknown): string {
}
/**
* Reads a fetch `Response` through the standard API envelope and returns the
* payload, throwing the server's message on `success: false`. A thrown error
* Reads `res` through the API envelope and returns the payload. A thrown error
* here (or a network `TypeError` from the `fetch` itself) is caught by
* `useApiForm`'s `submit` and rendered as the form's root error.
*
* @throws {Error} The server's message, when the response says `success:
* false`; also when the body isn't an envelope.
*/
export async function expectOk<T>(res: Response): Promise<T> {
const body = parseAPIResponse<T>(await res.json());

View File

@@ -1,11 +1,23 @@
/**
* The character counter's states, for fields with a length limit.
*
* @module
*/
/**
* Returns the counter's modifier class for `len` characters out of `max`: warn
* from 85%, danger at the limit, none below.
*/
export function charCountClass(len: number, max: number): string {
if (len >= max) return " text-editor-count--danger";
if (len >= max * 0.85) return " text-editor-count--warn";
return "";
}
/** Whether `value` is past `max` — the counter's danger state past its
* edge, and what the server would refuse. */
/**
* Checks whether `value` is past `max` — the counter's danger state past its
* edge, and what the server would refuse.
*/
export function overLimit(value: string, max: number): boolean {
return value.length > max;
}

View File

@@ -1,3 +1,11 @@
/**
* Turning what the API, the socket and localStorage hand back — JSON, where
* dates are ISO strings (see {@link Wire}) — back into the shared types, with
* Dates.
*
* @module
*/
import type { Wire } from "../../../shared/api.ts";
import type { Category } from "../../../shared/category.ts";
import type { ChatMessage } from "../../../shared/chat.ts";
@@ -16,9 +24,7 @@ import type {
SelfUser,
} from "../../../shared/user.ts";
// The API, the socket and localStorage all hand back JSON, where dates are ISO
// strings (see Wire<T>); these turn them back into Dates.
/** Returns `raw` with `createdAt` and `updatedAt` as Dates. */
function dates<T extends { createdAt: string; updatedAt?: string }>(raw: T) {
return {
...raw,
@@ -27,68 +33,85 @@ function dates<T extends { createdAt: string; updatedAt?: string }>(raw: T) {
};
}
/** Returns `raw` as a {@link Dump}. */
export function deserializeDump(raw: Wire<Dump>): Dump {
return dates(raw);
}
/** Returns `raw`, a dump from JSON of unchecked type, as a {@link Dump}. */
export function hydrateDump(raw: unknown): Dump {
return deserializeDump(raw as Wire<Dump>);
}
/** Returns `raw` as a {@link DumpUrlMatch}. */
export function deserializeDumpUrlMatch(raw: Wire<DumpUrlMatch>): DumpUrlMatch {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
/** Returns `raw` as a {@link Category}. */
export function deserializeCategory(raw: Wire<Category>): Category {
return dates(raw);
}
/** Returns `raw` as a {@link PublicUser}. */
export function deserializePublicUser(raw: Wire<PublicUser>): PublicUser {
return dates(raw);
}
/** Returns `raw` as a {@link SelfUser}. */
export function deserializeSelfUser(raw: Wire<SelfUser>): SelfUser {
return dates(raw);
}
/** Returns `raw` as an {@link Invitee}. */
export function deserializeInvitee(raw: Wire<Invitee>): Invitee {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
/** Returns `raw` as an {@link AuthResponse}. */
export function deserializeAuthResponse(
raw: Wire<AuthResponse>,
): AuthResponse {
return { ...raw, user: deserializeSelfUser(raw.user) };
}
/** Returns `raw` as a {@link Comment}. */
export function deserializeComment(raw: Wire<Comment>): Comment {
return dates(raw);
}
/** Returns `raw` as a {@link ChatMessage}. */
export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
return dates(raw);
}
/** Returns `raw` as a {@link Playlist}. */
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
return dates(raw);
}
/**
* Returns `raw`, a playlist from JSON of unchecked type, as a {@link Playlist}.
*/
export function hydratePlaylist(raw: unknown): Playlist {
return deserializePlaylist(raw as Wire<Playlist>);
}
/** Returns `raw` as a {@link PlaylistWithDumps}, its dumps included. */
export function deserializePlaylistWithDumps(
raw: Wire<PlaylistWithDumps>,
): PlaylistWithDumps {
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) };
}
/** Returns `raw` as a {@link PlaylistMembership}. */
export function deserializePlaylistMembership(
raw: Wire<PlaylistMembership>,
): PlaylistMembership {
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
}
/** Returns `raw` as a {@link Notification}. */
export function deserializeNotification(raw: Wire<Notification>): Notification {
return { ...raw, createdAt: new Date(raw.createdAt) };
}

View File

@@ -1,3 +1,10 @@
/**
* Formatting file sizes and file names for display.
*
* @module
*/
/** Returns `bytes` as a size in B, KB or MB. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -5,10 +12,11 @@ export function formatBytes(bytes: number): string {
}
/**
* A file's name as a readable dump title: the extension goes (it is noise in a
* feed, and the dump already carries its MIME type), and separator runs become
* spaces, so `holiday_photo-01.final.jpg` reads as `holiday photo-01.final`.
* Names that are *only* an extension (`.gitignore`) are left alone.
* Returns the file name `name` as a readable dump title: the extension goes (it
* is noise in a feed, and the dump already carries its MIME type), and
* underscore runs become spaces, so `holiday_photo-01.final.jpg` reads as
* `holiday photo-01.final`. Names that are *only* an extension (`.gitignore`)
* are left alone.
*/
export function titleFromFileName(name: string): string {
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");

View File

@@ -1,6 +1,15 @@
/**
* Routing third-party images through the API's image proxy.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
/**
* Returns the API's origin — `API_URL` is relative ("") when it is the page's
* own.
*/
function apiOrigin(): string {
try {
return new URL(API_URL, location.href).origin;
@@ -10,7 +19,7 @@ function apiOrigin(): string {
}
/**
* Whether `url` points at a third party, i.e. whether proxying it would
* Checks whether `url` points at a third party, i.e. whether proxying it would
* actually change anything. Our own files and thumbnails are served with no
* `Referer` checks, so retrying them through the proxy is pure waste.
*/
@@ -24,7 +33,8 @@ export function canProxyImage(url: string): boolean {
}
}
interface ProxyImageOptions {
/** How {@link proxyImageUrl} proxies. */
export interface ProxyImageOptions {
/**
* Proxy even when the URL is already HTTPS. Used as a retry after a direct
* load fails — typically hotlink protection rejecting our cross-site
@@ -34,11 +44,12 @@ interface ProxyImageOptions {
}
/**
* Route an external image through `GET /api/proxy-image`.
* Returns `url` routed through `GET /api/proxy-image`, or unchanged when it
* needs no proxy.
*
* By default only HTTP URLs are proxied, so they don't trigger mixed-content
* blocks when the frontend is served over HTTPS. `force` opts an HTTPS URL in
* as well.
* blocks when the frontend is served over HTTPS. `opts.force` opts an HTTPS URL
* in as well.
*/
export function proxyImageUrl(
url: string,

View File

@@ -1,5 +1,12 @@
import { i18n } from "../../i18n.ts";
/**
* Dates as the time since them.
*
* @module
*/
import { i18n } from "@lingui/core";
/** Returns how long ago `date` was ("3 minutes ago"), in the current locale. */
export function relativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const diff = date.getTime() - Date.now(); // negative = past

View File

@@ -1,18 +1,29 @@
// Light UI sounds for live notifications and chat, synthesised with Web Audio
// so there's no asset to ship or cache. Two voices: a single soft blip for a
// chat message, and a two-note rising chime for a notification (a mention, a
// follow, an upvote…).
/**
* Light UI sounds for live notifications and chat, synthesised with Web Audio
* so there's no asset to ship or cache. Two voices: a single soft blip for a
* chat message, and a two-note rising chime for a notification (a mention, a
* follow, an upvote…).
*
* @module
*/
/** The localStorage key of the sounds setting. */
const STORAGE_KEY = "sounds";
// Shared across tabs: the id of the last event that chimed, so a user with the
// site open in several tabs hears one chime per event rather than one per tab.
/**
* Shared across tabs: the id of the last event that chimed, so a user with the
* site open in several tabs hears one chime per event rather than one per tab.
*/
const CLAIM_KEY = "sounds-last-claim";
// A burst of chat messages shouldn't machine-gun blips; one per window is
// enough to say "the chat is moving".
/**
* Milliseconds between chat blips. A burst of chat messages shouldn't
* machine-gun blips; one per window is enough to say "the chat is moving".
*/
const CHAT_THROTTLE = 4_000;
/** Which chime: the chat's blip or a notification's. */
export type ChimeKind = "chat" | "notify";
/** Returns whether sounds are on — the default when nothing is stored. */
export function readSoundsEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "off";
@@ -21,6 +32,7 @@ export function readSoundsEnabled(): boolean {
}
}
/** Stores whether sounds are on. */
export function writeSoundsEnabled(enabled: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off");
@@ -29,9 +41,15 @@ export function writeSoundsEnabled(enabled: boolean): void {
}
}
/** The page's audio context, made on first use. */
let ctx: AudioContext | null = null;
/** When the last chat blip played, in epoch ms. */
let lastChatAt = 0;
/**
* Returns the page's audio context, made on first use; null where Web Audio
* isn't available.
*/
function audio(): AudioContext | null {
if (ctx) return ctx;
try {
@@ -42,9 +60,12 @@ function audio(): AudioContext | null {
return ctx;
}
// Browsers keep an AudioContext suspended until the page sees a user gesture.
// Resume it on the first one so a chime arriving later — possibly while the
// tab is in the background — can actually be heard.
/**
* Resumes the audio context on the page's first user gesture. Browsers keep an
* AudioContext suspended until the page sees one, so without this a chime
* arriving later — possibly while the tab is in the background — couldn't be
* heard.
*/
function unlock() {
const ac = audio();
if (ac?.state === "suspended") void ac.resume();
@@ -54,6 +75,7 @@ function unlock() {
globalThis.addEventListener("pointerdown", unlock);
globalThis.addEventListener("keydown", unlock);
/** Claims `eventId` for this tab, and returns whether no other tab had. */
function claim(eventId: string): boolean {
try {
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
@@ -64,6 +86,10 @@ function claim(eventId: string): boolean {
return true;
}
/**
* Plays one note of `freq` Hz on `ac`, from `start` for `duration` seconds,
* peaking at gain `peak`.
*/
function tone(
ac: AudioContext,
freq: number,
@@ -84,6 +110,7 @@ function tone(
osc.stop(start + duration + 0.02);
}
/** Plays the chime `kind`, if the audio context is running. */
function render(kind: ChimeKind) {
const ac = audio();
if (!ac || ac.state !== "running") return;

View File

@@ -1,3 +1,13 @@
/**
* Color and lettering for generated thumbnail placeholders.
*
* The tint is never painted on its own — it's mixed into the theme's surface
* color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for
* any site color, in every style, in both light and dark.
*
* @module
*/
/**
* Color and lettering for generated thumbnail placeholders.
*
@@ -7,16 +17,20 @@
*/
/**
* `accentColor` comes from a dumped page's `theme-color`, so it's
* attacker-controlled. Custom properties bypass CSS value parsing and get
* substituted verbatim wherever the var is used, so the canonical form is
* re-checked here before it ever reaches a style object.
* Checks whether `value` is a `#rrggbb` color. `accentColor` comes from a
* dumped page's `theme-color`, so it's attacker-controlled. Custom properties
* bypass CSS value parsing and get substituted verbatim wherever the var is
* used, so the canonical form is re-checked here before it ever reaches a style
* object.
*/
export function isSafeHex(value: string | undefined): value is string {
return !!value && /^#[0-9a-f]{6}$/.test(value);
}
/** FNV-1a — small, stable, and well spread across the hue circle. */
/**
* Returns the FNV-1a hash of `seed` — small, stable, and well spread across the
* hue circle.
*/
function hashString(seed: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
@@ -26,11 +40,14 @@ function hashString(seed: string): number {
return hash >>> 0;
}
/** A stable hue for a string, so the same site always gets the same tint. */
/**
* Returns a stable hue for `seed`, so the same site always gets the same tint.
*/
export function hueFromString(seed: string): string {
return `hsl(${hashString(seed) % 360} 55% 55%)`;
}
/** Returns the hostname of `url` without `www.`, undefined when it has none. */
function hostnameOf(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
@@ -41,10 +58,10 @@ function hostnameOf(url: string | undefined): string | undefined {
}
/**
* The page's own brand color when it declared a usable one, otherwise a hue
* derived from its hostname — so every card gets a tint, including rows saved
* before accent extraction existed. File dumps have no hostname to hash and
* pass `seed` (their filename) instead.
* Returns the page's own brand color when it declared a usable one, otherwise a
* hue derived from its hostname — so every card gets a tint, including rows
* saved before accent extraction existed. File dumps have no hostname to hash
* and pass `seed` (their filename) instead.
*/
export function tintFor(
{ accentColor, url, seed }: {
@@ -57,7 +74,10 @@ export function tintFor(
return hueFromString(seed ?? hostnameOf(url) ?? url ?? "");
}
/** The letter drawn when there's no favicon to show. */
/**
* Returns the letter drawn when there's no favicon to show: the first letter or
* digit of `siteName`, else of the hostname of `url`.
*/
export function initialsFor(
siteName: string | undefined,
url: string | undefined,

View File

@@ -1,14 +1,25 @@
/**
* The site's own URLs: pages, uploaded files, thumbnails and covers.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
type VersionedDump = {
/** What a dump's file and thumbnail URLs are built from. */
export type VersionedDump = {
/** The dump's id. */
id: string;
/** The uploaded file's size in bytes. */
fileSize?: number;
/** When the dump was posted. */
createdAt?: Date;
/** When the dump was last edited. */
updatedAt?: Date;
};
/**
* A file dump's version, as the server computes it (dumpFileVersion in
* Returns a file dump's version, as the server computes it (dumpFileVersion in
* api/services/dump-service.ts): it changes whenever the file can have. A URL
* carrying the current one is cached by the browser for good; any other is
* revalidated.
@@ -19,10 +30,10 @@ export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string {
}
/**
* Direct URL to a dump's uploaded file. The viewer's token is passed as a query
* param — media elements (`<audio>`, `<img>`, …) can't send an `Authorization`
* header — so the server can authorize access to private dumps. Same approach as
* the WebSocket connection (`?token=`).
* Returns the direct URL to the uploaded file of `dump`. The viewer's `token`
* is passed as a query param — media elements (`<audio>`, `<img>`, …) can't
* send an `Authorization` header — so the server can authorize access to
* private dumps. Same approach as the WebSocket connection (`?token=`).
*/
export function dumpFileUrl(
dump: VersionedDump,
@@ -33,10 +44,10 @@ export function dumpFileUrl(
}
/**
* Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}):
* the custom thumbnail if it has one, else what the server makes from the file
* — a video still, or a scaled-down copy of an image for cards to show instead
* of the full upload.
* Returns the thumbnail URL of `dump`, with the viewer's `token` (see {@link
* dumpFileUrl}): the custom thumbnail if it has one, else what the server makes
* from the file — a video still, or a scaled-down copy of an image for cards to
* show instead of the full upload.
*/
export function dumpThumbnailUrl(
dump: VersionedDump,
@@ -51,6 +62,10 @@ export function dumpThumbnailUrl(
}`;
}
/**
* Returns `input` as an absolute URL: trimmed, and `https://` added when it has
* no scheme.
*/
export function normalizeUrl(input: string): string {
const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s;
@@ -58,18 +73,22 @@ export function normalizeUrl(input: string): string {
return `https://${s}`;
}
/** Returns the page of `dump`, by slug when it has one. */
export function dumpUrl(dump: { id: string; slug?: string }): string {
return `/dumps/${dump.slug ?? dump.id}`;
}
/** Returns the page of `playlist`, by slug when it has one. */
export function playlistUrl(playlist: { id: string; slug?: string }): string {
return `/playlists/${playlist.slug ?? playlist.id}`;
}
/** A playlist's cover. Versioned on `updatedAt`, which a new cover bumps, so a
* replaced image isn't served from the browser's cache. A private playlist's
* cover carries the viewer's token (see {@link dumpFileUrl}); a public one
* doesn't, so its URL is the same for everyone. */
/**
* Returns the cover URL of `playlist`. Versioned on `updatedAt`, which a new
* cover bumps, so a replaced image isn't served from the browser's cache. A
* private playlist's cover carries the viewer's `token` (see {@link
* dumpFileUrl}); a public one doesn't, so its URL is the same for everyone.
*/
export function playlistImageUrl(
playlist: { id: string; updatedAt?: Date; isPublic?: boolean },
token?: string | null,

View File

@@ -1,7 +1,17 @@
/**
* Which dumps and playlists this browser has opened, for the unread dot.
*
* @module
*/
/** The localStorage key of the visited dumps. */
const DUMP_KEY = "visited_dumps";
/** The localStorage key of the visited playlists. */
const PLAYLIST_KEY = "visited_playlists";
/** How recent an item must be to show the unread dot: 7 days. */
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/** Returns the ids stored under `key`. */
function loadSet(key: string): Set<string> {
try {
const raw = localStorage.getItem(key);
@@ -11,33 +21,41 @@ function loadSet(key: string): Set<string> {
}
}
/** Stores `set` under `key`; a full storage is ignored. */
function saveSet(key: string, set: Set<string>): void {
try {
localStorage.setItem(key, JSON.stringify([...set]));
} catch { /* quota exceeded — ignore */ }
}
/** Checks whether dump `id` was opened in this browser. */
export function isDumpVisited(id: string): boolean {
return loadSet(DUMP_KEY).has(id);
}
/** Checks whether playlist `id` was opened in this browser. */
export function isPlaylistVisited(id: string): boolean {
return loadSet(PLAYLIST_KEY).has(id);
}
/** Records dump `id` as opened. */
export function markDumpVisited(id: string): void {
const set = loadSet(DUMP_KEY);
set.add(id);
saveSet(DUMP_KEY, set);
}
/** Records playlist `id` as opened. */
export function markPlaylistVisited(id: string): void {
const set = loadSet(PLAYLIST_KEY);
set.add(id);
saveSet(PLAYLIST_KEY, set);
}
/** Only items newer than MAX_AGE_MS are eligible to show the unread dot. */
/**
* Checks whether `createdAt` is recent enough for the unread dot: only items
* newer than {@link MAX_AGE_MS} show it.
*/
export function isRecent(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() < MAX_AGE_MS;
}