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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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(/(?!^)\.[^.]+$/, "");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user