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:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -1,5 +1,6 @@
/**
* What the signed-in user may do, as the UI asks it.
* Checks the signed-in user's permissions, for the pages and components that
* hide what the server would refuse.
*
* @module
*/
@@ -7,7 +8,10 @@
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. */
/**
* Checks whether `user` has `permission`. A null `user`, for the signed out,
* has none. The server checks again, so this only decides what to show.
*/
export function can(
user: Pick<PublicUser, "role"> | null | undefined,
permission: Permission,

View File

@@ -1,6 +1,6 @@
/**
* The stored session. The session is shared by every tab through one
* localStorage entry.
* Reads and writes the session in localStorage, where every tab shares one
* entry. `AuthProvider` and `useAuth` are its only users.
*
* @module
*/
@@ -9,19 +9,23 @@ import type { AuthResponse } from "../../../shared/user.ts";
import { deserializeAuthResponse } from "../shared/deserialize.ts";
/**
* The signed-in session as the app holds it. Sessions stored before media
* tokens existed have none; AuthProvider fetches one for them.
* Held by `AuthProvider` and stored here. It is the {@link AuthResponse} of a
* sign-in or sign-up, except that one stored before media tokens existed has
* none until `AuthProvider` fetches it.
*/
export type Session = Omit<AuthResponse, "mediaToken"> & {
mediaToken?: string;
};
/** The localStorage key of the session. */
/**
* The localStorage key of the session. `AuthProvider` listens for changes to it
* from other tabs.
*/
export const AUTH_STORAGE_KEY = "authResponse";
/**
* Checks whether `token`, a JWT, has expired; an unreadable one counts as
* expired.
* Checks whether `token`, a JWT, has expired, without asking the server. An
* unreadable one counts as expired.
*/
export function isTokenExpired(token: string): boolean {
try {
@@ -33,8 +37,8 @@ export function isTokenExpired(token: string): boolean {
}
/**
* Returns the stored session, or null — discarding one that can no longer be
* used.
* Returns the stored session, or null. A session that is unreadable, expired or
* from before roles existed is removed from storage.
*/
export function readStoredSession(): Session | null {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
@@ -51,9 +55,8 @@ export function readStoredSession(): Session | null {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
// The server rejects these tokens too, but the UI must never render a user
// without a role.
if (!parsed.user.role) {
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
@@ -67,9 +70,9 @@ export function storeSession(session: Session): void {
}
/**
* 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.
* Forgets the stored session, but only if it still holds `token`. Another tab
* may have replaced it with fresh tokens from a password change, and a 401 for
* the old token here must not wipe those.
*/
export function forgetStoredSession(token: string | undefined): void {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);

View File

@@ -1,29 +1,29 @@
/**
* 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.
* Keeps the chat composer's unsent message in sessionStorage, per user, so
* closing the chat modal or reloading the tab doesn't lose it. `ChatModal`
* reads it on open and writes it on every change.
*
* @module
*/
import type { ChatReplyTarget } from "../../contexts/ws/WSContext.ts";
/** An unsent chat message. */
/**
* Returned by {@link readDraft} to `ChatModal`, which passes it back to
* {@link writeDraft}.
*/
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.
* Returns the stored draft of `userId`, or an empty one when there is none,
* `userId` is undefined, or storage is blocked.
*/
export function readDraft(userId: string | undefined): StoredDraft {
if (userId) {
@@ -40,7 +40,10 @@ 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. */
/**
* Stores `value` as the draft of `userId`, or removes it when it has neither
* text nor a reply target. Does nothing when `userId` is undefined.
*/
export function writeDraft(
userId: string | undefined,
value: StoredDraft,

View File

@@ -1,6 +1,6 @@
/**
* Rules for a chat message: when it can be sent, how it is shown, and its
* one-line preview.
* Decides when a chat message can be sent and how its text is shown, for the
* chat modal's composer, edit box and message rows.
*
* @module
*/
@@ -11,14 +11,14 @@ import { isEmojiOnly } from "../editor/text.ts";
import { VALIDATION } from "../../../shared/validation.ts";
/**
* The length from which the character counter shows — only once a message is
* getting near the limit.
* The length from which the composer and the edit box show their character
* counter, near `VALIDATION.CHAT_MESSAGE_MAX`.
*/
export const COUNT_FROM = 1800;
/**
* Checks whether `draft` can be sent, by what the server holds a message to:
* its length once trimmed.
* Checks whether `draft` can be sent or saved. Like the server, it measures
* the trimmed text.
*/
export function sendable(draft: string): boolean {
const body = draft.trim();
@@ -26,8 +26,8 @@ export function sendable(draft: string): boolean {
}
/**
* Returns the class of a message's text: a message of nothing but a few emoji
* is shown large, as chats do.
* Returns the class for the text of a sent or pending message. A message of
* nothing but a few emoji gets the one that shows it large.
*/
export function messageTextClass(body: string): string {
return isEmojiOnly(body)
@@ -35,7 +35,10 @@ export function messageTextClass(body: string): string {
: "chat-message-text";
}
/** Returns `body` as one line of plain text, for reply previews. */
/**
* Returns `body` as one line of plain text, for the reply bar above the
* composer and the reply line above a message.
*/
export function snippet(body: string): string {
return markdownToPlainText(body, t`image`);
}

View File

@@ -1,5 +1,6 @@
/**
* Text helpers for the live chat.
* Reads the Markdown of chat messages, to find mentions and to flatten it into
* reply previews.
*
* @module
*/
@@ -7,8 +8,9 @@
import { MENTION_RE } from "../../../shared/mention.ts";
/**
* Checks whether `body` @-mentions `username` (case-insensitively), by the same
* rule the server uses to send mention notifications.
* Checks whether `body` @mentions `username`, ignoring case, with the pattern
* the server uses for mention notifications. `WSProvider` uses it to pick the
* chime, and `ChatModal` to highlight the message.
*/
export function mentionsUser(body: string, username: string): boolean {
const target = username.toLowerCase();
@@ -19,14 +21,15 @@ export function mentionsUser(body: string, username: string): boolean {
}
/**
* 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.
* Returns the Markdown `md` as one line of plain text. Images become their alt
* text, or `imageLabel` without one, and links their text. It takes a
* `replyToBody`, which the server cuts short, possibly inside a link: the
* unfinished link then shows as its text, without half a URL. `snippet` calls
* it.
*/
export function markdownToPlainText(md: string, imageLabel: string): string {
return md
// A truncated image or link at the very end: drop the unfinished target.
// An image or link cut short at the end loses its unfinished target.
.replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel)
.replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2")
.replace(/!\[[^\]]*$/, "")

View File

@@ -1,5 +1,6 @@
/**
* Timestamps in the chat: day separators and the grouped-message gutter.
* Formats the chat modal's dates, in the interface's locale, for its day
* separators and the times beside grouped messages.
*
* @module
*/
@@ -15,8 +16,8 @@ export function isSameDay(a: Date, b: Date): boolean {
}
/**
* Returns the label of the day separator for `date`: "Today" or "Yesterday" for
* the two most recent days, otherwise a localized full date.
* Returns the label of the day separator for `date`. It is "Today",
* "Yesterday", or the full date, with the year only when it isn't this one.
*/
export function dayLabel(date: Date): string {
const now = new Date();
@@ -33,9 +34,9 @@ export function dayLabel(date: Date): string {
}
/**
* 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.
* Returns the hours and minutes of `date`, without the day period ("PM") so it
* fits the narrow space beside a grouped message. Its tooltip shows the full
* time.
*/
export function clockTime(date: Date): string {
return new Intl.DateTimeFormat(i18n.locale, {

View File

@@ -1,58 +1,36 @@
/**
* Persistence for the half-written dump in the create modal.
* Keeps the half-written dump of the dump creation modal in localStorage, so
* that closing the modal by mistake, with Escape or a click outside, loses
* nothing but an attached file, which can't be stored.
*
* 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.
* There is one draft, not one per tab: whoever closed the modal by mistake
* wants back what they were just writing, wherever they reopen it.
*
* @module
*/
/**
* 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.
*/
/** The localStorage key of the draft. */
const STORAGE_KEY = "gerbeur.dumpDraft";
/** A half-written dump. */
/**
* Saved by `DumpCreateModal` on every change, and restored when it next opens
* without a shared link. Posting the dump clears it.
*/
export interface DumpDraft {
/** The link, as typed. */
/** As typed, not yet normalized. */
url: string;
/** The title. */
title: string;
/**
* Whether `title` was typed by the poster, rather than taken from the
* preview.
* Whether the poster typed `title`. When not, the modal takes the title
* from the link's preview again.
*/
titleFromUser: boolean;
/** The "why", in Markdown. */
/** 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. */
/** The modal's starting values, when no draft is restored. */
export const EMPTY_DUMP_DRAFT: DumpDraft = {
url: "",
title: "",
@@ -62,20 +40,26 @@ export const EMPTY_DUMP_DRAFT: DumpDraft = {
categoryIds: [],
};
/** Checks whether `draft` holds nothing worth restoring or warning about. */
/**
* Checks whether `draft` holds nothing worth restoring. A title taken from the
* preview doesn't count, since the preview gives it back.
*/
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. */
/**
* Returns the stored draft, or null when there is none worth restoring or
* storage is blocked. A field that doesn't parse takes its empty value.
*/
export function loadDumpDraft(): DumpDraft | null {
let raw: string | null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Storage disabled (private mode, blocked cookies) — no drafts.
return null; // Storage blocked, as in some private windows.
}
if (!raw) return null;
@@ -108,12 +92,14 @@ export function saveDumpDraft(draft: DumpDraft): void {
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
} catch {
// Storage full or unavailable — the draft is a convenience, never a
// precondition for posting, so a failure here is silent by design.
// Storage full or blocked: see the doc above.
}
}
/** Removes the stored draft. */
/**
* Removes the stored draft, once the dump is posted or the poster chose to
* start afresh.
*/
export function clearDumpDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);

View File

@@ -1,15 +1,19 @@
/**
* The emoji picker's skin tone, remembered across pickers and reloads.
* Stores the emoji picker's skin tone in localStorage, so that it outlasts the
* picker and the page. `EmojiPickerPopup` reads and writes it.
*
* @module
*/
import type { SkinTone } from "frimousse";
/** The localStorage key of the skin tone. */
/** Names the skin tone in localStorage. `EmojiPickerPopup` writes under it. */
export const SKIN_TONE_KEY = "emoji-skin-tone";
/** Returns the stored skin tone, `none` when there is none. */
/**
* Returns the stored skin tone, or `none` when there is none or storage is
* blocked. The stored value is not checked against frimousse's tones.
*/
export function readSkinTone(): SkinTone {
try {
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";

View File

@@ -1,14 +1,15 @@
/**
* Text helpers shared by the editor's autocompletes (mentions, emoji) and the
* chat's emoji-only messages.
* Reads a text as the editor's autocompletes and the chat see it: whether the
* caret is inside code, and whether a message is only emoji.
*
* @module
*/
/**
* 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.
* Checks whether the caret at the end of `textBefore` sits inside Markdown
* code, an open ``` fence or an unclosed `span` on the current line. The
* mention and emoji autocompletes stay closed there, since `@bob` and `:smile`
* are literal text in code.
*/
export function insideCode(textBefore: string): boolean {
const fences = textBefore.match(/^\s{0,3}```/gm)?.length ?? 0;
@@ -18,20 +19,24 @@ export function insideCode(textBefore: string): boolean {
return (line.match(/`/g)?.length ?? 0) % 2 === 1;
}
/** Matches text made of emoji, their modifiers and whitespace only. */
/**
* Matches text made only of emoji, their modifiers and whitespace. Keycap
* characters (`#`, `*`, digits) pass here, so "123" needs the check after it.
*/
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;
/**
* Checks 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 `max` emoji at most, which the chat
* shows large, as in "🎉" or "👍👍". Emoji are counted as graphemes, so a
* family or a flag counts once.
*/
export function isEmojiOnly(text: string, max = 3): boolean {
const trimmed = text.trim();
if (!trimmed || !EMOJI_ONLY_RE.test(trimmed)) return false;
if (!PICTOGRAPH_RE.test(trimmed)) return false; // "123", "#"
// Keycap characters alone ("123", "#").
if (!PICTOGRAPH_RE.test(trimmed)) return false;
const segments = [
...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
trimmed.replace(/\s+/g, ""),

View File

@@ -1,12 +1,17 @@
/**
* The ranking of the hot feed.
* Ranks dumps for the "hot" and "journal" tabs of the index page.
*
* @module
*/
import type { Dump } from "../../../shared/dump.ts";
/** Returns how hot `dump` is: its votes, decaying with its age in hours. */
/**
* Returns the score that `DumpFeed` and `composeJournal` sort by, highest
* first. It grows with the votes and decays with the age in hours, so a new
* dump without votes can outrank an old popular one. The server doesn't rank,
* so only the loaded dumps are ranked.
*/
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,6 +1,7 @@
/**
* The journal feed's layout: dumps ranked by hotness and laid out as an
* editorial mosaic, cut into blocks.
* Lays out the "journal" tab of the index page: it ranks the loaded dumps by
* {@link hotScore}, gives each a tile size in a 4-column mosaic, and cuts the
* mosaic into blocks. `JournalFeed` renders the blocks.
*
* @module
*/
@@ -9,43 +10,35 @@ 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
* - `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.
* The grid span of a tile, which `JournalCard` turns into a CSS class. A
* `feature` spans 2×2 cells, a `wide` 2×1, a `tall` 1×2 and a `square` 1×1.
* Whether the tile shows an image, a quote or text depends on the dump alone.
*/
export type JournalShape = "feature" | "wide" | "tall" | "square";
/** A dump placed in the mosaic. */
/** Returned by {@link composeJournal} within a {@link JournalBlock}. */
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.
* Returned by {@link composeJournal}. `JournalFeed` renders each block as its
* own grid, with a rule between two blocks.
*/
export interface JournalBlock {
/** Stable list key — the id of the dump the block opens on. */
/** The id of the block's first dump, stable as more pages load. */
key: string;
/** The block's entries, in reading order. */
/** In reading order. */
entries: JournalEntry[];
}
/** Columns the mosaic is composed against — the grid's widest breakpoint. */
// The grid's column count at its widest breakpoint.
const COLUMNS = 4;
/** Cards a sub-block gathers before a break comes due. */
// The number of tiles a 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 },
@@ -54,8 +47,9 @@ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
};
/**
* Checks whether `dump` can carry a real preview image (file image or rich
* thumbnail).
* Checks whether `dump` has an image to show: an uploaded thumbnail, an image
* or video file, or a link preview's thumbnail. Only such dumps get the large
* tiles, and `JournalCard` shows them as image tiles.
*/
export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file") {
@@ -69,21 +63,18 @@ export function hasThumbnail(dump: Dump): boolean {
}
/**
* Checks whether the comment of `dump` is substantial enough to feature as a
* pull-quote.
* Checks whether `dump` has a non-blank note, which `JournalCard` shows as a
* pull-quote when the dump has no image.
*/
export function hasQuote(dump: Dump): boolean {
return !!dump.comment && dump.comment.trim().length > 0;
}
/**
* 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.
* Because every card is clamped to that run (see `fit`), the cursor never has
* to skip a cell it can't fill, so the tiling is exact: no gaps, and the order
* cards are emitted in is the order they're read in.
* Returns the cells of a mosaic `columns` wide, filled in reading order.
* `next()` returns the first free cell and how many free cells follow it in
* its row. Since `fit` keeps every tile within that run, the tiling leaves no
* gaps, and the tiles are read in the order they are placed.
*/
function createMosaic(columns: number) {
const rows: boolean[][] = [];
@@ -108,14 +99,10 @@ function createMosaic(columns: number) {
}
/**
* 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
* rhythm is content-aware — only dumps with a thumbnail are allowed to claim
* the image-forward `tall`/`wide`/`feature` footprints; everything else stays a
* compact `square` so quote and text cards never stretch awkwardly.
* Returns the shape the dump at `index` would take before `fit` clamps it. An
* 11-step rhythm spreads large tiles through the feed, rather than sizing them
* by score. Only a dump with an image takes a large tile, bar an occasional
* `wide` quote, so that quote and text tiles don't stretch.
*/
function desiredShape(dump: Dump, index: number): JournalShape {
const thumb = hasThumbnail(dump);
@@ -127,19 +114,15 @@ function desiredShape(dump: Dump, index: number): JournalShape {
if (beat === 0 || beat === 5) return "wide";
return "square";
}
// No thumbnail: an occasional wide quote keeps the rhythm from flattening.
if (beat === 3) return "wide";
return "square";
}
/**
* 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
* card within a row of the end drops its second row: nothing is left to fill
* the cells beside a portrait that hangs off the bottom of the feed.
* Shrinks `shape` to fit the `free` cells of its row, with `remaining` tiles
* left to place. A 2-column tile facing one free cell loses a column, since
* wrapping would leave a hole. A tile near the end loses its second row, since
* no tiles would be left to fill the cells beside it.
*/
function fit(
shape: JournalShape,
@@ -154,11 +137,9 @@ function fit(
}
/**
* 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.
* Returns `sorted` led by a dump with an image. When the hottest has none, the
* first one with an image among the next three moves to the front, rather than
* opening the page on an empty banner. Their scores are close anyway.
*/
function leadWithArt(sorted: Dump[]): Dump[] {
if (sorted.length < 2 || hasThumbnail(sorted[0])) return sorted;
@@ -168,31 +149,15 @@ function leadWithArt(sorted: Dump[]): Dump[] {
}
/**
* 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.
* Sorts `dumps` by {@link hotScore} and lays them out as a mosaic whose rows
* all close, cut into blocks. `JournalFeed` calls it on every change of the
* feed.
*
* A break can only fall where a row both starts clean and isn't reached into
* from above, which the exact tiling makes cheap to test: every row closes
* 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.
*
* 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.
* A block ends once it holds 12 tiles, at the next row that starts empty and
* that no tall tile reaches into. The tile opening the next block is made a
* `feature`, or a `wide` without an image, which always fits a row that
* starts empty. The layout depends only on the ranking, so when the next page
* appends dumps that rank lower, only the last block changes.
*/
export function composeJournal(dumps: Dump[]): JournalBlock[] {
const ordered = leadWithArt(
@@ -206,10 +171,8 @@ export function composeJournal(dumps: Dump[]): JournalBlock[] {
ordered.forEach((dump, index) => {
const slot = mosaic.next();
const remaining = ordered.length - index - 1;
// The tail guard: cutting within a few cards of the end leaves a rule
// with a fragment under it, which reads as a mistake rather than as a
// division. Only the bottom of the feed can be affected, and that's the
// part infinite scroll is about to extend anyway.
// No break within 6 tiles of the end, which would leave a stub of a block
// under the rule. Infinite scroll extends the end anyway.
const breaking = entries.length >= BLOCK_CARDS &&
remaining >= BLOCK_CARDS / 2 &&
slot.c === 0 &&

View File

@@ -1,5 +1,7 @@
/**
* Turning a Bandcamp page into a queue for the site's own player.
* Turns a Bandcamp page into a queue of streams for the site's own player.
* `usePlayRichContent` calls it when a Bandcamp link is played in native mode,
* and `streamSources.ts` again when the streams' signed URLs expire.
*
* @module
*/
@@ -8,23 +10,25 @@ 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. */
/**
* Passed to {@link resolveBandcampQueue} by its callers and copied onto every
* item of the queue, since the server's tracklist knows nothing of the dump.
*/
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. */
/** Lets PlayerProvider fall back to Bandcamp's iframe if the streams fail. */
embedUrl?: string;
/** Used only if the page carries no album art of its own. */
fallbackArtworkUrl?: string;
}
/**
* 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.
* Resolves the Bandcamp page `pageUrl` into one `stream` item per streamable
* track, through `GET /api/bandcamp/tracks`, each carrying `ctx`. `force`
* bypasses the server's cache, for re-resolving once the signed URLs expire.
*
* @throws {Error} On any failure, no streamable track included — callers treat
* that as "fall back to the embed".
* @throws {Error} When the request fails or the page has no streamable track.
* Callers then fall back to the embed.
*/
export async function resolveBandcampQueue(
pageUrl: string,
@@ -50,7 +54,7 @@ export async function resolveBandcampQueue(
kind: "stream",
streamUrl: t.streamUrl,
type: "bandcamp",
// The row names the track; the artist rides along on the subtitle line.
// The header and the tracklist name the track; the artist goes below.
title: t.title,
subtitle: album.artist,
duration: t.duration,

View File

@@ -1,11 +1,12 @@
/**
* Track lengths for display.
* Formats track lengths for the media player and the global player's tracklist.
*
* @module
*/
/**
* Returns `s` seconds as m:ss. Shared by the media player and the player queue.
* Returns `s` seconds as m:ss, or 0:00 when `s` isn't finite, as a media
* element's duration is until its metadata loads.
*/
export function fmt(s: number): string {
if (!isFinite(s)) return "0:00";

View File

@@ -1,5 +1,7 @@
/**
* Keeping a stream item's signed URL fresh.
* Keeps stream items' signed URLs fresh. PlayerProvider calls it when a stream
* fails or a restored session's stream is stale, without knowing which service
* the stream comes from.
*
* @module
*/
@@ -8,10 +10,9 @@ import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
* 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.
* Re-resolves the whole queue that stream `item` came from, bypassing the
* server's cache. The promise rejects when the stream's type has no resolver
* here, or when its resolver fails.
*/
export function resolveStreamQueue(
item: Extract<PlayerItem, { kind: "stream" }>,
@@ -32,14 +33,15 @@ export function resolveStreamQueue(
}
/**
* How old a stream's signed URL may get. Signed URLs live 24h; re-resolve
* before that to avoid a stall mid-play.
* How old a stream's signed URL may get before {@link isStreamStale} says so.
* Bandcamp's live about 24 hours, and this leaves room for a long listen.
*/
export const STREAM_STALE_MS = 20 * 60 * 60 * 1000;
/**
* Checks whether the signed URL of `item` is older than {@link
* STREAM_STALE_MS}.
* STREAM_STALE_MS}. PlayerProvider refreshes a restored session's stream when
* it is, so that the first press of play doesn't stall.
*/
export function isStreamStale(
item: Extract<PlayerItem, { kind: "stream" }>,

View File

@@ -1,18 +1,15 @@
/**
* 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.
* Remembers the media player's volume across tracks and reloads. GlobalPlayer
* keys MediaPlayer on its source, so every track mounts a new media element at
* full volume; MediaPlayer reads the last volume from here on mount and writes
* back every change, which localStorage keeps across reloads.
*
* @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");
@@ -21,21 +18,24 @@ try {
lastMuted = stored.muted === true;
}
} catch {
// Malformed or unavailable storage — the defaults above stand.
// Unreadable or blocked storage leaves the defaults.
}
/** Remembers `volume` and `muted` for the next player. */
/**
* Remembers `volume` and `muted` for the next media element, and in
* localStorage when it is available.
*/
export function rememberVolume(volume: number, muted: boolean) {
lastVolume = volume;
lastMuted = muted;
try {
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
} catch {
// Private mode or blocked storage — volume just won't persist.
// Blocked storage: the volume lasts until the page is reloaded.
}
}
/** Returns the volume and mute state the last player left behind. */
/** Returns the volume and mute state last remembered, or full and unmuted. */
export function storedVolume(): { volume: number; muted: boolean } {
return { volume: lastVolume, muted: lastMuted };
}

View File

@@ -1,10 +1,12 @@
/**
* The waveform's geometry, and the peaks it draws, decoded from the audio.
* Decodes an audio file into the peaks of its waveform, and sets the waveform's
* geometry. MediaPlayer's `Waveform` and the dump page's audio preview
* (`FilePreview`) both draw with it.
*
* @module
*/
/** How many bars the waveform draws. */
/** How many bars the waveform draws, one per peak. */
export const NUM_BARS = 150;
/** A bar's width, in viewBox units. */
export const BAR_W = 3;
@@ -13,16 +15,17 @@ 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
export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP);
/**
* The peaks per URL, at module level: survives StrictMode double-effect and
* re-renders.
* Kept at module level, so that remounts and StrictMode's double effects don't
* fetch and decode a file again.
*/
const peaksCache = new Map<string, Float32Array>();
/**
* Returns `n` peaks of the audio at `url`, fetched and decoded once per URL.
* Returns `n` peaks of the audio at `url`, each the loudest sample of its slice
* of the first channel. Each URL is fetched and decoded once per page load.
*
* @throws {Error} When the fetch or the decoding fails.
*/
@@ -32,10 +35,8 @@ export async function extractPeaks(
): Promise<Float32Array> {
if (peaksCache.has(url)) return peaksCache.get(url)!;
// /api/files is public (no auth middleware). Plain fetch avoids the CORS
// preflight that credentials/Authorization would trigger — the server's
// oakCors config doesn't set Allow-Credentials so credentialed fetches
// are blocked, while <audio> bypasses CORS entirely.
// No credentials: the server's CORS doesn't allow them, and the file URL
// carries the viewer's media token already. <audio> needs no CORS, this does.
const res = await fetch(url);
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
const buf = await res.arrayBuffer();

View File

@@ -1,6 +1,6 @@
/**
* YouTube playlists in the player: the embed's list, its IFrame API parameters,
* and the tracklist.
* Reads, prepares and lists YouTube playlist embeds, for GlobalPlayer and
* useYouTubePlaylist to show and drive a playlist's tracklist.
*
* @module
*/
@@ -9,10 +9,9 @@ import { API_URL } from "../../config/api.ts";
import type { YouTubePlaylistTrack } from "../../../shared/player.ts";
/**
* 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.
* Returns the id of the playlist `embedUrl` plays, or null. It reads both
* shapes the server's YouTube embeds take, `/embed/videoseries?list=…` for a
* bare playlist and `/embed/<id>?list=…` for a video opened in one.
*/
export function embedListId(embedUrl: string): string | null {
try {
@@ -23,12 +22,10 @@ export function embedListId(embedUrl: string): string | null {
}
/**
* 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
* not the API script ends up loading.
* Returns `url` with the parameters the IFrame Player API needs to drive the
* embed. `enablejsapi` can't be turned on after the iframe loads, so
* GlobalPlayer adds it to every playlist embed, whether or not the API script
* loads.
*/
export function withJsApi(url: string): string {
try {
@@ -42,10 +39,11 @@ export function withJsApi(url: string): string {
}
/**
* Returns the titles and lengths of the tracks of playlist `listId`.
* Returns the titles and lengths of the videos of playlist `listId`, from
* `GET /api/youtube/playlist`, since the embed exposes neither.
*
* @throws {Error} When the fetch fails or finds no track; callers fall back to
* no list.
* @throws {Error} When the request fails or finds no video. useYouTubePlaylist
* then shows the rows without titles, if the player API lists them.
*/
export async function fetchPlaylistTracks(
listId: string,

View File

@@ -1,5 +1,6 @@
/**
* Reading the API's answers: the envelope, and the errors a request can end in.
* Reads the API's JSON envelope, and turns the errors a request can end in into
* messages. Every component and hook that calls the API goes through it.
*
* @module
*/
@@ -7,11 +8,10 @@
import type { APIResponse } from "../../../shared/api.ts";
/**
* 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.
* Returns `raw` as an API envelope. Only the `success` field is checked, so
* the payload's type is the caller's word.
*
* @throws {Error} When `raw` isn't an envelope.
* @throws {Error} When `raw` has no `success` field.
*/
export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
if (raw !== null && typeof raw === "object" && "success" in raw) {
@@ -21,8 +21,9 @@ export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
}
/**
* 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, …).
* Returns the message to show for `err`, an error caught around a request. A
* `TypeError` means the request never reached the server (no network, CORS),
* and gets a message saying so. The messages are not translated.
*/
export function friendlyFetchError(err: unknown): string {
if (err instanceof TypeError) {
@@ -32,12 +33,11 @@ export function friendlyFetchError(err: unknown): string {
}
/**
* 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.
* Returns the payload of `res`. In a form, the error it throws reaches
* `useApiForm`'s `submit`, which shows it as the form's error.
*
* @throws {Error} The server's message, when the response says `success:
* false`; also when the body isn't an envelope.
* @throws {Error} With the server's message when the response says
* `success: false`, and when the body isn't JSON or isn't an envelope.
*/
export async function expectOk<T>(res: Response): Promise<T> {
const body = parseAPIResponse<T>(await res.json());

View File

@@ -1,12 +1,14 @@
/**
* The character counter's states, for fields with a length limit.
* Gives the character counters of the text fields and `TextEditor` their
* warning states.
*
* @module
*/
/**
* Returns the counter's modifier class for `len` characters out of `max`: warn
* from 85%, danger at the limit, none below.
* Returns the counter's modifier class for `len` characters out of `max`, with
* a leading space so that it appends to `text-editor-count`. It warns from 85%
* of `max` and turns to danger at `max`.
*/
export function charCountClass(len: number, max: number): string {
if (len >= max) return " text-editor-count--danger";
@@ -15,8 +17,8 @@ export function charCountClass(len: number, max: number): string {
}
/**
* Checks whether `value` is past `max` — the counter's danger state past its
* edge, and what the server would refuse.
* Checks whether `value` is longer than `max`, which the server would refuse.
* Only the tests call it.
*/
export function overLimit(value: string, max: number): boolean {
return value.length > max;

View File

@@ -1,7 +1,8 @@
/**
* 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.
* Turns the JSON that the API, the socket and the browser's storage hand back,
* where dates are ISO strings ({@link Wire}), into the shared types with
* `Date`s. Every reader of those sources calls these on what it receives. The
* shape is not checked.
*
* @module
*/
@@ -24,7 +25,6 @@ import type {
SelfUser,
} from "../../../shared/user.ts";
/** Returns `raw` with `createdAt` and `updatedAt` as Dates. */
function dates<T extends { createdAt: string; updatedAt?: string }>(raw: T) {
return {
...raw,
@@ -38,7 +38,10 @@ export function deserializeDump(raw: Wire<Dump>): Dump {
return dates(raw);
}
/** Returns `raw`, a dump from JSON of unchecked type, as a {@link Dump}. */
/**
* Returns `raw` as a {@link Dump}, for `useFeedCache`, which reads it back
* untyped from sessionStorage.
*/
export function hydrateDump(raw: unknown): Dump {
return deserializeDump(raw as Wire<Dump>);
}
@@ -91,7 +94,8 @@ export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
}
/**
* Returns `raw`, a playlist from JSON of unchecked type, as a {@link Playlist}.
* Returns `raw` as a {@link Playlist}, for `useFeedCache`, which reads it back
* untyped from sessionStorage.
*/
export function hydratePlaylist(raw: unknown): Playlist {
return deserializePlaylist(raw as Wire<Playlist>);

View File

@@ -1,10 +1,14 @@
/**
* Formatting file sizes and file names for display.
* Formats a file dump's size and name for display, in the file fields, the
* previews and the dump creation dialog.
*
* @module
*/
/** Returns `bytes` as a size in B, KB or MB. */
/**
* Returns `bytes` as a size in B, KB or MB, where a KB is 1024 bytes. The units
* are not translated.
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -12,11 +16,11 @@ export function formatBytes(bytes: number): string {
}
/**
* 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.
* Returns the file name `name` as the title the dump creation dialog proposes.
* The extension goes, since the dump keeps its MIME type, and runs of
* underscores become spaces, so `holiday_photo-01.final.jpg` reads as
* `holiday photo-01.final`. A name that is only an extension (`.gitignore`)
* stays as it is.
*/
export function titleFromFileName(name: string): string {
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");

View File

@@ -1,15 +1,13 @@
/**
* Routing third-party images through the API's image proxy.
* Routes other sites' images through the API's image proxy,
* `GET /api/proxy-image`, for `Thumbnail` and `ThumbnailPlaceholder`.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
/**
* Returns the API's origin — `API_URL` is relative ("") when it is the page's
* own.
*/
/** Returns the API's origin. `API_URL` is "" when it is the page's own. */
function apiOrigin(): string {
try {
return new URL(API_URL, location.href).origin;
@@ -19,9 +17,9 @@ function apiOrigin(): string {
}
/**
* 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.
* Checks whether `url` points at another site, where proxying it could help.
* `Thumbnail` retries through the proxy only then, since the site's own files
* and thumbnails would fail the same way again.
*/
export function canProxyImage(url: string): boolean {
try {
@@ -33,23 +31,20 @@ export function canProxyImage(url: string): boolean {
}
}
/** How {@link proxyImageUrl} proxies. */
/** Passed to {@link proxyImageUrl} by `Thumbnail` to retry a failed image. */
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
* `Referer`, which the server-side fetch doesn't send.
* Proxies an HTTPS URL too, for a retry after a direct load failed, usually
* on hotlink protection.
*/
force?: boolean;
}
/**
* 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. `opts.force` opts an HTTPS URL
* in as well.
* needs no proxy. Only an HTTP URL is proxied by default, so that an HTTPS page
* doesn't block it as mixed content. A relative or localhost URL is never
* proxied.
*/
export function proxyImageUrl(
url: string,
@@ -59,7 +54,8 @@ export function proxyImageUrl(
try {
u = new URL(url);
} catch {
return url; // relative or same-origin path — nothing to proxy
// A relative path is the site's own.
return url;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return url;

View File

@@ -1,15 +1,20 @@
/**
* Dates as the time since them.
* Shows a date as the time since it, beside every dump, comment, message and
* notification.
*
* @module
*/
import { i18n } from "@lingui/core";
/** Returns how long ago `date` was ("3 minutes ago"), in the current locale. */
/**
* Returns how long ago `date` was, as "3 minutes ago", in the current locale.
* It is computed at render, so the text only moves on when the caller
* re-renders. A month counts 30 days and a year 365.
*/
export function relativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const diff = date.getTime() - Date.now(); // negative = past
const diff = date.getTime() - Date.now();
const abs = Math.abs(diff) / 1000;
if (abs < 60) return rtf.format(-Math.round(abs), "second");

View File

@@ -1,29 +1,32 @@
/**
* 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…).
* Plays the sounds of live events, which `WSProvider` triggers, and stores
* whether they are on. They are synthesised with Web Audio, so there's no file
* to ship: a soft blip for a chat message, and a rising two-note chime for a
* notification.
*
* @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.
* Holds, in localStorage, the id of the last event that chimed, so that a user
* with the site open in several tabs hears one chime per event, not one per
* tab.
*/
const CLAIM_KEY = "sounds-last-claim";
/**
* 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".
* The least time between chat blips, in milliseconds. One blip per window says
* that the chat is moving, and a burst of messages shouldn't blip for each.
*/
const CHAT_THROTTLE = 4_000;
/** Which chime: the chat's blip or a notification's. */
/** Passed to {@link playChime} and {@link previewChime} to pick the sound. */
export type ChimeKind = "chat" | "notify";
/** Returns whether sounds are on — the default when nothing is stored. */
/**
* Returns whether sounds are on in this browser, which they are unless turned
* off. {@link playChime} checks it on every play.
*/
export function readSoundsEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "off";
@@ -32,18 +35,16 @@ export function readSoundsEnabled(): boolean {
}
}
/** Stores whether sounds are on. */
/** Stores whether sounds are on, for `useSoundsEnabled`'s setter. */
export function writeSoundsEnabled(enabled: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off");
} catch {
// Private mode / blocked storage: the choice just won't persist.
// With storage blocked, the choice just won't persist.
}
}
/** 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;
/**
@@ -61,10 +62,9 @@ function audio(): AudioContext | null {
}
/**
* 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.
* Resumes the audio context on the page's first user gesture. Browsers keep it
* suspended until the page sees one, and a chime arriving later, perhaps in a
* background tab, couldn't resume it.
*/
function unlock() {
const ac = audio();
@@ -75,13 +75,16 @@ function unlock() {
globalThis.addEventListener("pointerdown", unlock);
globalThis.addEventListener("keydown", unlock);
/** Claims `eventId` for this tab, and returns whether no other tab had. */
/**
* Claims `eventId` for this tab, and returns whether no other tab had. Two tabs
* checking at the same instant can both win.
*/
function claim(eventId: string): boolean {
try {
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
localStorage.setItem(CLAIM_KEY, eventId);
} catch {
// No storage: every tab chimes, which is the lesser evil.
// Without storage every tab chimes, which is the lesser evil.
}
return true;
}
@@ -101,7 +104,7 @@ function tone(
const gain = ac.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, start);
// Fast attack, exponential tail: a struck, glassy note rather than a beep.
// A fast attack and an exponential tail give a struck note, not a beep.
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
@@ -110,7 +113,10 @@ function tone(
osc.stop(start + duration + 0.02);
}
/** Plays the chime `kind`, if the audio context is running. */
/**
* Plays the chime `kind` if the audio context is running. It stays silent
* until the first user gesture has unlocked it.
*/
function render(kind: ChimeKind) {
const ac = audio();
if (!ac || ac.state !== "running") return;
@@ -124,8 +130,9 @@ function render(kind: ChimeKind) {
}
/**
* Plays a chime for a live event, unless sounds are off, another tab already
* chimed for the same event, or (for chat) one played a moment ago.
* Plays the chime for a live event, unless sounds are off, another tab already
* chimed for `eventId`, or a chat blip played less than 4 seconds ago. The
* caller must pass an id that is the same in every tab, such as the message's.
*/
export function playChime(kind: ChimeKind, eventId: string): void {
if (!readSoundsEnabled()) return;
@@ -138,7 +145,10 @@ export function playChime(kind: ChimeKind, eventId: string): void {
render(kind);
}
/** Plays a chime unconditionally — used to preview it from the settings. */
/**
* Plays the chime `kind` whatever the setting, for the profile page's settings
* to preview it. It is called from a click, which unlocks the audio.
*/
export function previewChime(kind: ChimeKind): void {
const ac = audio();
if (!ac) return;

View File

@@ -1,35 +1,24 @@
/**
* 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.
* Picks the colour and the letter of `ThumbnailPlaceholder`. The CSS mixes the
* tint into the theme's surface (`--thumb-tint`) rather than painting it alone,
* so the text keeps its contrast whatever the site's colour and the theme.
*
* @module
*/
/**
* 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.
*/
/**
* 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.
* Checks whether `value` is a lowercase `#rrggbb` colour. An `accentColor`
* comes from a dumped page's `theme-color`, which anyone controls, and a custom
* property is substituted verbatim wherever it is used, unparsed. So the form
* is checked here before it reaches a style.
*/
export function isSafeHex(value: string | undefined): value is string {
return !!value && /^#[0-9a-f]{6}$/.test(value);
}
/**
* Returns the FNV-1a hash of `seed` — small, stable, and well spread across the
* hue circle.
* Returns the FNV-1a hash of `seed`, which is small, stable, and well spread
* across the hue circle.
*/
function hashString(seed: string): number {
let hash = 0x811c9dc5;
@@ -41,13 +30,13 @@ function hashString(seed: string): number {
}
/**
* Returns a stable hue for `seed`, so the same site always gets the same tint.
* Returns a colour of a hue drawn from `seed`, so that 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 {
@@ -58,10 +47,9 @@ function hostnameOf(url: string | undefined): string | undefined {
}
/**
* 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.
* Returns the page's own colour when it declared a valid one, or else a hue
* drawn from `seed` or the hostname of `url`, so that every card gets a tint,
* even for a dump saved before colours were extracted.
*/
export function tintFor(
{ accentColor, url, seed }: {
@@ -75,8 +63,8 @@ export function tintFor(
}
/**
* Returns the letter drawn when there's no favicon to show: the first letter or
* digit of `siteName`, else of the hostname of `url`.
* Returns the letter drawn when there's no favicon to show, the first letter or
* digit of `siteName`, or else of the hostname of `url`, or "?".
*/
export function initialsFor(
siteName: string | undefined,

View File

@@ -1,28 +1,31 @@
/**
* The site's own URLs: pages, uploaded files, thumbnails and covers.
* Builds the site's own URLs, for its pages and for the files, thumbnails and
* covers the API serves. Every link and media element of the frontend takes
* them from here.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
/** What a dump's file and thumbnail URLs are built from. */
/**
* Passed to {@link dumpFileUrl} and {@link dumpThumbnailUrl}, usually as a
* whole `Dump`. Without the dates, the URL carries no current version and the
* browser revalidates it.
*/
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;
};
/**
* 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.
* Returns a file dump's version, as `dumpFileVersion` in
* api/services/dump-service.ts computes it, so the two must change together.
* It changes whenever the file can have. The server lets the browser cache a
* URL that carries the current version for good, and has it revalidate any
* other.
*/
export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string {
const at = dump.updatedAt ?? dump.createdAt;
@@ -30,10 +33,10 @@ export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string {
}
/**
* 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=`).
* Returns the URL of the uploaded file of `dump`. The caller passes the
* session's media token (`useAuth`'s `mediaToken`) as `token`, which goes in
* the query string since a media element can't send a header. The server needs
* it for a private dump's file.
*/
export function dumpFileUrl(
dump: VersionedDump,
@@ -44,10 +47,10 @@ export function dumpFileUrl(
}
/**
* 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.
* Returns the URL of the thumbnail of `dump`, with the media `token` as for
* {@link dumpFileUrl}. The server answers with the custom thumbnail if the dump
* has one, or else with one made from the file, a video still or a scaled-down
* image.
*/
export function dumpThumbnailUrl(
dump: VersionedDump,
@@ -63,8 +66,8 @@ export function dumpThumbnailUrl(
}
/**
* Returns `input` as an absolute URL: trimmed, and `https://` added when it has
* no scheme.
* Returns what a user typed as a link, trimmed, with `https://` added when it
* has no http(s) scheme. The result is not checked to be a valid URL.
*/
export function normalizeUrl(input: string): string {
const s = input.trim();
@@ -73,21 +76,21 @@ export function normalizeUrl(input: string): string {
return `https://${s}`;
}
/** Returns the page of `dump`, by slug when it has one. */
/** Returns the path of 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. */
/** Returns the path of 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}`;
}
/**
* 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.
* Returns the URL of the cover of `playlist`, versioned on `updatedAt`, which a
* new cover bumps. A private playlist's URL carries the media `token`, as for
* {@link dumpFileUrl}. A public one doesn't, so that its URL is the same for
* everyone. A playlist without `isPublic` counts as public.
*/
export function playlistImageUrl(
playlist: { id: string; updatedAt?: Date; isPublic?: boolean },

View File

@@ -1,17 +1,15 @@
/**
* Which dumps and playlists this browser has opened, for the unread dot.
* Remembers in localStorage which dumps and playlists this browser has opened,
* for the unread dot on the dump and playlist cards.
*
* @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
/** The age past which an item shows no unread dot, visited or not. */
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Returns the ids stored under `key`. */
function loadSet(key: string): Set<string> {
try {
const raw = localStorage.getItem(key);
@@ -21,14 +19,16 @@ 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 */ }
} catch { /* With storage full or blocked, the visit is forgotten. */ }
}
/** Checks whether dump `id` was opened in this browser. */
/**
* Checks whether dump `id` was opened in this browser. It parses the whole
* stored list on every call.
*/
export function isDumpVisited(id: string): boolean {
return loadSet(DUMP_KEY).has(id);
}
@@ -38,14 +38,17 @@ export function isPlaylistVisited(id: string): boolean {
return loadSet(PLAYLIST_KEY).has(id);
}
/** Records dump `id` as opened. */
/**
* Records dump `id` as opened. Nothing re-renders, so a card already on screen
* keeps its dot until it renders again.
*/
export function markDumpVisited(id: string): void {
const set = loadSet(DUMP_KEY);
set.add(id);
saveSet(DUMP_KEY, set);
}
/** Records playlist `id` as opened. */
/** Records playlist `id` as opened, as {@link markDumpVisited} does a dump. */
export function markPlaylistVisited(id: string): void {
const set = loadSet(PLAYLIST_KEY);
set.add(id);
@@ -53,8 +56,8 @@ export function markPlaylistVisited(id: string): void {
}
/**
* Checks whether `createdAt` is recent enough for the unread dot: only items
* newer than {@link MAX_AGE_MS} show it.
* Checks whether `createdAt` is less than 7 days old. Older items show no
* unread dot, since an old item not opened here isn't news.
*/
export function isRecent(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() < MAX_AGE_MS;