v4: weekly digests and monthly recaps go out by email, every email is written once as Markdown in the app's one catalog, and a review of the last two commits removes what they stated twice — users get a weekly digest (what happened to them, what the people and playlists they follow posted, the week's top dumps, the community) and a monthly recap (the month's top dumps and the community), both on by default, in their own style and scheme. digest-service sends what is due every quarter of an hour from digest_schedule (migration 0020), which holds each user's rolling 7- or 30-day period; existing users' first periods are spread over the coming week or month, a digest more than a period late covers the last full one, an empty one isn't sent, and an address like admin@localhost is skipped. Each digest carries a one-click unsubscribe (List-Unsubscribe and List-Unsubscribe-Post, POST /api/users/unsubscribe) and a link to the new /unsubscribe page, which asks before turning that cadence off, since mail scanners open every link; the token (lib/jwt) names the user and the cadence and never expires. The welcome email now says the digests are on. The emails' words moved out of a hand-written api/mail/strings.ts into the Lingui catalogs the interface uses: lingui extracts api/mail/ too, the catalogs moved to a top-level locales/ that both halves read, compile emits ES modules the server imports (api/mail/i18n.ts), messages take ICU plurals, and the labels the settings tab, the unsubscribe page and the emails share are one message each (their French had already drifted apart). Each template writes its email once as Markdown, which is the plain-text part, and EmailKit.render draws its blocks in the style (a lone link is a button, a list item starting with an image a thumbnail row, a numbered list a ranking, --- the footer), so no string is declared for an HTML and a text version; user text is escaped for Markdown, which the welcome body didn't do for usernames, whose _ read as emphasis. paint() set colors before a border-top shorthand that reset them, so every divider was black. The review also gave one home to what was written twice: the locale list (shared/locale.ts, read by lingui.config, the client, the server and its catalog loader), the base style and default scheme (shared/theme.ts), an email's look (lookOf, which digest-service, welcome and reset share), whether email is on (EMAIL_ENABLED, read by email-service and index.html), a setting's column and its default in the digests' SQL (SETTINGS_COLUMNS, DEFAULT_SETTINGS), the unsubscribe token (made once, not parsed back from a URL), AuthProvider's compare-and-store, ThemeProvider's twin style and scheme code (useThemeChoice), the test JWT encoder, and duplicate digest messages. The email palettes stay a flattened copy of the themes' tokens, now under a test that caught Times' secondary and link colors drifting. The Dockerfile copies api/mail and locales so the build extracts the emails' messages and the server finds its catalogs, and deno.json maps @lingui/core for the runtime image. 236 backend tests (new: digest building, sending, catch-up, scheduling and unsubscribing; the Markdown renderer; French from the catalog; palettes against themes; the stagger migration; unsubscribe tokens) and 661 frontend tests (new: the unsubscribe page) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-04 22:14:46 +00:00
parent 89014639cb
commit 7960d74c55
60 changed files with 3778 additions and 534 deletions

View File

@@ -11,6 +11,9 @@ COPY index.html ./
COPY public/ ./public/
COPY src/ ./src/
COPY shared/ ./shared/
# The build extracts and compiles the catalogs, the emails' messages included.
COPY locales/ ./locales/
COPY api/mail/ ./api/mail/
# VITE_API_* are only needed when the API lives on a different host than the frontend.
ARG VITE_API_PROTOCOL
@@ -35,6 +38,7 @@ RUN deno install
COPY api/ ./api/
COPY shared/ ./shared/
COPY --from=builder /app/dist/ ./dist/
COPY --from=builder /app/locales/ ./locales/
# Persistent data: database and uploaded/generated files must be mounted as volumes.
VOLUME ["/app/api/sql", "/app/api/uploads"]

View File

@@ -3,8 +3,9 @@
A small invite-only social platform for sharing links and files. Users can post
URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote,
comment, follow each other, build playlists, and search content. A real-time
WebSocket layer handles live presence, vote counts, and notifications. The UI is
localized (English and French) and ships multiple visual themes.
WebSocket layer handles live presence, vote counts, and notifications, and
weekly and monthly email digests sum up what happened. The UI is localized
(English and French) and ships multiple visual themes.
## Stack
@@ -125,6 +126,7 @@ example.com {
```
shared/ # Imported by both halves: API and socket types, limits, permissions
test/ # Tests of shared/ (Deno): test/<module>.test.ts
locales/ # Lingui catalogues (en, fr): the interface's and the emails' messages
api/
main.ts # Entry point — Oak application, middleware, routes
config.ts # Environment variables
@@ -155,7 +157,6 @@ src/ # React frontend (Vite)
hooks/ # Every hook (useFoo.ts)
utils/ # Every non-React module: pure helpers, storage, API glue
test/ # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker)
@@ -249,16 +250,16 @@ types are used through the global `React` namespace rather than a default import
of `react`, which `deno doc` can't follow.
`deno task doc` builds the HTML reference into `docs/` from every module of
`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`).
Its index page is the module doc of `mod.ts` at the root, which introduces the
repository's three parts and their layers in prose; `deno doc` builds the list
of every module itself, grouped by path. The `docs` workflow publishes it on
every push to `main` that touches them. `deno task doc:lint` checks the rules
the linter can see, and that no summary ends in a colon, through `doc-lint.ts`.
Two kinds of diagnostic are let through. A field without a doc passes, since a
field gets one only when it adds to its type's. `deno doc` doesn't document npm
packages, so it takes a type imported from one (react-hook-form's `FieldValues`,
say) for a private one, and those references pass too.
`api/`, `shared/` and `src/` but the tests. Its index page is the module doc of
`mod.ts` at the root, which introduces the repository's three parts and their
layers in prose; `deno doc` builds the list of every module itself, grouped by
path. The `docs` workflow publishes it on every push to `main` that touches
them. `deno task doc:lint` checks the rules the linter can see, and that no
summary ends in a colon, through `doc-lint.ts`. Two kinds of diagnostic are let
through. A field without a doc passes, since a field gets one only when it adds
to its type's. `deno doc` doesn't document npm packages, so it takes a type
imported from one (react-hook-form's `FieldValues`, say) for a private one, and
those references pass too.
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
split into the same domain folders — `auth`, `category`, `chat`, `comment`,

View File

@@ -7,7 +7,8 @@
* @module
*/
import { isStyleName, type StyleName } from "../shared/theme.ts";
import { BASE_STYLE, isStyleName, type StyleName } from "../shared/theme.ts";
import { isLocale, type Locale, SOURCE_LOCALE } from "../shared/locale.ts";
/** The port the server listens on (`GERBEUR_PORT`, default 8000). */
export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
@@ -18,6 +19,12 @@ export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
*/
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
"";
/**
* Whether the instance sends email, which is whether {@link SMTPS_URL} is
* set. `email-service.ts` opens its transport by it, and index.html tells the
* client, whose settings show the digest switches disabled without it.
*/
export const EMAIL_ENABLED: boolean = SMTPS_URL.length > 0;
/**
* The `From` address of every email (`GERBEUR_FROM_EMAIL`), as an address or
* as `Name <address>`. `email-service.ts` refuses to load without it when
@@ -33,24 +40,25 @@ export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
*/
export const WELCOME_EMAIL_BODY: string | null =
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || null;
/** A language the emails are written in. */
export type EmailLocale = "en" | "fr";
/**
* The language of every email (`GERBEUR_EMAIL_LOCALE`). The interface picks
* each visitor's language in their browser, which the server never learns, so
* the emails take the instance's. Any value but `"fr"` means `"en"`.
* the emails take the instance's. A value that isn't a locale means the
* source one, English.
*/
export const EMAIL_LOCALE: EmailLocale =
Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim() === "fr" ? "fr" : "en";
export const EMAIL_LOCALE: Locale = (() => {
const locale = Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim();
return isLocale(locale) ? locale : SOURCE_LOCALE;
})();
/**
* The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a
* visitor or their account picks one, through index.html, and the emails that
* aren't a user's own (the welcome and the password reset) are drawn in it. A
* value that isn't a style means "smooth".
* value that isn't a style means {@link BASE_STYLE}.
*/
export const DEFAULT_STYLE: StyleName = (() => {
const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim();
return isStyleName(style) ? style : "smooth";
return isStyleName(style) ? style : BASE_STYLE;
})();
/**
* The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`).

View File

@@ -26,6 +26,7 @@ import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_in
import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts";
import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts";
import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts";
import { up as up0020DigestSchedule } from "./migrations/0020_digest_schedule.ts";
interface Migration {
name: string;
@@ -64,6 +65,7 @@ const MIGRATIONS: Migration[] = [
up: up0018AttachmentResourceType,
},
{ name: "0019_user_settings", up: up0019UserSettings },
{ name: "0020_digest_schedule", up: up0020DigestSchedule },
];
/**

View File

@@ -0,0 +1,52 @@
/**
* Lets `digest-service.ts` know when each user's next digest is due.
* `digest_schedule` holds, for each user and cadence they receive, the start
* of the period their next digest covers; it is due once a full period has
* passed.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
const DAY_MS = 86_400_000;
// A frozen copy of digest-service's periods: a migration must keep doing
// what it did when it shipped, whatever the service later becomes.
const PERIOD_DAYS = { weekly: 7, monthly: 30 } as const;
/** Spreads `userId` over `[0, days)` days, the same way each run. */
function offsetOf(userId: string, days: number): number {
let hash = 2166136261;
for (const ch of userId) hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619);
return ((hash >>> 0) % (days * 24)) * 3_600_000;
}
/**
* Run once by `runMigrations`. Every user present gets both cadences, with a
* first period that ends at some hour of the coming week or month, picked
* from their id: the digests start trickling out after the deploy instead of
* all leaving a week later. Users created later are scheduled by the service
* when it first sees them.
*/
export function up(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS digest_schedule (
user_id TEXT NOT NULL,
cadence TEXT NOT NULL,
window_start TEXT NOT NULL,
PRIMARY KEY (user_id, cadence),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
const users = db.prepare(`SELECT id FROM users;`).all() as { id: string }[];
const insert = db.prepare(
`INSERT OR IGNORE INTO digest_schedule (user_id, cadence, window_start) VALUES (?, ?, ?);`,
);
const now = Date.now();
for (const { id } of users) {
for (const [cadence, days] of Object.entries(PERIOD_DAYS)) {
const start = now - days * DAY_MS + offsetOf(id, days);
insert.run(id, cadence, new Date(start).toISOString());
}
}
}

View File

@@ -223,6 +223,14 @@ CREATE TABLE user_settings (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE digest_schedule (
user_id TEXT NOT NULL,
cadence TEXT NOT NULL,
window_start TEXT NOT NULL,
PRIMARY KEY (user_id, cadence),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,

View File

@@ -9,9 +9,9 @@
import {
BANDCAMP_PLAYER,
DEFAULT_STYLE,
EMAIL_ENABLED,
OG_SITE_NAME,
SITE_EMOJI,
SMTPS_URL,
} from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts";
@@ -43,7 +43,7 @@ export async function loadIndexHtml(root: string): Promise<string> {
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", SMTPS_URL ? "on" : "off");
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off");
indexHtml.set(root, { mtime, html });
return html;
}

View File

@@ -1,6 +1,6 @@
/**
* Signs and verifies the server's three kinds of token (session, media and
* password reset), all HS256 with the one key. Only a token's signature and
* Signs and verifies the server's four kinds of token (session, media,
* password reset and unsubscribe), all HS256 with the one key. Only a token's signature and
* expiry are checked here: `lib/auth.ts` checks a session or media token
* against its user.
*
@@ -18,6 +18,7 @@ import {
type PasswordResetPayload,
} from "../model/auth.ts";
import { JWT_SECRET } from "../config.ts";
import { type DigestCadence, isDigestCadence } from "../../shared/settings.ts";
if (!JWT_SECRET) {
throw new Error(
@@ -128,3 +129,39 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
return null;
}
}
/**
* Signs the token of a digest's unsubscribe link, which turns off `cadence`
* for `userId` without signing in. It never expires, so the link in an old
* email still works; turning the digest back on doesn't revoke it, which is
* harmless since all it can do is turn it off again.
*/
export async function createUnsubscribeToken(
userId: string,
cadence: DigestCadence,
): Promise<string> {
return await new SignJWT({ purpose: "unsubscribe", userId, cadence })
.setProtectedHeader({ alg: "HS256" })
.sign(JWT_KEY);
}
/**
* Returns the user and cadence of an unsubscribe token, or null when it is
* invalid or another kind of token.
*/
export async function verifyUnsubscribeToken(
token: string,
): Promise<{ userId: string; cadence: DigestCadence } | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (
payload.purpose !== "unsubscribe" || typeof payload.userId !== "string" ||
!isDigestCadence(payload.cadence)
) {
return null;
}
return { userId: payload.userId, cadence: payload.cadence };
} catch {
return null;
}
}

View File

@@ -23,6 +23,7 @@ export const RESERVED_SLUGS: readonly string[] = [
"register",
"notifications",
"reset-password",
"unsubscribe",
"manage",
"api",
"ws",

622
api/mail/digest.ts Normal file
View File

@@ -0,0 +1,622 @@
/**
* Renders the weekly digest and the monthly recap, as the drafts approved
* them: the weekly one has four sections (what happened to the reader, what
* the people and playlists they follow posted, the period's top dumps, and
* the community), the monthly one only the last two. `digest-service.ts`
* gathers a {@link DigestContent} and sends what this returns, drawn in the
* reader's own style and scheme.
*
* @module
*/
import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
import type { DigestCadence } from "../../shared/settings.ts";
import { i18n } from "./i18n.ts";
import {
EmailKit,
type EmailLook,
escMd,
mdLink,
type RenderedEmail,
} from "./layout.ts";
/** The kinds of activity a weekly digest counts. */
export type ActivityKind =
| "upvotes"
| "followers"
| "comments"
| "mentions"
| "likes"
| "playlistFollows";
/** What a highlight of the weekly digest says happened. */
export type HighlightKind =
| "mentionComment"
| "mentionDump"
| "mentionPlaylist"
| "mentionChat"
| "comment"
| "playlistFollow"
| "userFollow"
| "commentLike";
/** A dump in a digest's lists, with its links made absolute. */
export interface DigestDump {
title: string;
url: string;
poster: string;
/** The link's host, or null for an upload. */
domain: string | null;
/** An absolute image URL, or null to show no image. */
thumbnail: string | null;
votes: number;
comments: number;
/** Posted by the reader, which the lists mark. */
mine: boolean;
/** The followed playlist it was added to, when that's why it's listed. */
addedTo?: string;
}
/** One line of the weekly digest's "For you", made from a notification. */
export interface DigestHighlight {
kind: HighlightKind;
/** Who did it. */
who: string;
/** What they did it on, absent for a follow of the reader or the chat. */
target?: { title: string; url: string };
/** The comment or message, flattened and shortened. */
quote?: string;
at: Date;
}
/**
* Passed to {@link renderDigestEmail} by `digest-service.ts`. Each section is
* null when it has nothing to show, and is left out; the service sends
* nothing when all of them are.
*/
export interface DigestContent {
cadence: DigestCadence;
/** The period covered, from `from` to `to`. */
from: Date;
to: Date;
reader: string;
/** Weekly only. */
activity: {
counts: Partial<Record<ActivityKind, number>>;
highlights: DigestHighlight[];
/** Every notification of the period, which the link to them counts. */
total: number;
} | null;
/** Weekly only. */
network: { dumps: DigestDump[]; more: number } | null;
top: DigestDump[];
community: {
members: { username: string; invitedBy: string | null }[];
membersMore: number;
playlists: { title: string; url: string; owner: string; dumps: number }[];
playlistsMore: number;
chatMessages: number;
totals: { dumps: number; upvotes: number; comments: number };
} | null;
links: {
notifications: string;
followed: string;
chat: string;
settings: string;
/** The one-click page that turns this cadence off. */
unsubscribe: string;
};
}
const ACTIVITY_ORDER: ActivityKind[] = [
"upvotes",
"followers",
"comments",
"mentions",
"likes",
"playlistFollows",
];
/** Returns the label of `n` of a kind of activity, as in "12 upvotes". */
function countLabel(kind: ActivityKind, n: number): string {
switch (kind) {
case "upvotes":
return i18n._({
id: "email.digest.count.upvotes",
message: "{n, plural, one {upvote} other {upvotes}}",
values: { n },
});
case "followers":
return i18n._({
id: "email.digest.count.followers",
message: "{n, plural, one {new follower} other {new followers}}",
values: { n },
});
case "comments":
return i18n._({
id: "email.digest.count.comments",
message: "{n, plural, one {comment} other {comments}}",
values: { n },
});
case "mentions":
return i18n._({
id: "email.digest.count.mentions",
message: "{n, plural, one {mention} other {mentions}}",
values: { n },
});
case "likes":
return i18n._({
id: "email.digest.count.likes",
message: "{n, plural, one {comment like} other {comment likes}}",
values: { n },
});
case "playlistFollows":
return i18n._({
id: "email.digest.count.playlistFollows",
message: "{n, plural, one {playlist follow} other {playlist follows}}",
values: { n },
});
}
}
/**
* Returns the sentence of a highlight, `who` and `target` already formatted
* for the part it goes in (HTML or Markdown).
*/
function highlightSentence(
kind: HighlightKind,
who: string,
target: string,
): string {
const values = { who, target };
switch (kind) {
case "mentionComment":
return i18n._({
id: "email.digest.hl.mentionComment",
message: "{who} mentioned you in a comment on {target}",
values,
});
case "mentionDump":
case "mentionChat":
return i18n._({
id: "email.digest.hl.mentionIn",
message: "{who} mentioned you in {target}",
values,
});
case "mentionPlaylist":
return i18n._({
id: "email.digest.hl.mentionPlaylist",
message: "{who} mentioned you in the playlist {target}",
values,
});
case "comment":
return i18n._({
id: "email.digest.hl.comment",
message: "{who} commented on your dump {target}",
values,
});
case "playlistFollow":
return i18n._({
id: "email.digest.hl.playlistFollow",
message: "{who} followed your playlist {target}",
values,
});
case "userFollow":
return i18n._({
id: "email.digest.hl.userFollow",
message: "{who} started following you",
values,
});
case "commentLike":
return i18n._({
id: "email.digest.hl.commentLike",
message: "{who} liked your comment on {target}",
values,
});
}
}
const byLine = (username: string) =>
i18n._({
id: "email.digest.by",
message: "by @{username}",
values: { username },
});
const addedToLine = (playlist: string) =>
i18n._({
id: "email.digest.addedTo",
message: "added to {playlist}",
values: { playlist },
});
const uploadLabel = () =>
i18n._({ id: "email.digest.upload", message: "upload" });
const invitedLine = (inviter: string, reader: string) =>
inviter === reader
? i18n._({ id: "email.digest.invitedByYou", message: "invited by you" })
: i18n._({
id: "email.digest.invitedBy",
message: "invited by @{username}",
values: { username: inviter },
});
const andMoreLine = (n: number) =>
i18n._({
id: "email.digest.andMore",
message: "and {n} more",
values: { n },
});
const playlistMeta = (owner: string, n: number) =>
i18n._({
id: "email.digest.playlistMeta",
message: "by @{username} · {n, plural, one {# dump} other {# dumps}}",
values: { username: owner, n },
});
/**
* Returns the digest `content` describes, written as Markdown and drawn in
* `look`, with its subject and hidden preheader.
*/
export function renderDigestEmail(
content: DigestContent,
look: EmailLook,
): RenderedEmail {
const monthly = content.cadence === "monthly";
const nf = new Intl.NumberFormat(EMAIL_LOCALE);
const range = (year: boolean) =>
new Intl.DateTimeFormat(
EMAIL_LOCALE,
year
? { month: "short", day: "numeric", year: "numeric" }
: { month: "short", day: "numeric" },
).formatRange(content.from, content.to);
const day = (d: Date) =>
new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d);
const figure = (n: number, label: string) =>
`**${nf.format(n)}** ${escMd(label)}`;
const yours = i18n._({ id: "email.digest.yours", message: "yours" });
/** A dump as a list item: its thumbnail, title, pill and detail. */
const dumpItem = (d: DigestDump, bullet: string, image = true) =>
`${bullet} ${image && d.thumbnail ? `![](${d.thumbnail}) ` : ""}${
mdLink(d.title, d.url)
}${d.mine ? ` *${escMd(yours)}*` : ""} · ${
escMd(
`${d.addedTo ? addedToLine(d.addedTo) : byLine(d.poster)}${
d.addedTo ? "" : ` · ${d.domain ?? uploadLabel()}`
} · ${nf.format(d.votes)} ${countLabel("upvotes", d.votes)} · ${
nf.format(d.comments)
} ${countLabel("comments", d.comments)}`,
)
}`;
const md: string[] = [
`*${
escMd(
`${
monthly
? i18n._({
id: "digest.monthly",
message: "Monthly recap",
})
: i18n._({
id: "digest.weekly",
message: "Weekly digest",
})
} · ${range(true)}`,
)
}*`,
`# ${
escMd(
monthly
? i18n._({
id: "email.digest.monthly.title",
message: "The month on {site}",
values: { site: OG_SITE_NAME },
})
: i18n._({
id: "email.digest.weekly.title",
message: "Your week, {username}",
values: { username: content.reader },
}),
)
}`,
];
const { activity, network, community } = content;
if (activity) {
md.push(
`## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`,
ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0)
.map((k) =>
figure(activity.counts[k]!, countLabel(k, activity.counts[k]!))
)
.join(" · "),
activity.highlights.map((h) => {
const target = h.kind === "mentionChat"
? mdLink(
i18n._({ id: "email.digest.theChat", message: "the chat" }),
content.links.chat,
)
: h.target
? mdLink(h.target.title, h.target.url)
: "";
const line = `- ${
highlightSentence(h.kind, `**@${escMd(h.who)}**`, target)
} *${escMd(day(h.at))}*`;
return h.quote ? `${line}\n > ${escMd(h.quote)}` : line;
}).join("\n"),
`${
mdLink(
i18n._({
id: "email.digest.seeAll",
message:
"{n, plural, one {See the notification} other {See all # notifications}}",
values: { n: activity.total },
}),
content.links.notifications,
)
} →`,
);
}
if (network) {
md.push(
`## ${
escMd(i18n._({
id: "email.digest.network",
message: "From people you follow",
}))
}`,
network.dumps.map((d) => dumpItem(d, "-")).join("\n"),
);
if (network.more > 0) {
md.push(`${
mdLink(
i18n._({
id: "email.digest.more",
message: "{n} more on your Followed feed",
values: { n: network.more },
}),
content.links.followed,
)
} →`);
}
}
if (content.top.length > 0) {
const [first] = content.top;
md.push(
`## ${
escMd(
monthly
? i18n._({ id: "email.digest.topMonth", message: "Top this month" })
: i18n._({ id: "email.digest.topWeek", message: "Top this week" }),
)
}`,
);
// The recap gives its first dump a wide image and the next two a thumbnail.
if (monthly && first.thumbnail) md.push(`![](${first.thumbnail})`);
md.push(
content.top.map((d, i) =>
dumpItem(d, `${i + 1}.`, monthly ? i === 1 || i === 2 : true)
).join("\n"),
);
}
if (community) {
const { dumps, upvotes, comments } = community.totals;
md.push(
`## ${
escMd(i18n._({ id: "email.digest.community", message: "Community" }))
}`,
);
if (monthly) {
md.push(
[
figure(
dumps,
i18n._({
id: "email.digest.stat.dumps",
message: "{n, plural, one {dump} other {dumps}}",
values: { n: dumps },
}),
),
figure(upvotes, countLabel("upvotes", upvotes)),
figure(comments, countLabel("comments", comments)),
figure(
community.chatMessages,
i18n._({
id: "email.digest.stat.chat",
message: "{n, plural, one {chat message} other {chat messages}}",
values: { n: community.chatMessages },
}),
),
].join(" · "),
);
}
if (community.members.length > 0) {
md.push(
`### ${
escMd(
i18n._({ id: "email.digest.newMembers", message: "New members" }),
)
}`,
[
...community.members.map((m) =>
`- **@${escMd(m.username)}**${
m.invitedBy
? ` ${escMd(invitedLine(m.invitedBy, content.reader))}`
: ""
}`
),
...(community.membersMore > 0
? [`- ${escMd(andMoreLine(community.membersMore))}`]
: []),
].join("\n"),
);
}
if (community.playlists.length > 0) {
md.push(
`### ${
escMd(i18n._({
id: "email.digest.newPlaylists",
message: "New public playlists",
}))
}`,
[
...community.playlists.map((p) =>
`- ${mdLink(p.title, p.url)} · ${
escMd(playlistMeta(p.owner, p.dumps))
}`
),
...(community.playlistsMore > 0
? [`- ${escMd(andMoreLine(community.playlistsMore))}`]
: []),
].join("\n"),
);
}
if (!monthly && community.chatMessages > 0) {
md.push(
`### ${
escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" }))
}`,
`${
escMd(i18n._({
id: "email.digest.chatWeek",
message:
"{n, plural, one {# message this week} other {# messages this week}}",
values: { n: community.chatMessages },
}))
} · ${
mdLink(
i18n._({ id: "email.digest.openChat", message: "Open the chat" }),
content.links.chat,
)
}`,
);
}
if (!monthly && dumps + upvotes + comments > 0) {
md.push(`*${
escMd(i18n._({
id: "email.digest.totals",
message:
"{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.",
values: { dumps, upvotes, comments },
}))
}*`);
}
}
md.push(
"---",
escMd(
monthly
? i18n._({
id: "email.digest.monthly.reason",
message:
"You're getting the monthly recap because it's on in your settings.",
})
: i18n._({
id: "email.digest.weekly.reason",
message:
"You're getting the weekly digest because it's on in your settings.",
}),
),
`${
mdLink(
monthly
? i18n._({
id: "digest.monthly.off",
message: "Turn off monthly recaps",
})
: i18n._({
id: "digest.weekly.off",
message: "Turn off weekly digests",
}),
content.links.unsubscribe,
)
} · ${
mdLink(
i18n._({ id: "email.settings", message: "Settings" }),
content.links.settings,
)
}`,
);
return new EmailKit(look).render({
subject: monthly
? i18n._({
id: "email.digest.monthly.subject",
message: "The month on {site}, {range}",
values: { site: OG_SITE_NAME, range: range(false) },
})
: i18n._({
id: "email.digest.weekly.subject",
message: "Your week on {site}, {range}",
values: { site: OG_SITE_NAME, range: range(false) },
}),
preheader: preheader(content),
markdown: md.join("\n\n"),
});
}
/**
* Returns the line an inbox shows beside the subject: the counts that make
* the digest worth opening, at most three of them.
*/
function preheader(content: DigestContent): string {
const parts: string[] = [];
if (content.cadence === "weekly") {
for (const k of ["upvotes", "followers", "comments"] as const) {
const n = content.activity?.counts[k] ?? 0;
if (n > 0) parts.push(`${n} ${countLabel(k, n)}`);
}
const n = (content.network?.dumps.length ?? 0) +
(content.network?.more ?? 0);
if (n > 0) {
parts.push(i18n._({
id: "email.digest.pre.network",
message:
"{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}",
values: { n },
}));
}
} else {
const members = (content.community?.members.length ?? 0) +
(content.community?.membersMore ?? 0);
const playlists = (content.community?.playlists.length ?? 0) +
(content.community?.playlistsMore ?? 0);
if (content.top.length > 0) {
parts.push(i18n._({
id: "email.digest.pre.topMonth",
message:
"{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}",
values: { n: content.top.length },
}));
}
if (members > 0) {
parts.push(i18n._({
id: "email.digest.pre.members",
message: "{n, plural, one {# new member} other {# new members}}",
values: { n: members },
}));
}
if (playlists > 0) {
parts.push(i18n._({
id: "email.digest.pre.playlists",
message: "{n, plural, one {# new playlist} other {# new playlists}}",
values: { n: playlists },
}));
}
}
if (parts.length === 0 && content.top.length > 0) {
parts.push(i18n._({
id: "email.digest.pre.topWeek",
message:
"{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}",
values: { n: content.top.length },
}));
}
if (parts.length === 0) return "";
const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" })
.format(parts.slice(0, 3));
return `${line.charAt(0).toUpperCase()}${line.slice(1)}.`;
}

33
api/mail/i18n.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Lets the emails speak the instance's language from the same catalogs as
* the interface. Their messages are written in `api/mail/` as
* `i18n._({ id, message }, values)`, which `lingui extract` collects into
* `locales/<lang>.po` beside the interface's and `lingui compile` turns into
* the `locales/<lang>.mjs` imported here. The ids are spelled out, since the
* server runs no Lingui macro to hash them.
*
* @module
*/
import { type I18n, type Messages, setupI18n } from "@lingui/core";
import { EMAIL_LOCALE } from "../config.ts";
import { LOCALES } from "../../shared/locale.ts";
const catalogs: [string, Messages][] = await Promise.all(
LOCALES.map(async (locale) =>
[
locale,
(await import(`../../locales/${locale}.mjs`)).messages as Messages,
] as [string, Messages]
),
);
/**
* Translates the emails' messages, in {@link EMAIL_LOCALE}. Every catalog is
* loaded, so a test can activate another language.
*/
export const i18n: I18n = setupI18n({
locale: EMAIL_LOCALE,
messages: Object.fromEntries(catalogs),
});

View File

@@ -13,8 +13,20 @@
* @module
*/
import { type EmailLocale, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import type { ColorScheme, StyleName } from "../../shared/theme.ts";
import { Marked, Parser, type Token, type Tokens } from "marked";
import {
DEFAULT_STYLE,
EMAIL_LOCALE,
OG_SITE_NAME,
SITE_EMOJI,
} from "../config.ts";
import {
type ColorScheme,
DEFAULT_COLOR_SCHEME,
type StyleName,
} from "../../shared/theme.ts";
import type { UserSettings } from "../../shared/settings.ts";
import {
EMAIL_STYLES,
type EmailPalette,
@@ -29,19 +41,36 @@ import {
export interface RenderedEmail {
subject: string;
html: string;
/** The plain-text part, for clients that show no HTML. */
/**
* The plain-text part, for clients that show no HTML. It is Markdown, so it
* reads as text and renders as the email's outline.
*/
text: string;
}
/**
* Passed to {@link EmailKit}. The instance's style draws the emails that
* aren't a user's own; a digest takes its reader's settings.
* Passed to {@link EmailKit}, made by {@link lookOf}: the instance's style for
* the emails that aren't a user's own, their settings for a digest.
*/
export interface EmailLook {
style: StyleName;
scheme: ColorScheme;
}
/**
* Returns the look of a reader's settings. An unset style is the instance's,
* as on the site, and an unset scheme the system's; with no settings at all,
* as for the welcome and reset emails, both are.
*/
export function lookOf(
settings: Pick<UserSettings, "style" | "colorScheme"> | null = null,
): EmailLook {
return {
style: settings?.style ?? DEFAULT_STYLE,
scheme: settings?.colorScheme ?? DEFAULT_COLOR_SCHEME,
};
}
/**
* Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes
* it, so that the dark block can recolor the element by the same name.
@@ -79,14 +108,31 @@ export function esc(value: string): string {
);
}
/**
* Escapes `value` for Markdown, so that user text in a plain-text part (a
* title with `*` or `[`, say) stays text.
*/
export function escMd(value: string): string {
return value.replace(/[\\`*_[\]<>#!|~]/g, "\\$&");
}
/** Returns a Markdown link to `url` labelled with `text`, escaped. */
export function mdLink(text: string, url: string): string {
const safeUrl = url.replace(
/[()\s]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
);
return `[${escMd(text)}](${safeUrl})`;
}
const gradient = (colors: string[]) =>
`linear-gradient(90deg,${colors.join(",")})`;
/**
* Draws the blocks of an email in one {@link EmailLook}. A template creates
* one, builds its body with the block methods, and wraps it with
* {@link EmailKit.document}. The blocks return HTML: text passed to them must
* already be escaped with {@link esc}.
* Draws an email in one {@link EmailLook}. A template writes the email as
* Markdown and hands it to {@link EmailKit.render}, which draws each block in
* the style. The block methods return HTML: text passed to them must already
* be escaped with {@link esc}.
*/
export class EmailKit {
/** The style being drawn. */
@@ -98,10 +144,12 @@ export class EmailKit {
/** The font stack, for templates that set their own blocks. */
readonly font: string;
constructor(look: EmailLook, readonly locale: EmailLocale) {
constructor(look: EmailLook) {
this.style = EMAIL_STYLES[look.style];
this.palette = look.scheme === "dark" ? this.style.dark : this.style.light;
this.darkPalette = look.scheme === "auto" ? this.style.dark : null;
this.darkPalette = look.scheme === DEFAULT_COLOR_SCHEME
? this.style.dark
: null;
this.font = this.style.font;
}
@@ -140,7 +188,9 @@ export class EmailKit {
style += `border-color:${p[roles.bd] ?? p.rule};`;
}
const cls = classes.length ? ` class="${classes.join(" ")}"` : "";
return `${cls} style="${style}${css}"`;
// The colors come last: a shorthand such as `border-top` in `css` would
// otherwise reset them.
return `${cls} style="${css}${style}"`;
}
/** Returns a link to `href` around `html`, in the link color. */
@@ -156,21 +206,6 @@ export class EmailKit {
}>${html}</a>`;
}
/** Returns `@username`, set off as a name. */
name(username: string): string {
return `<b${
this.paint(
{ fg: "text" },
`font-weight:${Math.max(600, this.style.heavy)};`,
)
}>@${esc(username)}</b>`;
}
/** Returns `html` in the muted color, inline. */
muted(html: string): string {
return `<span${this.paint({ fg: "muted" })}>${html}</span>`;
}
/** Returns the email's heading, in the style's title type. */
heading(html: string, marginBottom = 24): string {
const t = this.style.title;
@@ -281,30 +316,6 @@ export class EmailKit {
}>${html}</div>`;
}
/** Returns `lines`, one per line, in the secondary color. */
lines(lines: string[]): string {
return `<div${
this.paint(
{ fg: "sec" },
`font-family:${this.font};font-size:${
this.fs(14)
};line-height:1.7;margin:0 0 18px;`,
)
}>${lines.join("<br>")}</div>`;
}
/** Returns a block set off from what precedes it by a hairline. */
aside(html: string): string {
return `<div${
this.paint(
{ fg: "muted", bd: "rule" },
`border-top:1px solid;font-family:${this.font};font-size:${
this.fs(13.5)
};line-height:1.6;padding:16px 0 14px;`,
)
}>${html}</div>`;
}
/**
* Returns a section of the card: a divider in the style's rule (none before
* the first), an uppercase title in the accent color, and `html`.
@@ -343,16 +354,327 @@ export class EmailKit {
}
/**
* Returns the whole HTML document: the brand, the card with the optional
* `eyebrow` and `title` over `body`, and the `footer`. `preheader` is the
* hidden first line inboxes show beside the subject.
* Returns the email written as `markdown`: the Markdown itself is the
* plain-text part, and its blocks, drawn in this kit's style, are the HTML.
* Each template writes its email once this way. The blocks map as follows.
*
* - `# Title` is the heading; an `*emphasised*` paragraph above it, the
* eyebrow. Elsewhere an emphasised paragraph is a note.
* - `## Section` starts a section, with its divider; `### Name` a subhead.
* - A link alone in a paragraph is a button, an image alone a wide image,
* and a fenced block a box for a raw link.
* - A list item whose text starts with an image, then a link, is a row
* with that thumbnail and the link as its title; what follows is its
* detail, and an emphasised word right after the link a pill. Other items
* are sentences, where emphasis marks the date. A numbered list ranks its
* rows, and a quote under an item is set as one.
* - A bold number is a figure.
* - What follows the last `---` is the footer.
*/
document(
{ subject, preheader, eyebrow, title, body, footer }: {
render(
{ subject, preheader, markdown }: {
subject: string;
preheader: string;
markdown: string;
},
): RenderedEmail {
const cut = markdown.lastIndexOf("\n---\n");
const body = cut === -1 ? markdown : markdown.slice(0, cut);
const foot = cut === -1 ? "" : markdown.slice(cut + 5);
return {
subject,
html: this.document({
subject,
preheader,
body: this.blocks(this.marked.lexer(body)),
footer: this.marked.lexer(foot)
.filter((t): t is Tokens.Paragraph => t.type === "paragraph")
.map((t) => this.inline(t.tokens, "footer")).join("<br>"),
}),
text: markdown,
};
}
/** How {@link inline} draws emphasis and links, by where they sit. */
private mode: "text" | "footer" | "row" | "sentence" = "text";
/** Draws Markdown's inline tokens with the kit's colors. */
private readonly marked: Marked = (() => {
// deno-lint-ignore no-this-alias
const kit = this;
return new Marked({
renderer: {
link({ href, tokens }) {
const html = this.parser.parseInline(tokens);
return kit.mode === "footer"
? `<a href="${esc(href)}"${
kit.paint({ fg: "muted" }, "text-decoration:underline;")
}>${html}</a>`
: kit.link(html, href);
},
strong({ tokens }) {
const html = this.parser.parseInline(tokens);
const figure = /^[\d\s.,  ]+$/.test(html);
return `<b${
kit.paint(
{ fg: "text" },
figure
? `font-size:${kit.fs(22)};font-weight:${kit.style.heavy};`
: `font-weight:${Math.max(600, kit.style.heavy)};`,
)
}>${html}</b>`;
},
em({ tokens }) {
const html = this.parser.parseInline(tokens);
if (kit.mode === "row") return kit.pill(html);
if (kit.mode === "sentence") {
return `<span${
kit.paint(
{ fg: kit.palette.date ? "date" : "muted" },
`font-size:${kit.fs(12.5)};white-space:nowrap;${
kit.palette.date ? "font-style:italic;" : ""
}`,
)
}>${html}</span>`;
}
return `<i>${html}</i>`;
},
codespan({ text }) {
return `<code style="font-family:Menlo,Consolas,'Courier New',monospace;">${text}</code>`;
},
image({ href, text }) {
return kit.image(href, text, 104, 78);
},
},
});
})();
private inline(tokens: Token[], mode: EmailKit["mode"]): string {
this.mode = mode;
try {
return Parser.parseInline(tokens, this.marked.defaults);
} finally {
this.mode = "text";
}
}
/** Draws the body's blocks, gathering each `##` section with its content. */
private blocks(tokens: Token[]): string {
const out: string[] = [];
let section: { title: string; parts: string[] } | null = null;
let first = true;
let headed = false;
const push = (html: string) =>
section ? section.parts.push(html) : out.push(html);
const close = () => {
if (!section) return;
out.push(this.section(section.title, section.parts.join(""), first));
first = false;
section = null;
};
for (const token of tokens) {
switch (token.type) {
case "heading": {
const html = this.inline(token.tokens ?? [], "text");
if (token.depth === 1) {
headed = true;
push(this.heading(html));
} else if (token.depth === 2) {
close();
section = { title: html, parts: [] };
} else {
push(this.subhead(html));
}
break;
}
case "paragraph": {
const kids = (token.tokens ?? []).filter((t) =>
!(t.type === "text" && !t.raw.trim())
);
const only = kids.length === 1 ? kids[0] : null;
if (only?.type === "link") {
push(
this.button(this.inline(only.tokens ?? [], "text"), only.href),
);
} else if (only?.type === "image") {
push(
`${
this.image(only.href, only.text, 528, 264)
}<div style="height:14px;line-height:14px;font-size:0;">&nbsp;</div>`,
);
} else if (only?.type === "em") {
const html = this.inline(only.tokens ?? [], "text");
push(headed ? this.note(html) : this.eyebrow(html));
} else {
push(this.para(this.inline(token.tokens ?? [], "text")));
}
break;
}
case "list":
push(this.list(token as Tokens.List));
break;
case "blockquote":
push(this.quote(this.inline(
(token.tokens ?? []).flatMap((t) =>
"tokens" in t ? t.tokens ?? [] : []
),
"text",
)));
break;
case "code":
push(this.linkBox(esc(token.text)));
break;
case "html":
push(token.text);
break;
}
}
close();
return out.join("");
}
/** Draws a list as rows, ranked when it is numbered. */
private list(list: Tokens.List): string {
const start = typeof list.start === "number" ? list.start : 1;
return list.items.map((item, i) => {
const text = item.tokens.find((t) =>
t.type === "text" || t.type === "paragraph"
) as Tokens.Text | Tokens.Paragraph | undefined;
const quote = item.tokens.find((t) => t.type === "blockquote") as
| Tokens.Blockquote
| undefined;
let rest: Token[] = [...(text?.tokens ?? [])];
const skipSpace = () => {
while (rest[0]?.type === "text" && !rest[0].raw.trim()) {
rest = rest.slice(1);
}
};
skipSpace();
const image = rest[0]?.type === "image"
? rest.shift() as Tokens.Image
: null;
skipSpace();
const link = rest[0]?.type === "link"
? rest.shift() as Tokens.Link
: null;
let content: string;
if (link) {
const pills: Token[] = [];
skipSpace();
while (rest[0]?.type === "em") {
pills.push(rest.shift()!);
skipSpace();
}
// The detail follows the title after a " · ".
if (rest[0]?.type === "text") {
const t = rest[0] as Tokens.Text;
const trimmed = t.text.replace(/^\s*·\s*/, "");
rest[0] = { ...t, text: trimmed, raw: trimmed, tokens: undefined };
}
content = `<a href="${esc(link.href)}"${
this.paint(
{ fg: "text" },
`font-size:${
this.fs(15)
};line-height:1.35;font-weight:${this.style.heavy};text-decoration:none;`,
)
}>${this.inline(link.tokens, "text")}</a>${this.inline(pills, "row")}${
rest.length
? `<div${
this.paint(
{ fg: "muted" },
`font-size:${this.fs(13)};line-height:1.5;margin-top:3px;`,
)
}>${this.inline(rest, "text")}</div>`
: ""
}`;
} else {
content = `<div${
this.paint(
{ fg: "sec" },
`font-size:${this.fs(14.5)};line-height:1.5;`,
)
}>${this.inline(rest, "sentence")}</div>`;
}
if (quote) {
content += this.quote(this.inline(
quote.tokens.flatMap((t) => "tokens" in t ? t.tokens ?? [] : []),
"text",
));
}
const size = list.ordered ? [64, 48] : [104, 78];
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 ${
link || image ? 16 : 10
}px;"><tr>${
list.ordered
? `<td width="30" valign="top"${
this.paint(
{ fg: "accent" },
`font-family:${this.font};font-size:${this.fs(20)};font-weight:${
Math.max(600, this.style.heavy)
};line-height:1.2;`,
)
}>${start + i}</td>`
: ""
}${
image
? `<td width="${size[0]}" valign="top" style="padding-right:${
list.ordered ? 12 : 14
}px;">${
this.image(image.href, image.text, size[0], size[1], link?.href)
}</td>`
: ""
}<td valign="top" style="font-family:${this.font};">${content}</td></tr></table>`;
}).join("");
}
/** Returns the small uppercase line above the heading. */
private eyebrow(html: string): string {
return `<div${
this.paint(
{ fg: "muted" },
`font-family:${this.font};font-size:${
this.fs(12)
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
)
}>${html}</div>`;
}
/** Returns a quoted comment or message. */
private quote(html: string): string {
return `<div${
this.paint(
{ fg: "quote" },
`font-family:${this.font};font-size:${
this.fs(14.5)
};line-height:1.5;font-style:italic;margin:4px 0 0;`,
)
}>“${html}”</div>`;
}
/** Returns a raw link set in a box, for clients that break buttons. */
private linkBox(html: string): string {
return `<div${
this.paint(
{ fg: "sec", bg: "soft" },
`font-family:Menlo,Consolas,'Courier New',monospace;font-size:12px;line-height:1.5;border-radius:${
Math.min(this.style.radius.img, 6)
}px;padding:10px 12px;margin:0 0 22px;word-break:break-all;`,
)
}>${html}</div>`;
}
/**
* Returns the whole HTML document: the brand, the card holding `body`, and
* the `footer`. `preheader` is the hidden first line inboxes show beside the
* subject.
*/
private document(
{ subject, preheader, body, footer }: {
subject: string;
preheader: string;
eyebrow?: string;
title?: string;
body: string;
footer: string;
},
@@ -390,7 +712,7 @@ export class EmailKit {
: "light";
return `<!doctype html>
<html lang="${this.locale}">
<html lang="${EMAIL_LOCALE}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -453,19 +775,6 @@ ${
};${frame}padding:34px 36px 12px;`,
)
}>
${
eyebrow
? `<div${
this.paint(
{ fg: "muted" },
`font-family:${this.font};font-size:${
this.fs(12)
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
)
}>${eyebrow}</div>`
: ""
}
${title ? this.heading(title) : ""}
${body}
</td></tr>
<tr><td${
@@ -483,19 +792,6 @@ ${body}
</html>`;
}
/**
* Returns a footer: `reason` (why the reader gets this email), then
* `links`, each `[label, href]`, joined on one line.
*/
footer(reason: string, links: [string, string][] = []): string {
const linkHtml = links.map(([label, href]) =>
`<a href="${esc(href)}"${
this.paint({ fg: "muted" }, "text-decoration:underline;")
}>${label}</a>`
).join(" · ");
return `${reason}${linkHtml ? `<br>${linkHtml}` : ""}`;
}
/**
* Returns the CSS that recolors every painted element in dark mode, or
* nothing off the "auto" scheme.

View File

@@ -6,9 +6,15 @@
* @module
*/
import { DEFAULT_STYLE, EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
import { EmailKit, esc, type RenderedEmail } from "./layout.ts";
import { STRINGS } from "./strings.ts";
import { OG_SITE_NAME } from "../config.ts";
import { i18n } from "./i18n.ts";
import {
EmailKit,
escMd,
lookOf,
mdLink,
type RenderedEmail,
} from "./layout.ts";
/**
* Returns the reset email of `username`, whose button opens `resetUrl`. The
@@ -19,49 +25,60 @@ export function renderPasswordResetEmail(
resetUrl: string,
hours: number,
): RenderedEmail {
const t = STRINGS[EMAIL_LOCALE].reset;
const kit = new EmailKit(
{ style: DEFAULT_STYLE, scheme: "auto" },
EMAIL_LOCALE,
);
const subject = t.subject(OG_SITE_NAME);
const urlBox = `<div${
kit.paint(
{ fg: "sec", bg: "soft" },
`font-family:Menlo,Consolas,'Courier New',monospace;font-size:12px;line-height:1.5;border-radius:${
Math.min(kit.style.radius.img, 6)
}px;padding:10px 12px;margin:0 0 22px;word-break:break-all;`,
)
}>${esc(resetUrl)}</div>`;
const body = kit.para(esc(t.hi(username))) +
kit.para(esc(t.body(OG_SITE_NAME, hours))) +
kit.button(esc(t.button), resetUrl) +
kit.note(esc(t.fallback), "margin-bottom:8px;") +
urlBox +
kit.note(esc(t.ignore));
return {
subject,
html: kit.document({
subject,
preheader: t.preheader(hours),
title: esc(t.title),
body,
footer: kit.footer(esc(t.reason)),
}),
text: [
t.title,
"",
t.hi(username),
"",
t.body(OG_SITE_NAME, hours),
"",
const markdown = [
`# ${
escMd(i18n._({ id: "email.reset.title", message: "Reset your password" }))
}`,
escMd(i18n._({
id: "email.reset.hi",
message: "Hi {username},",
values: { username },
})),
escMd(i18n._({
id: "email.reset.body",
message:
"Someone asked to reset the password of your {site} account. The button below lets you choose a new one. It works once, for {hours, plural, one {# hour} other {# hours}}.",
values: { site: OG_SITE_NAME, hours },
})),
mdLink(
i18n._({ id: "email.reset.button", message: "Reset password" }),
resetUrl,
"",
t.ignore,
"",
"--",
t.reason,
].join("\n"),
};
),
`*${
escMd(i18n._({
id: "email.reset.fallback",
message:
"If the button doesn't work, paste this link into your browser:",
}))
}*`,
"```\n" + resetUrl + "\n```",
`*${
escMd(i18n._({
id: "email.reset.ignore",
message:
"If you didn't ask for this, ignore this email. Your password stays the same.",
}))
}*`,
"---",
escMd(i18n._({
id: "email.reset.reason",
message:
"You're getting this email because a password reset was requested for this address.",
})),
].join("\n\n");
return new EmailKit(lookOf()).render({
subject: i18n._({
id: "email.reset.subject",
message: "Reset your {site} password",
values: { site: OG_SITE_NAME },
}),
preheader: i18n._({
id: "email.reset.preheader",
message:
"The link works once, for {hours, plural, one {# hour} other {# hours}}.",
values: { hours },
}),
markdown,
});
}

View File

@@ -1,108 +0,0 @@
/**
* The words of every email, in each language an instance can pick with
* `GERBEUR_EMAIL_LOCALE`. The templates beside this module read
* `STRINGS[EMAIL_LOCALE]`. French follows the interface's own words (a dump is
* a "reco", a playlist a "collection") and puts a no-break space before
* `: ! ?`.
*
* @module
*/
import type { EmailLocale } from "../config.ts";
/** A no-break space, which French typography puts before `: ! ?`. */
const NB = " ";
/**
* Read by the templates for one language. A function takes what varies, the
* site's name first; none escapes what it is given.
*/
export interface MailStrings {
/** The footer's link to the settings. */
settings: string;
welcome: {
subject: (site: string) => string;
preheader: string;
/** The Markdown body, unless `GERBEUR_WELCOME_EMAIL_BODY` replaces it. */
body: string;
button: (site: string) => string;
reason: (site: string) => string;
};
reset: {
subject: (site: string) => string;
preheader: (hours: number) => string;
title: string;
hi: (username: string) => string;
body: (site: string, hours: number) => string;
button: string;
fallback: string;
ignore: string;
reason: string;
};
}
/** Every email's words, by language. */
export const STRINGS: Record<EmailLocale, MailStrings> = {
en: {
settings: "Settings",
welcome: {
subject: (site) => `Welcome to ${site}`,
preheader: "Your account is ready.",
body:
"# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!",
button: (site) => `Open ${site}`,
reason: (site) =>
`You're getting this email because an account was created on ${site} with this address.`,
},
reset: {
subject: (site) => `Reset your ${site} password`,
preheader: (hours) =>
`The link works once, for ${hours} ${hours === 1 ? "hour" : "hours"}.`,
title: "Reset your password",
hi: (username) => `Hi ${username},`,
body: (site, hours) =>
`Someone asked to reset the password of your ${site} account. The button below lets you choose a new one. It works once, for ${hours} ${
hours === 1 ? "hour" : "hours"
}.`,
button: "Reset password",
fallback:
"If the button doesn't work, paste this link into your browser:",
ignore:
"If you didn't ask for this, ignore this email. Your password stays the same.",
reason:
"You're getting this email because a password reset was requested for this address.",
},
},
fr: {
settings: "Paramètres",
welcome: {
subject: (site) => `Bienvenue sur ${site}`,
preheader: "Votre compte est prêt.",
body:
`# Bienvenue sur {{site_name}}${NB}!\n\nBonjour **{{username}}**,\n\nVotre compte a bien été créé. Bienvenue à bord${NB}!`,
button: (site) => `Ouvrir ${site}`,
reason: (site) =>
`Vous recevez cet e-mail car un compte ${site} a été créé avec cette adresse.`,
},
reset: {
subject: (site) => `Réinitialisez votre mot de passe ${site}`,
preheader: (hours) =>
`Le lien ne fonctionne qu'une fois, pendant ${hours}${NB}${
hours < 2 ? "heure" : "heures"
}.`,
title: "Réinitialiser votre mot de passe",
hi: (username) => `Bonjour ${username},`,
body: (site, hours) =>
`Quelqu'un a demandé à réinitialiser le mot de passe de votre compte ${site}. Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant ${hours}${NB}${
hours < 2 ? "heure" : "heures"
}.`,
button: "Choisir un nouveau mot de passe",
fallback:
`Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur${NB}:`,
ignore:
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.",
reason:
"Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.",
},
},
};

View File

@@ -2,8 +2,9 @@
* Carries each site style into email, so a digest looks like the site its
* reader chose and the other emails like the instance's style. A mail client
* takes no stylesheet of ours, so each style is spelled out here: its palettes,
* copied from its theme under `src/themes/`, and the few touches that make it
* recognisable once flattened into inline styles. `layout.ts` reads them.
* copied from its theme under `src/themes/` and flattened to plain colors, and
* the few touches that make it recognisable once inlined. `layout.ts` reads
* them. A test fails when a copied color no longer matches its theme's.
*
* @module
*/
@@ -227,10 +228,10 @@ export const EMAIL_STYLES: Record<StyleName, EmailStyle> = {
outer: "#ffffff",
card: "#ffffff",
text: "#121212",
sec: "#363636",
sec: "#5a5a5a",
muted: "#767676",
accent: "#121212",
link: "#121212",
link: "#1a1a1a",
onAccent: "#ffffff",
rule: "#e2e2e2",
soft: "#f3f3f3",
@@ -240,7 +241,7 @@ export const EMAIL_STYLES: Record<StyleName, EmailStyle> = {
outer: "#121212",
card: "#1e1e1e",
text: "#e6e6e6",
sec: "#c4c4c4",
sec: "#ababab",
muted: "#8a8a8a",
accent: "#ececec",
link: "#ececec",

View File

@@ -1,78 +1,76 @@
/**
* Renders the email that greets a new account. `sendWelcomeEmail`
* (`user-service.ts`) sends it after sign-up, in the instance's style and
* language. Its body is Markdown: the instance's `GERBEUR_WELCOME_EMAIL_BODY`,
* or the built-in one.
* language. It opens with the instance's `GERBEUR_WELCOME_EMAIL_BODY`, or the
* built-in body, then says the digests are on, since they are by default, and
* where to turn them off.
*
* @module
*/
import { Marked } from "marked";
import { FRONTEND_URL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
import { i18n } from "./i18n.ts";
import {
DEFAULT_STYLE,
EMAIL_LOCALE,
FRONTEND_URL,
OG_SITE_NAME,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { EmailKit, esc, type RenderedEmail } from "./layout.ts";
import { STRINGS } from "./strings.ts";
EmailKit,
escMd,
lookOf,
mdLink,
type RenderedEmail,
} from "./layout.ts";
/**
* Returns a Markdown renderer whose blocks are `kit`'s, so the body takes the
* email's style. Raw HTML in the Markdown passes through: the body comes from
* whoever runs the instance, never from a user.
*/
function markdownIn(kit: EmailKit): Marked {
return new Marked({
renderer: {
heading({ tokens, depth }) {
const html = this.parser.parseInline(tokens);
return depth === 1 ? kit.heading(html, 20) : kit.subhead(html);
},
paragraph({ tokens }) {
return kit.para(this.parser.parseInline(tokens));
},
link({ href, tokens }) {
return kit.link(this.parser.parseInline(tokens), href);
},
strong({ tokens }) {
return `<strong style="font-weight:${
Math.max(600, kit.style.heavy)
};">${this.parser.parseInline(tokens)}</strong>`;
},
},
});
}
/**
* Returns the welcome email of `username`, with `{{username}}` and
* `{{site_name}}` filled into the body.
* Returns the welcome email of `username`. The instance's body has
* `{{username}}` and `{{site_name}}` filled in; raw HTML in it passes
* through, since it comes from whoever runs the instance, never from a user.
*/
export function renderWelcomeEmail(username: string): RenderedEmail {
const t = STRINGS[EMAIL_LOCALE].welcome;
const kit = new EmailKit(
{ style: DEFAULT_STYLE, scheme: "auto" },
EMAIL_LOCALE,
);
const markdown = (WELCOME_EMAIL_BODY ?? t.body)
.replaceAll("{{username}}", username)
.replaceAll("{{site_name}}", OG_SITE_NAME);
const subject = t.subject(OG_SITE_NAME);
const body = markdownIn(kit).parse(markdown, { async: false }) +
kit.button(esc(t.button(OG_SITE_NAME)), FRONTEND_URL);
// A username may hold a `_`, which Markdown would read as emphasis.
const body = WELCOME_EMAIL_BODY
? WELCOME_EMAIL_BODY
.replaceAll("{{username}}", escMd(username))
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
: i18n._({
id: "email.welcome.body",
message:
"# Welcome to {site}!\n\nHi **{username}**,\n\nYour account has been created successfully. Welcome aboard!",
values: { site: escMd(OG_SITE_NAME), username: escMd(username) },
});
const markdown = [
body,
mdLink(
i18n._({
id: "email.welcome.button",
message: "Open {site}",
values: { site: OG_SITE_NAME },
}),
FRONTEND_URL,
),
`*${
escMd(i18n._({
id: "email.welcome.digests",
message:
"Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings.",
}))
}*`,
"---",
escMd(i18n._({
id: "email.welcome.reason",
message:
"You're getting this email because an account was created on {site} with this address.",
values: { site: OG_SITE_NAME },
})),
].join("\n\n");
return {
subject,
html: kit.document({
subject,
preheader: t.preheader,
body,
footer: kit.footer(esc(t.reason(OG_SITE_NAME))),
return new EmailKit(lookOf()).render({
subject: i18n._({
id: "email.welcome.subject",
message: "Welcome to {site}",
values: { site: OG_SITE_NAME },
}),
text: `${markdown}\n\n${t.button(OG_SITE_NAME)}: ${FRONTEND_URL}\n\n--\n${
t.reason(OG_SITE_NAME)
}`,
};
preheader: i18n._({
id: "email.welcome.preheader",
message: "Your account is ready.",
}),
markdown,
});
}

View File

@@ -1,6 +1,6 @@
/**
* Builds the Oak application and, when run directly, listens on
* {@link LISTEN_HOST}:{@link PORT}. A request goes through the error
* Builds the Oak application and, when run directly, starts the digests'
* scheduler and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes through the error
* middleware, the OG tags and CORS, then the routers, and last the built
* frontend, which answers every path no router did.
*
@@ -33,6 +33,7 @@ import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
import { startDigestScheduler } from "./services/digest-service.ts";
/**
* The whole server, exported so the tests' helpers can send it requests
@@ -142,6 +143,7 @@ app.addEventListener(
);
if (import.meta.main) {
startDigestScheduler();
await app.listen({ hostname: LISTEN_HOST, port: PORT });
}

View File

@@ -26,6 +26,17 @@ export interface UserSettingsRow {
[key: string]: SQLOutputValue;
}
/**
* Names the column of each setting, for the queries that pick users by one,
* as `digest-service.ts` does by its digest's switch.
*/
export const SETTINGS_COLUMNS: Record<keyof UserSettings, string> = {
style: "style",
colorScheme: "color_scheme",
digestWeekly: "digest_weekly",
digestMonthly: "digest_monthly",
};
/**
* Checks whether `obj`, a row `getSettings` selected, is a
* {@link UserSettingsRow}.

View File

@@ -22,6 +22,10 @@
* - `GET /api/users/me/settings` — signed in. Answers the caller's settings.
* - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's
* settings, and answers them all.
* - `POST /api/users/unsubscribe?token=` — public, with a digest's unsubscribe
* token. Turns that digest off and answers its cadence. Mail clients post
* here for a one-click unsubscribe (RFC 8058), and so does the client's
* `/unsubscribe` page.
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
* user's role.
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
@@ -69,7 +73,11 @@ import {
import type { Playlist } from "../../shared/playlist.ts";
import { toPublicUser, toSelfUser, type User } from "../model/user.ts";
import { createJWT, createMediaToken } from "../lib/jwt.ts";
import {
createJWT,
createMediaToken,
verifyUnsubscribeToken,
} from "../lib/jwt.ts";
import { hashPassword, verifyPassword } from "../lib/password.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import {
@@ -94,7 +102,11 @@ import {
resetPassword,
} from "../services/password-reset-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
import type { UserSettings } from "../../shared/settings.ts";
import {
DIGEST_SETTING,
type DigestCadence,
type UserSettings,
} from "../../shared/settings.ts";
import { isUpdateSettingsRequest } from "../validators/settings.ts";
import { getSettings, updateSettings } from "../services/settings-service.ts";
import { broadcastUserUpdated } from "../services/ws-service.ts";
@@ -327,6 +339,22 @@ router.patch("/me/settings", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
// The body is ignored: a mail client's one-click post sends a form saying
// "List-Unsubscribe=One-Click", the client's page sends nothing.
router.post("/unsubscribe", async (ctx) => {
const token = ctx.request.url.searchParams.get("token") ?? "";
const payload = await verifyUnsubscribeToken(token);
if (!payload) throw new APIException(400, "Invalid unsubscribe link");
// Throws a 404 once the account is gone.
getUserById(payload.userId);
updateSettings(payload.userId, { [DIGEST_SETTING[payload.cadence]]: false });
const responseBody: APIResponse<{ cadence: DigestCadence }> = {
success: true,
data: { cadence: payload.cadence },
};
ctx.response.body = responseBody;
});
router.patch(
"/:userId/role",
authMiddleware,

View File

@@ -0,0 +1,602 @@
/**
* Sends each user their weekly digest and monthly recap, unless they turned
* it off. `main.ts` starts {@link startDigestScheduler}, which every quarter
* of an hour sends the digests that are due; `digest_schedule` remembers, for
* each user and cadence, the start of the period their next one covers.
*
* A period is 7 days or 30, rolling from when the user's schedule began, so
* the digests spread over the week instead of leaving together. A digest
* covers the period just ended, or the last full period when the server was
* down longer than one; a digest with nothing to show isn't sent, and either
* way the next period begins.
*
* @module
*/
import { db, transaction } from "../db/connection.ts";
import { FRONTEND_URL, PUBLIC_URL } from "../config.ts";
import {
DEFAULT_SETTINGS,
DIGEST_CADENCES,
DIGEST_SETTING,
type DigestCadence,
} from "../../shared/settings.ts";
import { SETTINGS_COLUMNS } from "../model/settings.ts";
import type { NotificationType } from "../../shared/notification.ts";
import {
type ActivityKind,
type DigestContent,
type DigestDump,
type DigestHighlight,
renderDigestEmail,
} from "../mail/digest.ts";
import { lookOf } from "../mail/layout.ts";
import { createUnsubscribeToken } from "../lib/jwt.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { getSettings } from "./settings-service.ts";
import { dumpFileVersion } from "./dump-service.ts";
const DAY_MS = 86_400_000;
/** How long each cadence's period lasts. */
export const PERIOD_MS: Record<DigestCadence, number> = {
weekly: 7 * DAY_MS,
monthly: 30 * DAY_MS,
};
const TICK_MS = 15 * 60_000;
// What one digest shows at most, as the drafts settled it.
const HIGHLIGHTS_MAX = 5;
const NETWORK_MAX = 6;
const TOP_MAX: Record<DigestCadence, number> = { weekly: 5, monthly: 10 };
const MEMBERS_MAX = 10;
const PLAYLISTS_MAX = 5;
const QUOTE_MAX = 140;
const COUNTED: Partial<Record<NotificationType, ActivityKind>> = {
dump_upvoted: "upvotes",
user_followed: "followers",
dump_commented: "comments",
user_mentioned: "mentions",
comment_liked: "likes",
playlist_followed: "playlistFollows",
};
/** A period as the ISO strings dates are stored in, for comparing as text. */
interface Window {
from: string;
to: string;
}
const dumpUrl = (row: { id: string; slug: string | null }) =>
`${FRONTEND_URL}/dumps/${encodeURIComponent(row.slug ?? row.id)}`;
const playlistUrl = (row: { id: string; slug: string | null }) =>
`${FRONTEND_URL}/playlists/${encodeURIComponent(row.slug ?? row.id)}`;
/** Flattens Markdown into a short line of text for a quote. */
function quoteOf(markdown: string | undefined | null): string | undefined {
if (!markdown) return undefined;
const flat = markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[`*_>#~]/g, "")
.replace(/\s+/g, " ")
.trim();
if (!flat) return undefined;
return flat.length > QUOTE_MAX
? `${flat.slice(0, QUOTE_MAX - 1).trimEnd()}…`
: flat;
}
/** The columns {@link toDigestDump} reads, selected from `dumps d`. */
const DUMP_COLS = `d.id, d.slug, d.title, d.kind, d.url, d.user_id,
d.created_at, d.updated_at, d.file_mime, d.file_size, d.rich_content,
d.custom_thumbnail_mime, d.vote_count, u.username AS poster,
(SELECT COUNT(*) FROM comments c WHERE c.dump_id = d.id AND c.deleted = 0) AS comment_count`;
interface DumpRow {
id: string;
slug: string | null;
title: string;
kind: string;
url: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
file_mime: string | null;
file_size: number | null;
rich_content: string | null;
custom_thumbnail_mime: string | null;
vote_count: number;
poster: string;
comment_count: number;
}
/**
* Returns the image a mail client can load for `row`, which must be public:
* the thumbnails route for a custom thumbnail or an image or video upload,
* else the link preview's image, through the image proxy when it is plain
* http, which a mail client may refuse.
*/
function thumbnailOf(row: DumpRow): string | null {
const isMedia = row.kind === "file" &&
/^(image|video)\//.test(row.file_mime ?? "");
if (row.custom_thumbnail_mime || isMedia) {
const version = dumpFileVersion({
fileSize: row.file_size ?? undefined,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
});
return `${PUBLIC_URL}/api/thumbnails/${row.id}?v=${version}`;
}
let thumbnailUrl: unknown;
try {
thumbnailUrl = row.rich_content
? JSON.parse(row.rich_content)?.thumbnailUrl
: undefined;
} catch {
return null;
}
if (typeof thumbnailUrl !== "string") return null;
if (thumbnailUrl.startsWith("https://")) return thumbnailUrl;
if (thumbnailUrl.startsWith("http://")) {
return `${PUBLIC_URL}/api/proxy-image?url=${
encodeURIComponent(thumbnailUrl)
}`;
}
return null;
}
function toDigestDump(row: DumpRow, readerId: string): DigestDump {
let domain: string | null = null;
if (row.kind === "url" && row.url) {
try {
domain = new URL(row.url).hostname.replace(/^www\./, "");
} catch {
domain = null;
}
}
return {
title: row.title,
url: dumpUrl(row),
poster: row.poster,
domain,
thumbnail: thumbnailOf(row),
votes: row.vote_count,
comments: row.comment_count,
mine: row.user_id === readerId,
};
}
/**
* Returns what happened to the reader: their notifications of the period,
* counted by kind, and the few worth reading, mentions and comments first.
*/
function activityOf(
readerId: string,
w: Window,
): DigestContent["activity"] {
const byType = db.prepare(
`SELECT type, COUNT(*) AS n FROM notifications
WHERE user_id = ? AND created_at >= ? AND created_at < ?
GROUP BY type;`,
).all(readerId, w.from, w.to) as { type: NotificationType; n: number }[];
const counts: Partial<Record<ActivityKind, number>> = {};
let total = 0;
for (const { type, n } of byType) {
total += n;
const kind = COUNTED[type];
if (kind) counts[kind] = n;
}
const rows = db.prepare(
`SELECT type, data, created_at FROM notifications
WHERE user_id = ? AND created_at >= ? AND created_at < ?
AND type IN ('user_mentioned', 'dump_commented', 'playlist_followed',
'user_followed', 'comment_liked')
ORDER BY CASE type
WHEN 'user_mentioned' THEN 0 WHEN 'dump_commented' THEN 0
WHEN 'comment_liked' THEN 2 ELSE 1 END,
created_at DESC
LIMIT ?;`,
).all(readerId, w.from, w.to, HIGHLIGHTS_MAX) as {
type: NotificationType;
data: string;
created_at: string;
}[];
const highlights = rows.flatMap((row) => {
try {
const h = highlightOf(row.type, JSON.parse(row.data));
return h ? [{ ...h, at: new Date(row.created_at) }] : [];
} catch {
return [];
}
});
if (Object.keys(counts).length === 0 && highlights.length === 0) return null;
return { counts, highlights, total };
}
const commentBody = (id: string) =>
(db.prepare(`SELECT body FROM comments WHERE id = ? AND deleted = 0;`)
.get(id) as { body: string } | undefined)?.body;
const dumpTarget = (id: string, fallbackTitle: string) => {
const row = db.prepare(`SELECT id, slug, title FROM dumps WHERE id = ?;`)
.get(id) as { id: string; slug: string | null; title: string } | undefined;
return row ? { title: row.title, url: dumpUrl(row) } : {
title: fallbackTitle,
url: `${FRONTEND_URL}/dumps/${encodeURIComponent(id)}`,
};
};
const playlistTarget = (id: string, fallbackTitle: string) => {
const row = db.prepare(`SELECT id, slug, title FROM playlists WHERE id = ?;`)
.get(id) as { id: string; slug: string | null; title: string } | undefined;
return row ? { title: row.title, url: playlistUrl(row) } : {
title: fallbackTitle,
url: `${FRONTEND_URL}/playlists/${encodeURIComponent(id)}`,
};
};
/** Returns the highlight a notification makes, or null for another kind. */
function highlightOf(
type: NotificationType,
// deno-lint-ignore no-explicit-any
data: any,
): Omit<DigestHighlight, "at"> | null {
switch (type) {
case "user_mentioned":
switch (data.contextType) {
case "comment":
return {
kind: "mentionComment",
who: data.mentionerUsername,
target: dumpTarget(data.dumpId, data.contextTitle),
quote: quoteOf(commentBody(data.contextId)),
};
case "dump":
return {
kind: "mentionDump",
who: data.mentionerUsername,
target: dumpTarget(data.contextId, data.contextTitle),
};
case "playlist":
return {
kind: "mentionPlaylist",
who: data.mentionerUsername,
target: playlistTarget(data.contextId, data.contextTitle),
};
case "chat":
return {
kind: "mentionChat",
who: data.mentionerUsername,
quote: quoteOf(
(db.prepare(`SELECT body FROM chat_messages WHERE id = ?;`)
.get(data.contextId) as { body: string } | undefined)?.body,
),
};
}
return null;
case "dump_commented":
return {
kind: "comment",
who: data.commenterUsername,
target: dumpTarget(data.dumpId, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)),
};
case "playlist_followed":
return {
kind: "playlistFollow",
who: data.followerUsername,
target: playlistTarget(data.playlistId, data.playlistTitle),
};
case "user_followed":
return { kind: "userFollow", who: data.followerUsername };
case "comment_liked":
return {
kind: "commentLike",
who: data.likerUsername,
target: dumpTarget(data.dumpId, data.dumpTitle),
quote: quoteOf(commentBody(data.commentId)),
};
}
return null;
}
/**
* Returns the public dumps of the period from the users and playlists the
* reader follows, the most upvoted first, without their own.
*/
function networkOf(readerId: string, w: Window): DigestContent["network"] {
const rows = db.prepare(
`SELECT ${DUMP_COLS}, NULL AS playlist_title, d.created_at AS at
FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0 AND d.user_id != :reader
AND d.created_at >= :from AND d.created_at < :to
AND d.user_id IN (SELECT followed_user_id FROM follows
WHERE follower_id = :reader AND followed_user_id IS NOT NULL)
UNION ALL
SELECT ${DUMP_COLS}, p.title AS playlist_title, pd.added_at AS at
FROM playlist_dumps pd
JOIN playlists p ON p.id = pd.playlist_id
JOIN dumps d ON d.id = pd.dump_id
JOIN users u ON u.id = d.user_id
WHERE p.is_public = 1 AND d.is_private = 0 AND d.user_id != :reader
AND pd.added_at >= :from AND pd.added_at < :to
AND pd.playlist_id IN (SELECT followed_playlist_id FROM follows
WHERE follower_id = :reader AND followed_playlist_id IS NOT NULL);`,
).all({ reader: readerId, from: w.from, to: w.to }) as unknown as (DumpRow & {
playlist_title: string | null;
at: string;
})[];
// A dump both posted by someone followed and added to a followed playlist
// is listed once, as the post.
const byId = new Map<string, (typeof rows)[number]>();
for (const row of rows) {
const seen = byId.get(row.id);
if (!seen || (seen.playlist_title && !row.playlist_title)) {
byId.set(row.id, row);
}
}
const sorted = [...byId.values()].sort((a, b) =>
b.vote_count - a.vote_count || b.at.localeCompare(a.at)
);
if (sorted.length === 0) return null;
return {
dumps: sorted.slice(0, NETWORK_MAX).map((row) => ({
...toDigestDump(row, readerId),
...(row.playlist_title ? { addedTo: row.playlist_title } : {}),
})),
more: Math.max(0, sorted.length - NETWORK_MAX),
};
}
/**
* Returns the period's most upvoted public dumps among those posted in it,
* the newer first on a tie, as the "hot" tab would.
*/
function topOf(
readerId: string,
cadence: DigestCadence,
w: Window,
): DigestDump[] {
const rows = db.prepare(
`SELECT ${DUMP_COLS} FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0 AND d.vote_count > 0
AND d.created_at >= ? AND d.created_at < ?
ORDER BY d.vote_count DESC, d.created_at DESC
LIMIT ?;`,
).all(w.from, w.to, TOP_MAX[cadence]) as unknown as DumpRow[];
return rows.map((row) => toDigestDump(row, readerId));
}
const countOf = (sql: string, ...params: string[]) =>
(db.prepare(sql).get(...params) as { n: number }).n;
/**
* Returns what the whole site did in the period: who joined, the new public
* playlists, the chat's activity and the totals.
*/
function communityOf(readerId: string, w: Window): DigestContent["community"] {
const members = db.prepare(
`SELECT u.username, i.username AS invited_by
FROM users u LEFT JOIN users i ON i.id = u.invited_by
WHERE u.created_at >= ? AND u.created_at < ? AND u.id != ?
ORDER BY u.created_at;`,
).all(w.from, w.to, readerId) as {
username: string;
invited_by: string | null;
}[];
const playlists = db.prepare(
`SELECT p.id, p.slug, p.title, u.username AS owner,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) AS dumps
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
ORDER BY dumps DESC, p.created_at DESC;`,
).all(w.from, w.to) as {
id: string;
slug: string | null;
title: string;
owner: string;
dumps: number;
}[];
const chatMessages = countOf(
`SELECT COUNT(*) AS n FROM chat_messages WHERE created_at >= ? AND created_at < ?;`,
w.from,
w.to,
);
const totals = {
dumps: countOf(
`SELECT COUNT(*) AS n FROM dumps WHERE is_private = 0 AND created_at >= ? AND created_at < ?;`,
w.from,
w.to,
),
upvotes: countOf(
`SELECT COUNT(*) AS n FROM votes WHERE created_at >= ? AND created_at < ?;`,
w.from,
w.to,
),
comments: countOf(
`SELECT COUNT(*) AS n FROM comments WHERE deleted = 0 AND created_at >= ? AND created_at < ?;`,
w.from,
w.to,
),
};
if (
members.length + playlists.length + chatMessages + totals.dumps +
totals.upvotes + totals.comments === 0
) return null;
return {
members: members.slice(0, MEMBERS_MAX).map((m) => ({
username: m.username,
invitedBy: m.invited_by,
})),
membersMore: Math.max(0, members.length - MEMBERS_MAX),
playlists: playlists.slice(0, PLAYLISTS_MAX).map((p) => ({
title: p.title,
url: playlistUrl(p),
owner: p.owner,
dumps: p.dumps,
})),
playlistsMore: Math.max(0, playlists.length - PLAYLISTS_MAX),
chatMessages,
totals,
};
}
/**
* Returns the digest of user `readerId` for the period from `from` to `to`,
* or null when it would show nothing. The links are absolute; the
* unsubscribe one carries `token`, which turns off `cadence`.
*/
export function buildDigest(
readerId: string,
reader: string,
cadence: DigestCadence,
from: Date,
to: Date,
token: string,
): DigestContent | null {
const w = { from: from.toISOString(), to: to.toISOString() };
const weekly = cadence === "weekly";
const activity = weekly ? activityOf(readerId, w) : null;
const network = weekly ? networkOf(readerId, w) : null;
const top = topOf(readerId, cadence, w);
const community = communityOf(readerId, w);
if (!activity && !network && top.length === 0 && !community) return null;
return {
cadence,
from,
to,
reader,
activity,
network,
top,
community,
links: {
notifications: `${FRONTEND_URL}/notifications`,
followed: `${FRONTEND_URL}/~/followed`,
chat: `${FRONTEND_URL}/?chat=open`,
settings: `${FRONTEND_URL}/users/${
encodeURIComponent(reader)
}/~/settings`,
unsubscribe: `${FRONTEND_URL}/unsubscribe?token=${
encodeURIComponent(token)
}`,
},
};
}
/**
* Brings `digest_schedule` in line with the settings: a user who turned a
* cadence off loses its row, and one who receives it without a row, being
* new or having turned it back on, starts a period at `now`.
*/
export function syncDigestSchedules(now: Date): void {
transaction(() => {
for (const cadence of DIGEST_CADENCES) {
const setting = DIGEST_SETTING[cadence];
const column = SETTINGS_COLUMNS[setting];
const byDefault = DEFAULT_SETTINGS[setting] ? 1 : 0;
db.prepare(
`DELETE FROM digest_schedule WHERE cadence = ? AND user_id IN
(SELECT user_id FROM user_settings WHERE ${column} = 0);`,
).run(cadence);
db.prepare(
`INSERT INTO digest_schedule (user_id, cadence, window_start)
SELECT u.id, ?, ? FROM users u
LEFT JOIN user_settings s ON s.user_id = u.id
WHERE COALESCE(s.${column}, ${byDefault}) = 1
AND NOT EXISTS (SELECT 1 FROM digest_schedule ds
WHERE ds.user_id = u.id AND ds.cadence = ?);`,
).run(cadence, now.toISOString(), cadence);
}
});
}
/**
* Sends every digest due at `now`, one after the other, and returns how many
* went out. A digest that fails to send is logged and not retried: its
* period is over either way, and the next one starts.
*/
export async function sendDueDigests(now: Date = new Date()): Promise<number> {
if (!isEmailEnabled()) return 0;
syncDigestSchedules(now);
let sent = 0;
for (const cadence of DIGEST_CADENCES) {
const period = PERIOD_MS[cadence];
const due = db.prepare(
`SELECT ds.user_id, ds.window_start, u.username, u.email
FROM digest_schedule ds JOIN users u ON u.id = ds.user_id
WHERE ds.cadence = ? AND ds.window_start <= ?;`,
).all(cadence, new Date(now.getTime() - period).toISOString()) as {
user_id: string;
window_start: string;
username: string;
email: string;
}[];
for (const row of due) {
let from = new Date(row.window_start);
let to = new Date(from.getTime() + period);
// More than a period late (the server was down): cover the last full
// period rather than one long gone.
if (to.getTime() + period <= now.getTime()) {
from = new Date(now.getTime() - period);
to = now;
}
db.prepare(
`UPDATE digest_schedule SET window_start = ? WHERE user_id = ? AND cadence = ?;`,
).run(to.toISOString(), row.user_id, cadence);
// An address with no dot in its domain, such as the first admin's
// admin@localhost, can't receive mail.
if (!/@[^@]+\.[^@]+$/.test(row.email)) continue;
const token = await createUnsubscribeToken(row.user_id, cadence);
const content = buildDigest(
row.user_id,
row.username,
cadence,
from,
to,
token,
);
if (!content) continue;
const email = renderDigestEmail(
content,
lookOf(getSettings(row.user_id)),
);
try {
await sendEmail({
to: row.email,
...email,
headers: {
"List-Unsubscribe": `<${PUBLIC_URL}/api/users/unsubscribe?token=${
encodeURIComponent(token)
}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});
sent++;
} catch (err) {
console.error(`[digest] ${cadence} to ${row.username} failed:`, err);
}
}
}
return sent;
}
/**
* Sends the due digests a minute after the server starts, then every quarter
* of an hour. A run still sending when the next is due makes that one skip.
* `main.ts` calls it when run directly, never in the tests.
*/
export function startDigestScheduler(): void {
let running = false;
const tick = () => {
if (running) return;
running = true;
sendDueDigests()
.then((n) => n > 0 && console.log(`[digest] sent ${n}`))
.catch((err) => console.error("[digest] run failed:", err))
.finally(() => running = false);
};
setTimeout(tick, 60_000);
setInterval(tick, TICK_MS);
}

View File

@@ -13,10 +13,10 @@
import nodemailer from "nodemailer";
import { FROM_EMAIL, SMTPS_URL } from "../config.ts";
import { EMAIL_ENABLED, FROM_EMAIL, SMTPS_URL } from "../config.ts";
import type { RenderedEmail } from "../mail/layout.ts";
if (SMTPS_URL && !FROM_EMAIL) {
if (EMAIL_ENABLED && !FROM_EMAIL) {
throw new Error(
"GERBEUR_FROM_EMAIL environment variable is required when GERBEUR_SMTPS_URL is set.",
);
@@ -39,7 +39,7 @@ export interface EmailTransport {
sendMail(message: OutgoingEmail & { from: string }): Promise<unknown>;
}
let transport: EmailTransport | null = SMTPS_URL
let transport: EmailTransport | null = EMAIL_ENABLED
? nodemailer.createTransport(SMTPS_URL)
: null;

View File

@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
.get() as {
n: number;
};
assertEquals(applied.n, 19);
assertEquals(applied.n, 20);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
19,
20,
);
db.close();
});

View File

@@ -0,0 +1,35 @@
import "../../setup.ts";
import { assert, assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0020 } from "../../../db/migrations/0020_digest_schedule.ts";
Deno.test("migration 0020 spreads existing users' first digests over the coming period", () => {
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
const ids = Array.from({ length: 40 }, () => crypto.randomUUID());
for (const id of ids) {
db.prepare(`INSERT INTO users (id) VALUES (?);`).run(id);
}
const before = Date.now();
up0020(db);
up0020(db); // idempotent
const rows = db.prepare(
`SELECT user_id, cadence, window_start FROM digest_schedule;`,
).all() as { user_id: string; cadence: string; window_start: string }[];
assertEquals(rows.length, 80);
const due = (cadence: string, days: number) =>
rows.filter((r) => r.cadence === cadence).map((r) =>
new Date(r.window_start).getTime() + days * 86_400_000
);
for (const [cadence, days] of [["weekly", 7], ["monthly", 30]] as const) {
const dues = due(cadence, days);
// Each first digest falls due within the coming period, not all at once.
assert(
dues.every((t) =>
t >= before - 1000 && t <= Date.now() + days * 86_400_000
),
);
assert(new Set(dues.map((t) => Math.floor(t / 3_600_000))).size > 10);
}
db.close();
});

View File

@@ -35,6 +35,7 @@ const TABLES = [
"attachments",
"chat_messages",
"user_settings",
"digest_schedule",
"categories",
"playlists",
"dumps",

View File

@@ -5,9 +5,11 @@ import {
createJWT,
createMediaToken,
createPasswordResetToken,
createUnsubscribeToken,
verifyJWT,
verifyMediaToken,
verifyPasswordResetToken,
verifyUnsubscribeToken,
} from "../../lib/jwt.ts";
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
@@ -60,3 +62,24 @@ Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async
assertEquals(await verifyJWT("not.a.jwt"), null);
});
Deno.test("an unsubscribe token names its user and cadence, and is nothing else", async () => {
const userId = crypto.randomUUID();
const token = await createUnsubscribeToken(userId, "monthly");
assertEquals(await verifyUnsubscribeToken(token), {
userId,
cadence: "monthly",
});
assertEquals(await verifyJWT(token), null);
assertEquals(await verifyMediaToken(token), null);
assertEquals(
await verifyUnsubscribeToken(await createMediaToken(userId)),
null,
);
const yearly = await new SignJWT({
purpose: "unsubscribe",
userId,
cadence: "yearly",
}).setProtectedHeader({ alg: "HS256" }).sign(KEY);
assertEquals(await verifyUnsubscribeToken(yearly), null);
});

View File

@@ -0,0 +1,104 @@
import "../setup.ts";
import { assert, assertFalse, assertStringIncludes } from "@std/assert";
import { type DigestContent, renderDigestEmail } from "../../mail/digest.ts";
const links = {
notifications: "https://g.test/notifications",
followed: "https://g.test/~/followed",
chat: "https://g.test/?chat=open",
settings: "https://g.test/users/ann/~/settings",
unsubscribe: "https://g.test/unsubscribe?token=t",
};
const dump = (title: string, over = {}) => ({
title,
url: "https://g.test/dumps/x",
poster: "bob",
domain: "example.com",
thumbnail: "https://g.test/api/thumbnails/x?v=1",
votes: 1,
comments: 0,
mine: false,
...over,
});
Deno.test("renderDigestEmail: the weekly digest escapes what users wrote and links to unsubscribe", () => {
const content: DigestContent = {
cadence: "weekly",
from: new Date("2026-09-27T08:00:00Z"),
to: new Date("2026-10-04T08:00:00Z"),
reader: "ann",
activity: {
counts: { upvotes: 12, followers: 1 },
highlights: [{
kind: "comment",
who: "bob",
target: { title: "<script>x</script>", url: "https://g.test/dumps/y" },
quote: "a <b>bold</b> claim",
at: new Date("2026-09-30T10:00:00Z"),
}],
total: 13,
},
network: { dumps: [dump("Net & work")], more: 2 },
top: [dump("Mine", { mine: true, votes: 4 })],
community: null,
links,
};
const email = renderDigestEmail(content, { style: "smooth", scheme: "auto" });
assertStringIncludes(email.subject, "Your week on gerbeur, Sep 27");
assertFalse(email.html.includes("<script>x"));
assertStringIncludes(email.html, "&lt;script&gt;x&lt;/script&gt;");
assertStringIncludes(email.html, "a &lt;b&gt;bold&lt;/b&gt; claim");
assertStringIncludes(email.html, "Net &amp; work");
assertStringIncludes(email.html, "2 more on your Followed feed");
assertStringIncludes(email.html, ">yours</span>");
assertStringIncludes(email.html, `href="${links.unsubscribe}"`);
assertStringIncludes(
email.html,
"12 upvotes, 1 new follower, and 3 new dumps",
);
// The plain-text part is Markdown, with what users wrote escaped.
assertStringIncludes(email.text, "# Your week, ann");
assertStringIncludes(email.text, "## From people you follow");
assertStringIncludes(
email.text,
"[Turn off weekly digests](https://g.test/unsubscribe?token=t)",
);
assertStringIncludes(
email.text,
"[\\<script\\>x\\</script\\>](https://g.test/dumps/y)",
);
assertStringIncludes(email.text, " > a \\<b\\>bold\\</b\\> claim");
assertFalse(email.html.includes("Community"));
});
Deno.test("renderDigestEmail: the monthly recap leads with a wide image and the month's totals", () => {
const content: DigestContent = {
cadence: "monthly",
from: new Date("2026-09-04T08:00:00Z"),
to: new Date("2026-10-04T08:00:00Z"),
reader: "ann",
activity: null,
network: null,
top: [dump("First"), dump("Second"), dump("Third"), dump("Fourth")],
community: {
members: [{ username: "cy", invitedBy: "ann" }],
membersMore: 0,
playlists: [],
playlistsMore: 0,
chatMessages: 1284,
totals: { dumps: 203, upvotes: 911, comments: 402 },
},
links,
};
const email = renderDigestEmail(content, {
style: "melange",
scheme: "light",
});
assertStringIncludes(email.subject, "The month on gerbeur");
assertStringIncludes(email.html, `width="528"`);
assertStringIncludes(email.html, "1,284");
assertStringIncludes(email.html, "invited by you");
assertStringIncludes(email.html, "Turn off monthly recaps");
// Only #2 and #3 get a thumbnail beside the hero.
assert(email.html.match(/<img[^>]*width="64"/g)?.length === 2);
});

View File

@@ -10,13 +10,12 @@ import { EMAIL_STYLES } from "../../mail/styles.ts";
import { STYLE_NAMES } from "../../../shared/theme.ts";
const doc = (kit: EmailKit) =>
kit.document({
kit.render({
subject: "S <1>",
preheader: "P & Q",
title: "T",
body: kit.para("body"),
footer: kit.footer("why", [["Settings", "https://x.test/?a=1&b=2"]]),
});
markdown:
"# T\n\nbody\n\n## One\n\nfirst\n\n## Two\n\nsecond\n\n---\n\nwhy\n\n[Settings](https://x.test/?a=1&b=2)",
}).html;
Deno.test("esc: user text never becomes markup", () => {
assertEquals(
@@ -26,7 +25,7 @@ Deno.test("esc: user text never becomes markup", () => {
});
Deno.test("EmailKit: auto inlines light and recolors in a dark media block", () => {
const html = doc(new EmailKit({ style: "smooth", scheme: "auto" }, "en"));
const html = doc(new EmailKit({ style: "smooth", scheme: "auto" }));
const { light, dark } = EMAIL_STYLES.smooth;
assertStringIncludes(html, `background-color:${light.card}`);
assertStringIncludes(html, "@media (prefers-color-scheme:dark)");
@@ -44,36 +43,70 @@ Deno.test("EmailKit: auto inlines light and recolors in a dark media block", ()
Deno.test("EmailKit: a chosen scheme is the only one sent", () => {
const { light, dark } = EMAIL_STYLES.kanagawa;
const darkHtml = doc(
new EmailKit({ style: "kanagawa", scheme: "dark" }, "en"),
new EmailKit({ style: "kanagawa", scheme: "dark" }),
);
assertStringIncludes(darkHtml, `background-color:${dark.card}`);
assertFalse(darkHtml.includes(light.card));
assertFalse(darkHtml.includes("prefers-color-scheme"));
assertStringIncludes(darkHtml, `content="dark"`);
const lightHtml = doc(
new EmailKit({ style: "kanagawa", scheme: "light" }, "fr"),
new EmailKit({ style: "kanagawa", scheme: "light" }),
);
assertStringIncludes(lightHtml, `background-color:${light.card}`);
assertStringIncludes(lightHtml, `<html lang="fr">`);
assertFalse(lightHtml.includes("prefers-color-scheme"));
});
Deno.test("EmailKit: every style draws its own card and font", () => {
for (const name of STYLE_NAMES) {
const style = EMAIL_STYLES[name];
const html = doc(new EmailKit({ style: name, scheme: "light" }, "en"));
const html = doc(new EmailKit({ style: name, scheme: "light" }));
assertStringIncludes(html, `background-color:${style.light.card}`);
assertStringIncludes(html, `font-family:${style.font}`);
assertEquals(html.includes("fonts.googleapis.com"), !!style.webFont, name);
}
// The rainbow and the hard shadow are what GeoCities and Brutalist look like.
const geo = new EmailKit({ style: "geocities", scheme: "light" }, "en");
const geo = new EmailKit({ style: "geocities", scheme: "light" });
assertStringIncludes(
geo.section("A", "", false),
"linear-gradient(90deg,#ff004d",
);
const brutal = doc(
new EmailKit({ style: "brutalist", scheme: "light" }, "en"),
new EmailKit({ style: "brutalist", scheme: "light" }),
);
assert(brutal.includes("box-shadow:5px 5px 0"));
});
Deno.test("EmailKit.render: the Markdown is the text part, and its blocks take the style", () => {
const kit = new EmailKit({ style: "smooth", scheme: "light" });
const markdown = [
"*Eyebrow*",
"# Title",
"## Section",
"**12** upvotes",
"- **@lou** said hi *Fri*\n > a quote",
"1. ![](https://x.test/t.png) [A dump](https://x.test/d) *yours* · by @lou",
"[Do it](https://x.test/go)",
"```\nhttps://x.test/raw\n```",
"*A note.*",
"---",
"Why you get this.",
].join("\n\n");
const { html, text } = kit.render({ subject: "S", preheader: "P", markdown });
assertEquals(text, markdown);
const { light } = EMAIL_STYLES.smooth;
assertStringIncludes(html, "text-transform:uppercase;margin:0 0 8px;");
assertStringIncludes(html, ">Title</h1>");
assertStringIncludes(html, `font-size:22px;`);
assertStringIncludes(html, "“a quote”");
assertStringIncludes(html, `>1</td>`);
assertStringIncludes(html, `width="64" height="48"`);
assertStringIncludes(html, `>A dump</a>`);
assertStringIncludes(html, `>yours</span>`);
assertStringIncludes(html, `>by @lou</div>`);
assertStringIncludes(html, `background-color:${light.accent};`);
assertStringIncludes(html, ">Do it</a></td></tr></table>");
assertStringIncludes(html, "word-break:break-all;");
assertStringIncludes(html, "Why you get this.</td>");
// A divider takes its color after the shorthand that would reset it.
assertFalse(html.includes(`border-color:${light.rule};border-top`));
});

View File

@@ -0,0 +1,40 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { EMAIL_STYLES, type EmailPalette } from "../../mail/styles.ts";
import { STYLE_NAMES } from "../../../shared/theme.ts";
// The email palettes are copies of the themes' tokens, flattened for mail
// clients. The roles that map one to one must stay equal to the theme's.
const TOKENS: [keyof EmailPalette, string][] = [
["outer", "--color-bg"],
["card", "--color-surface"],
["text", "--color-text"],
["sec", "--color-text-secondary"],
["muted", "--color-text-muted"],
["accent", "--color-accent"],
["link", "--color-link"],
["onAccent", "--color-on-accent"],
];
Deno.test("email palettes keep their theme's colors", () => {
for (const name of STYLE_NAMES) {
const css = Deno.readTextFileSync(
new URL(`../../../src/themes/${name}.css`, import.meta.url),
);
for (const [role, token] of TOKENS) {
const match = css.match(
new RegExp(
`${token}:\\s*light-dark\\(\\s*(#[0-9a-f]+),\\s*(#[0-9a-f]+)\\s*\\)`,
"i",
),
);
if (!match) throw new Error(`${name} has no ${token}`);
const { light, dark } = EMAIL_STYLES[name];
assertEquals(
[light[role], dark[role]],
[match[1].toLowerCase(), match[2].toLowerCase()],
`${name} ${role} (${token})`,
);
}
}
});

View File

@@ -2,20 +2,22 @@ import "../setup.ts";
import { assertFalse, assertStringIncludes } from "@std/assert";
import { renderWelcomeEmail } from "../../mail/welcome.ts";
import { renderPasswordResetEmail } from "../../mail/password-reset.ts";
import { i18n } from "../../mail/i18n.ts";
Deno.test("renderWelcomeEmail: fills the built-in body, in both parts", () => {
const email = renderWelcomeEmail("newbie");
assertStringIncludes(email.subject, "Welcome to gerbeur");
assertStringIncludes(email.text, "Hi **newbie**");
assertFalse(email.text.includes("{{"));
assertStringIncludes(email.html, "newbie</strong>");
assertStringIncludes(email.html, "newbie</b>");
assertStringIncludes(email.html, "Welcome to gerbeur!</h1>");
});
Deno.test("renderPasswordResetEmail: the button and the raw link carry the URL", () => {
const url = "http://gerbeur.test/reset-password?token=a.b&x=<y>";
const email = renderPasswordResetEmail("bob", url, 1);
assertStringIncludes(email.text, url);
assertStringIncludes(email.text, "# Reset your password");
assertStringIncludes(email.text, `[Reset password](${url})`);
assertStringIncludes(email.text, "for 1 hour");
assertStringIncludes(
email.html,
@@ -23,3 +25,15 @@ Deno.test("renderPasswordResetEmail: the button and the raw link carry the URL",
);
assertFalse(email.html.includes("<y>"));
});
Deno.test("the emails speak the catalog's language", () => {
i18n.activate("fr");
try {
const email = renderPasswordResetEmail("bob", "http://g.test/r", 2);
assertStringIncludes(email.subject, "Réinitialisez votre mot de passe");
assertStringIncludes(email.text, "pendant 2 heures");
assertStringIncludes(renderWelcomeEmail("bob").text, "Bonjour **bob**");
} finally {
i18n.activate("en");
}
});

View File

@@ -479,7 +479,7 @@ test("request-password-reset: emails a link that resets the password", async ()
assertEquals(mail.sent.length, 1);
assertEquals(mail.sent[0].to, user.email);
const token = decodeURIComponent(
mail.sent[0].text.match(/token=([^\s]+)/)![1],
mail.sent[0].text.match(/token=([^\s)]+)/)![1],
);
const res = await post("/api/users/reset-password", {
json: { token, newPassword: "from-the-email" },

View File

@@ -0,0 +1,270 @@
import {
captureEmails,
createUser,
db,
insertComment,
insertDump,
insertPlaylist,
post,
test,
} from "../helpers.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import {
buildDigest,
PERIOD_MS,
sendDueDigests,
syncDigestSchedules,
} from "../../services/digest-service.ts";
import {
getSettings,
updateSettings,
} from "../../services/settings-service.ts";
const DAY = 86_400_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const week = () =>
[new Date(Date.now() - 7 * DAY), new Date(Date.now() + 60_000)] as const;
function notify(
userId: string,
type: string,
data: object,
createdAt = ago(DAY),
) {
db.prepare(
`INSERT INTO notifications (id, user_id, type, data, read, created_at) VALUES (?, ?, ?, ?, 0, ?);`,
).run(crypto.randomUUID(), userId, type, JSON.stringify(data), createdAt);
}
function follow(
followerId: string,
target: { user?: string; playlist?: string },
) {
db.prepare(
`INSERT INTO follows (id, follower_id, followed_user_id, followed_playlist_id, created_at) VALUES (?, ?, ?, ?, ?);`,
).run(
crypto.randomUUID(),
followerId,
target.user ?? null,
target.playlist ?? null,
ago(DAY),
);
}
function vote(dumpId: string, n: number) {
db.prepare(`UPDATE dumps SET vote_count = ? WHERE id = ?;`).run(n, dumpId);
}
function schedule(userId: string, cadence: string, windowStart: string) {
db.prepare(
`INSERT OR REPLACE INTO digest_schedule (user_id, cadence, window_start) VALUES (?, ?, ?);`,
).run(userId, cadence, windowStart);
}
const windowOf = (userId: string, cadence: string) =>
(db.prepare(
`SELECT window_start FROM digest_schedule WHERE user_id = ? AND cadence = ?;`,
).get(userId, cadence) as { window_start: string } | undefined)?.window_start;
test("buildDigest: the weekly digest gathers activity, network, top and community", async () => {
const reader = await createUser();
const friend = await createUser({ invitedBy: reader.id });
const stranger = await createUser();
const mine = insertDump({ userId: reader.id, title: "My dump" });
vote(mine.id, 3);
const commentId = insertComment({
dumpId: mine.id,
userId: friend.id,
body: "Loved **this** [link](https://x.test)",
});
notify(reader.id, "dump_commented", {
commenterId: friend.id,
commenterUsername: friend.username,
commentId,
dumpId: mine.id,
dumpTitle: "My dump",
});
notify(reader.id, "user_followed", {
followerId: friend.id,
followerUsername: friend.username,
});
for (let i = 0; i < 3; i++) {
notify(reader.id, "dump_upvoted", {
voterId: friend.id,
voterUsername: friend.username,
dumpId: mine.id,
dumpTitle: "My dump",
});
}
// Before the period: not counted.
notify(reader.id, "user_followed", { followerUsername: "old" }, ago(9 * DAY));
follow(reader.id, { user: friend.id });
const posted = insertDump({ userId: friend.id, title: "Friend's post" });
vote(posted.id, 5);
insertDump({ userId: friend.id, title: "Private", isPrivate: true });
insertDump({ userId: friend.id, title: "Too old", createdAt: ago(10 * DAY) });
const mix = insertPlaylist({ userId: stranger.id, title: "Mix" });
follow(reader.id, { playlist: mix.id });
const added = insertDump({ userId: stranger.id, title: "Added <b>" });
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`,
).run(mix.id, added.id, ago(DAY));
// Posted by someone followed and added to a followed playlist: listed once.
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 1, ?);`,
).run(mix.id, posted.id, ago(DAY));
const [from, to] = week();
const digest =
(buildDigest(reader.id, reader.username, "weekly", from, to, "tok"))!;
assertEquals(digest.activity?.counts, {
comments: 1,
followers: 1,
upvotes: 3,
});
assertEquals(digest.activity?.total, 5);
assertEquals(digest.activity?.highlights.map((h) => h.kind), [
"comment",
"userFollow",
]);
assertEquals(digest.activity?.highlights[0].quote, "Loved this link");
assertEquals(digest.network?.dumps.map((d) => [d.title, d.addedTo]), [
["Friend's post", undefined],
["Added <b>", "Mix"],
]);
assertEquals(digest.top.map((d) => [d.title, d.mine]), [
["Friend's post", false],
["My dump", true],
]);
assertEquals(
digest.community?.members.map((m) => [m.username, m.invitedBy]),
[
[friend.username, reader.username],
[stranger.username, null],
],
);
assertEquals(digest.community?.totals.dumps, 3);
assert(digest.links.unsubscribe.includes("/unsubscribe?token="));
});
test("buildDigest: the monthly recap has no personal sections, and nothing makes no digest", async () => {
const reader = await createUser({ createdAt: ago(40 * DAY) });
const [from, to] = [
new Date(Date.now() - 30 * DAY),
new Date(Date.now() + 60_000),
];
assertEquals(
buildDigest(reader.id, reader.username, "monthly", from, to, "tok"),
null,
);
const other = await createUser({ createdAt: ago(40 * DAY) });
notify(reader.id, "user_followed", { followerUsername: other.username });
const d = insertDump({ userId: other.id, title: "Hit" });
vote(d.id, 2);
insertDump({ userId: other.id, title: "No votes" });
const recap =
(buildDigest(reader.id, reader.username, "monthly", from, to, "tok"))!;
assertEquals(recap.activity, null);
assertEquals(recap.network, null);
assertEquals(recap.top.map((t) => t.title), ["Hit"]);
});
test("sendDueDigests: sends what's due in the reader's style, once, and moves the period on", async () => {
using mail = captureEmails();
const reader = await createUser({ createdAt: ago(20 * DAY) });
const other = await createUser({ createdAt: ago(20 * DAY) });
updateSettings(reader.id, { style: "geocities", colorScheme: "dark" });
const d = insertDump({
userId: other.id,
title: "Worth a look",
createdAt: ago(3 * DAY),
});
vote(d.id, 1);
const start = ago(PERIOD_MS.weekly + DAY);
schedule(reader.id, "weekly", start);
schedule(reader.id, "monthly", ago(DAY));
schedule(other.id, "weekly", ago(DAY));
schedule(other.id, "monthly", ago(DAY));
assertEquals(await sendDueDigests(), 1);
assertEquals(mail.sent.length, 1);
const email = mail.sent[0];
assertEquals(email.to, reader.email);
assert(email.subject.startsWith("Your week on gerbeur"));
assert(email.html.includes("Worth a look"));
assert(email.html.includes("#1b1b46"), "GeoCities' dark card");
assertFalse(email.html.includes("prefers-color-scheme"));
assertEquals(
email.headers?.["List-Unsubscribe-Post"],
"List-Unsubscribe=One-Click",
);
assertEquals(
windowOf(reader.id, "weekly"),
new Date(new Date(start).getTime() + PERIOD_MS.weekly).toISOString(),
);
// Nothing is due any more.
assertEquals(await sendDueDigests(), 0);
// The header's link turns the weekly digest off, in one click.
const link = email.headers!["List-Unsubscribe"].slice(1, -1);
const res = await post(new URL(link).pathname + new URL(link).search, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
assertEquals(res.status, 200);
assertEquals(getSettings(reader.id).digestWeekly, false);
assertEquals(getSettings(reader.id).digestMonthly, true);
syncDigestSchedules(new Date());
assertEquals(windowOf(reader.id, "weekly"), undefined);
});
test("sendDueDigests: an empty digest isn't sent, a late one covers the last period, and admin@localhost is skipped", async () => {
using mail = captureEmails();
const quiet = await createUser({ createdAt: ago(60 * DAY) });
schedule(quiet.id, "weekly", ago(PERIOD_MS.weekly + DAY));
assertEquals(await sendDueDigests(), 0);
assert(windowOf(quiet.id, "weekly")! > ago(2 * DAY));
const late = await createUser({ createdAt: ago(60 * DAY) });
const d = insertDump({ userId: quiet.id, title: "Recent" });
vote(d.id, 1);
schedule(late.id, "weekly", ago(5 * PERIOD_MS.weekly));
const local = await createUser({
email: "admin@localhost",
createdAt: ago(60 * DAY),
});
schedule(local.id, "weekly", ago(PERIOD_MS.weekly + DAY));
const before = Date.now();
// Quiet's next period has only begun.
assertEquals(await sendDueDigests(), 1);
assertEquals(mail.sent.map((m) => m.to), [late.email]);
// The late one's period is the last week, so the next starts now.
assert(new Date(windowOf(late.id, "weekly")!).getTime() >= before);
assert(windowOf(local.id, "weekly")! > ago(2 * DAY));
});
test("syncDigestSchedules: new users start now, a turned-off cadence loses its row", async () => {
const user = await createUser();
const now = new Date();
syncDigestSchedules(now);
assertEquals(windowOf(user.id, "weekly"), now.toISOString());
assertEquals(windowOf(user.id, "monthly"), now.toISOString());
updateSettings(user.id, { digestMonthly: false });
syncDigestSchedules(new Date(now.getTime() + 1000));
assertEquals(windowOf(user.id, "weekly"), now.toISOString());
assertEquals(windowOf(user.id, "monthly"), undefined);
});
test("unsubscribe: a bad or other kind of token is refused", async () => {
const user = await createUser();
assertEquals((await post("/api/users/unsubscribe?token=nope")).status, 400);
assertEquals(
(await post(`/api/users/unsubscribe?token=${user.token}`)).status,
400,
);
assertEquals((await post("/api/users/unsubscribe")).status, 400);
});

View File

@@ -12,8 +12,8 @@
"test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/",
"test:web": "deno run -A npm:vitest run",
"test:web:coverage": "deno run -A npm:vitest run --coverage",
"doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')",
"doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')"
"doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')",
"doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*')"
},
"exports": "./mod.ts",
"nodeModulesDir": "auto",
@@ -40,6 +40,7 @@
"@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1",
"nodemailer": "npm:nodemailer@^10.0.10",
"marked": "npm:marked@^18.0.14",
"@lingui/core": "npm:@lingui/core@6.8.0",
"@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2"
},
"allowScripts": ["npm:@swc/core@1.16.2"]

1
deno.lock generated
View File

@@ -3061,6 +3061,7 @@
"jsr:@std/assert@^1.0.19",
"jsr:@std/testing@^1.0.21",
"jsr:@tajpouria/cors@^1.2.1",
"npm:@lingui/core@6.8.0",
"npm:@resvg/resvg-wasm@^2.6.2",
"npm:marked@^18.0.14",
"npm:nodemailer@^10.0.10"

View File

@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist", "src/locales"]),
globalIgnores(["dist", "locales"]),
{
files: ["**/*.{ts,tsx}"],
extends: [

View File

@@ -1,16 +1,20 @@
import { formatter } from "@lingui/format-po";
import type { LinguiConfig } from "@lingui/conf";
import { LOCALES, SOURCE_LOCALE } from "./shared/locale.ts";
const config: LinguiConfig = {
locales: ["en", "fr"],
sourceLocale: "en",
locales: [...LOCALES],
sourceLocale: SOURCE_LOCALE,
catalogs: [
{
path: "src/locales/{locale}",
include: ["src"],
// One catalog per language for the whole app: the interface's messages
// and the emails' (api/mail/), which the server imports compiled.
path: "locales/{locale}",
include: ["src", "api/mail"],
},
],
format: formatter(),
compileNamespace: "es",
};
export default config;

1
locales/en.mjs Normal file

File diff suppressed because one or more lines are too long

View File

@@ -55,10 +55,105 @@ msgstr "{0} more online: {restNames}"
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} characters: letters, numbers, or underscores"
#. js-lingui-explicit-id
#: api/mail/digest.ts:514
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
#: src/pages/Search.tsx:120
msgid "{label} ({count})"
msgstr "{label} ({count})"
#. js-lingui-explicit-id
#: api/mail/digest.ts:238
msgid "email.digest.comments"
msgstr "{n, plural, one {# comment} other {# comments}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:498
msgid "email.digest.chatWeek"
msgstr "{n, plural, one {# message this week} other {# messages this week}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:591
msgid "email.digest.pre.network"
msgstr "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:612
msgid "email.digest.pre.members"
msgstr "{n, plural, one {# new member} other {# new members}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:619
msgid "email.digest.pre.playlists"
msgstr "{n, plural, one {# new playlist} other {# new playlists}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:232
msgid "email.digest.votes"
msgstr "{n, plural, one {# upvote} other {# upvotes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:442
msgid "email.digest.stat.chat"
msgstr "{n, plural, one {chat message} other {chat messages}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:149
msgid "email.digest.count.likes"
msgstr "{n, plural, one {comment like} other {comment likes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:137
msgid "email.digest.count.comments"
msgstr "{n, plural, one {comment} other {comments}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:432
msgid "email.digest.stat.dumps"
msgstr "{n, plural, one {dump} other {dumps}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:143
msgid "email.digest.count.mentions"
msgstr "{n, plural, one {mention} other {mentions}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:131
msgid "email.digest.count.followers"
msgstr "{n, plural, one {new follower} other {new followers}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:155
msgid "email.digest.count.playlistFollows"
msgstr "{n, plural, one {playlist follow} other {playlist follows}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:364
msgid "email.digest.seeAll"
msgstr "{n, plural, one {See the notification} other {See all # notifications}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:604
msgid "email.digest.pre.topMonth"
msgstr "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:627
msgid "email.digest.pre.topWeek"
msgstr "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:125
msgid "email.digest.count.upvotes"
msgstr "{n, plural, one {upvote} other {upvotes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:389
msgid "email.digest.more"
msgstr "{n} more on your Followed feed"
#: src/components/chat/ChatModal.tsx:756
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
@@ -67,6 +162,55 @@ msgstr "{unseenCount} new messages"
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:199
msgid "email.digest.hl.comment"
msgstr "{who} commented on your dump {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:205
msgid "email.digest.hl.playlistFollow"
msgstr "{who} followed your playlist {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:217
msgid "email.digest.hl.commentLike"
msgstr "{who} liked your comment on {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:181
msgid "email.digest.hl.mentionDump"
msgstr "{who} mentioned you in {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:193
msgid "email.digest.hl.mentionChat"
msgstr "{who} mentioned you in {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:175
msgid "email.digest.hl.mentionComment"
msgstr "{who} mentioned you in a comment on {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:187
msgid "email.digest.hl.mentionPlaylist"
msgstr "{who} mentioned you in the playlist {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:211
msgid "email.digest.hl.userFollow"
msgstr "{who} started following you"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:32
msgid "email.welcome.body"
msgstr "# Welcome to {site}!\n"
"\n"
"Hi **{username}**,\n"
"\n"
"Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:741
#: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:159
@@ -181,6 +325,11 @@ msgstr "Add image"
msgid "Add to playlist"
msgstr "Add to playlist"
#. js-lingui-explicit-id
#: api/mail/digest.ts:244
msgid "email.digest.addedTo"
msgstr "added to {playlist}"
#: src/components/profile/RoleSwitcher.tsx:24
msgid "Admin"
msgstr "Admin"
@@ -204,6 +353,11 @@ msgstr "Already have an account? <0>Log in</0>"
msgid "An unexpected error occurred."
msgstr "An unexpected error occurred."
#. js-lingui-explicit-id
#: api/mail/digest.ts:260
msgid "email.digest.andMore"
msgstr "and {n} more"
#: src/components/profile/ProfileSettingsTab.tsx:82
msgid "Appearance"
msgstr "Appearance"
@@ -228,6 +382,16 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#. js-lingui-explicit-id
#: api/mail/digest.ts:226
msgid "email.digest.by"
msgstr "by @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:266
msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:761
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -336,6 +500,11 @@ msgstr "Collapse player"
msgid "Color scheme"
msgstr "Color scheme"
#. js-lingui-explicit-id
#: api/mail/digest.ts:424
msgid "email.digest.community"
msgstr "Community"
#: src/components/profile/ChangePasswordModal.tsx:109
#: src/pages/ResetPassword.tsx:128
msgid "Confirm new password"
@@ -378,6 +547,10 @@ msgstr "Couldn't load a preview for this link — check it, or post it as-is and
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
#: src/pages/Unsubscribe.tsx:111
msgid "Couldn't unsubscribe"
msgstr "Couldn't unsubscribe"
#: src/components/social/FollowButton.tsx:112
msgid "Couldn't update. Try again."
msgstr "Couldn't update. Try again."
@@ -725,6 +898,11 @@ msgstr "Followed playlists"
msgid "Following"
msgstr "Following"
#. js-lingui-explicit-id
#: api/mail/digest.ts:342
msgid "email.digest.forYou"
msgstr "For you"
#: src/pages/UserLogin.tsx:97
msgid "Forgot password?"
msgstr "Forgot password?"
@@ -733,6 +911,11 @@ msgstr "Forgot password?"
msgid "From people"
msgstr "From people"
#. js-lingui-explicit-id
#: api/mail/digest.ts:379
msgid "email.digest.network"
msgstr "From people you follow"
#: src/components/feed/FollowedFeed.tsx:133
msgid "From playlists"
msgstr "From playlists"
@@ -749,6 +932,11 @@ msgstr "Go home"
msgid "Go to login"
msgstr "Go to login"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:32
msgid "email.reset.hi"
msgstr "Hi {username},"
#: src/components/feed/FeedTabBar.tsx:45
#: src/components/profile/ProfileSettingsTab.tsx:40
msgid "Hot"
@@ -758,6 +946,16 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:48
msgid "email.reset.fallback"
msgstr "If the button doesn't work, paste this link into your browser:"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:56
msgid "email.reset.ignore"
msgstr "If you didn't ask for this, ignore this email. Your password stays the same."
#: src/utils/chat/message.ts:43
msgid "image"
msgstr "image"
@@ -766,6 +964,11 @@ msgstr "image"
msgid "In collections"
msgstr "In collections"
#. js-lingui-explicit-id
#: api/mail/digest.ts:495
msgid "email.digest.inChat"
msgstr "In the chat"
#: src/components/editor/TextEditor.tsx:544
#: src/components/editor/TextEditor.tsx:546
msgid "Insert emoji"
@@ -776,6 +979,7 @@ msgid "Invalid invite"
msgstr "Invalid invite"
#: src/pages/ResetPassword.tsx:61
#: src/pages/Unsubscribe.tsx:61
msgid "Invalid link"
msgstr "Invalid link"
@@ -788,6 +992,16 @@ msgstr "Invite link"
msgid "invited by"
msgstr "invited by"
#. js-lingui-explicit-id
#: api/mail/digest.ts:254
msgid "email.digest.invitedBy"
msgstr "invited by @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:253
msgid "email.digest.invitedByYou"
msgstr "invited by you"
#: src/components/profile/ProfileInviteesTab.tsx:76
#: src/pages/UserPublicProfile.tsx:192
msgid "Invitees"
@@ -909,8 +1123,10 @@ msgstr "Max {limit}"
msgid "Moderator"
msgstr "Moderator"
#. js-lingui-explicit-id
#: api/mail/digest.ts:311
#: src/components/profile/ProfileSettingsTab.tsx:137
msgid "Monthly recap"
msgid "digest.monthly"
msgstr "Monthly recap"
#: src/components/player/MediaPlayer.tsx:346
@@ -939,6 +1155,11 @@ msgstr "New"
msgid "New dump"
msgstr "New dump"
#. js-lingui-explicit-id
#: api/mail/digest.ts:455
msgid "email.digest.newMembers"
msgstr "New members"
#: src/components/profile/ChangePasswordModal.tsx:95
#: src/pages/ResetPassword.tsx:113
msgid "New password"
@@ -949,6 +1170,11 @@ msgstr "New password"
msgid "New playlist"
msgstr "New playlist"
#. js-lingui-explicit-id
#: api/mail/digest.ts:475
msgid "email.digest.newPlaylists"
msgstr "New public playlists"
#: src/components/dump/create/DumpCreateModal.tsx:763
msgid "Next →"
msgstr "Next →"
@@ -1040,6 +1266,11 @@ msgstr "Off"
msgid "On"
msgstr "On"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:49
msgid "email.welcome.digests"
msgstr "Once a week you'll get a digest of what happened here, and once a month a recap of the most upvoted dumps. You can turn either off in your settings."
#: src/components/editor/TextEditor.tsx:359
msgid "Only images can be dropped here."
msgstr "Only images can be dropped here."
@@ -1048,6 +1279,11 @@ msgstr "Only images can be dropped here."
msgid "Only its poster or a moderator can edit this dump."
msgstr "Only its poster or a moderator can edit this dump."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:41
msgid "email.welcome.button"
msgstr "Open {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
msgid "Open on {0}"
@@ -1057,6 +1293,11 @@ msgstr "Open on {0}"
msgid "Open source"
msgstr "Open source"
#. js-lingui-explicit-id
#: api/mail/digest.ts:506
msgid "email.digest.openChat"
msgstr "Open the chat"
#: src/components/form/FileDropZone.tsx:163
msgid "or <0>browse files</0>"
msgstr "or <0>browse files</0>"
@@ -1240,6 +1481,11 @@ msgstr "Request failed"
msgid "Reset failed"
msgstr "Reset failed"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:44
msgid "email.reset.button"
msgstr "Reset password"
#: src/pages/ResetPassword.tsx:34
msgid "Reset password"
msgstr "Reset password"
@@ -1254,6 +1500,16 @@ msgstr "Reset to default"
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:71
msgid "email.reset.subject"
msgstr "Reset your {site} password"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:30
msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:307
#: src/pages/DumpEdit.tsx:210
@@ -1337,6 +1593,11 @@ msgstr "Server unreachable"
msgid "Set new password"
msgstr "Set new password"
#. js-lingui-explicit-id
#: api/mail/digest.ts:554
msgid "email.settings"
msgstr "Settings"
#: src/pages/UserPublicProfile.tsx:194
msgid "Settings"
msgstr "Settings"
@@ -1357,6 +1618,11 @@ msgstr "slug"
msgid "Some images couldn't be uploaded."
msgstr "Some images couldn't be uploaded."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:37
msgid "email.reset.body"
msgstr "Someone asked to reset the password of your {site} account. The button below lets you choose a new one. It works once, for {hours, plural, one {# hour} other {# hours}}."
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:729
@@ -1384,6 +1650,11 @@ msgstr "Submit search"
msgid "That file is over {limit}."
msgstr "That file is over {limit}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:351
msgid "email.digest.theChat"
msgstr "the chat"
#: src/pages/Notifications.tsx:222
msgid "the chat"
msgstr "the chat"
@@ -1396,6 +1667,21 @@ msgstr "The dump couldn't be removed. Try again."
msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:76
msgid "email.reset.preheader"
msgstr "The link works once, for {hours, plural, one {# hour} other {# hours}}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:325
msgid "email.digest.monthly.title"
msgstr "The month on {site}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:562
msgid "email.digest.monthly.subject"
msgstr "The month on {site}, {range}"
#: src/hooks/playlist/usePlaylist.ts:307
msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again."
@@ -1424,6 +1710,10 @@ msgstr "This reset link is missing or malformed."
msgid "This site doesn't send email, so no digest goes out."
msgstr "This site doesn't send email, so no digest goes out."
#: src/pages/Unsubscribe.tsx:64
msgid "This unsubscribe link is missing or malformed."
msgstr "This unsubscribe link is missing or malformed."
#: src/pages/DumpEdit.tsx:433
msgid "Thumbnail"
msgstr "Thumbnail"
@@ -1443,6 +1733,16 @@ msgstr "Title is required."
msgid "Today"
msgstr "Today"
#. js-lingui-explicit-id
#: api/mail/digest.ts:406
msgid "email.digest.topMonth"
msgstr "Top this month"
#. js-lingui-explicit-id
#: api/mail/digest.ts:407
msgid "email.digest.topWeek"
msgstr "Top this week"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
msgid "Track {0}"
@@ -1452,6 +1752,30 @@ msgstr "Track {0}"
msgid "Try again"
msgstr "Try again"
#. js-lingui-explicit-id
#: api/mail/digest.ts:542
#: src/pages/Unsubscribe.tsx:116
msgid "digest.monthly.off"
msgstr "Turn off monthly recaps"
#: src/pages/Unsubscribe.tsx:107
msgid "Turn off the monthly recap?"
msgstr "Turn off the monthly recap?"
#: src/pages/Unsubscribe.tsx:106
msgid "Turn off the weekly digest?"
msgstr "Turn off the weekly digest?"
#. js-lingui-explicit-id
#: api/mail/digest.ts:546
#: src/pages/Unsubscribe.tsx:114
msgid "digest.weekly.off"
msgstr "Turn off weekly digests"
#: src/pages/Unsubscribe.tsx:112
msgid "Turning off…"
msgstr "Turning off…"
#: src/components/chat/ChatModal.tsx:829
msgid "Type a message…"
msgstr "Type a message…"
@@ -1476,6 +1800,19 @@ msgstr "Unfollow playlist"
msgid "Unmute"
msgstr "Unmute"
#: src/pages/Unsubscribe.tsx:30
msgid "Unsubscribe"
msgstr "Unsubscribe"
#: src/pages/Unsubscribe.tsx:78
msgid "Unsubscribed"
msgstr "Unsubscribed"
#. js-lingui-explicit-id
#: api/mail/digest.ts:250
msgid "email.digest.upload"
msgstr "upload"
#: src/components/profile/ProfileHeader.tsx:70
msgid "Upload failed"
msgstr "Upload failed"
@@ -1552,10 +1889,17 @@ msgstr "View dump →"
msgid "Volume"
msgstr "Volume"
#. js-lingui-explicit-id
#: api/mail/digest.ts:315
#: src/components/profile/ProfileSettingsTab.tsx:128
msgid "Weekly digest"
msgid "digest.weekly"
msgstr "Weekly digest"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:65
msgid "email.welcome.subject"
msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:711
#: src/pages/DumpEdit.tsx:515
msgid "What makes it worth it?"
@@ -1592,10 +1936,38 @@ msgstr "Yesterday"
msgid "You can post it anyway."
msgstr "You can post it anyway."
#: src/pages/Unsubscribe.tsx:89
msgid "You won't get the monthly recap any more. You can turn it back on in your settings."
msgstr "You won't get the monthly recap any more. You can turn it back on in your settings."
#: src/pages/Unsubscribe.tsx:83
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
msgstr "You won't get the weekly digest any more. You can turn it back on in your settings."
#: src/pages/Notifications.tsx:359
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#. js-lingui-explicit-id
#: api/mail/digest.ts:528
msgid "email.digest.monthly.reason"
msgstr "You're getting the monthly recap because it's on in your settings."
#. js-lingui-explicit-id
#: api/mail/digest.ts:533
msgid "email.digest.weekly.reason"
msgstr "You're getting the weekly digest because it's on in your settings."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:63
msgid "email.reset.reason"
msgstr "You're getting this email because a password reset was requested for this address."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:56
msgid "email.welcome.reason"
msgstr "You're getting this email because an account was created on {site} with this address."
#: src/contexts/ws/WSProvider.tsx:910
msgid "You're offline — not sent"
msgstr "You're offline — not sent"
@@ -1604,6 +1976,11 @@ msgstr "You're offline — not sent"
msgid "You've reached the end."
msgstr "You've reached the end."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:70
msgid "email.welcome.preheader"
msgstr "Your account is ready."
#: src/pages/UserLogin.tsx:152
msgid "Your email address"
msgstr "Your email address"
@@ -1611,3 +1988,22 @@ msgstr "Your email address"
#: src/pages/ResetPassword.tsx:86
msgid "Your password has been changed. You can now log in."
msgstr "Your password has been changed. You can now log in."
#: src/pages/Unsubscribe.tsx:51
msgid "Your settings"
msgstr "Your settings"
#. js-lingui-explicit-id
#: api/mail/digest.ts:567
msgid "email.digest.weekly.subject"
msgstr "Your week on {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:330
msgid "email.digest.weekly.title"
msgstr "Your week, {username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:293
msgid "email.digest.yours"
msgstr "yours"

1
locales/fr.mjs Normal file

File diff suppressed because one or more lines are too long

View File

@@ -55,10 +55,105 @@ msgstr "{0} de plus en ligne : {restNames}"
msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
#. js-lingui-explicit-id
#: api/mail/digest.ts:514
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
#: src/pages/Search.tsx:120
msgid "{label} ({count})"
msgstr "{label} ({count})"
#. js-lingui-explicit-id
#: api/mail/digest.ts:238
msgid "email.digest.comments"
msgstr "{n, plural, one {# commentaire} other {# commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:498
msgid "email.digest.chatWeek"
msgstr "{n, plural, one {# message cette semaine} other {# messages cette semaine}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:591
msgid "email.digest.pre.network"
msgstr "{n, plural, one {# nouvelle reco de ceux que vous suivez} other {# nouvelles recos de ceux que vous suivez}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:612
msgid "email.digest.pre.members"
msgstr "{n, plural, one {# nouveau membre} other {# nouveaux membres}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:619
msgid "email.digest.pre.playlists"
msgstr "{n, plural, one {# nouvelle collection} other {# nouvelles collections}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:232
msgid "email.digest.votes"
msgstr "{n, plural, one {# vote} other {# votes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:442
msgid "email.digest.stat.chat"
msgstr "{n, plural, one {message dans la tribune} other {messages dans la tribune}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:149
msgid "email.digest.count.likes"
msgstr "{n, plural, one {j'aime sur vos commentaires} other {j'aime sur vos commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:137
msgid "email.digest.count.comments"
msgstr "{n, plural, one {commentaire} other {commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:432
msgid "email.digest.stat.dumps"
msgstr "{n, plural, one {reco} other {recos}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:143
msgid "email.digest.count.mentions"
msgstr "{n, plural, one {mention} other {mentions}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:131
msgid "email.digest.count.followers"
msgstr "{n, plural, one {nouvel abonné} other {nouveaux abonnés}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:155
msgid "email.digest.count.playlistFollows"
msgstr "{n, plural, one {abonnement à vos collections} other {abonnements à vos collections}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:364
msgid "email.digest.seeAll"
msgstr "{n, plural, one {Voir la notification} other {Voir les # notifications}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:604
msgid "email.digest.pre.topMonth"
msgstr "{n, plural, one {la reco la plus votée du mois} other {les # recos les plus votées du mois}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:627
msgid "email.digest.pre.topWeek"
msgstr "{n, plural, one {la reco la plus votée de la semaine} other {les # recos les plus votées de la semaine}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:125
msgid "email.digest.count.upvotes"
msgstr "{n, plural, one {vote} other {votes}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:389
msgid "email.digest.more"
msgstr "{n} de plus dans votre fil Suivi"
#: src/components/chat/ChatModal.tsx:756
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
@@ -67,6 +162,55 @@ msgstr "{unseenCount} nouveaux messages"
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:199
msgid "email.digest.hl.comment"
msgstr "{who} a commenté votre reco {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:205
msgid "email.digest.hl.playlistFollow"
msgstr "{who} a suivi votre collection {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:217
msgid "email.digest.hl.commentLike"
msgstr "{who} a aimé votre commentaire sur {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:181
msgid "email.digest.hl.mentionDump"
msgstr "{who} vous a mentionné dans {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:193
msgid "email.digest.hl.mentionChat"
msgstr "{who} vous a mentionné dans {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:175
msgid "email.digest.hl.mentionComment"
msgstr "{who} vous a mentionné dans un commentaire sur {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:187
msgid "email.digest.hl.mentionPlaylist"
msgstr "{who} vous a mentionné dans la collection {target}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:211
msgid "email.digest.hl.userFollow"
msgstr "{who} vous suit désormais"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:32
msgid "email.welcome.body"
msgstr "# Bienvenue sur {site} !\n"
"\n"
"Bonjour **{username}**,\n"
"\n"
"Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:741
#: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:159
@@ -181,6 +325,11 @@ msgstr "Ajouter une image"
msgid "Add to playlist"
msgstr "Ajouter à la collection"
#. js-lingui-explicit-id
#: api/mail/digest.ts:244
msgid "email.digest.addedTo"
msgstr "ajoutée à {playlist}"
#: src/components/profile/RoleSwitcher.tsx:24
msgid "Admin"
msgstr "Administrateur"
@@ -204,6 +353,11 @@ msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite."
#. js-lingui-explicit-id
#: api/mail/digest.ts:260
msgid "email.digest.andMore"
msgstr "et {n} de plus"
#: src/components/profile/ProfileSettingsTab.tsx:82
msgid "Appearance"
msgstr "Apparence"
@@ -228,6 +382,16 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#. js-lingui-explicit-id
#: api/mail/digest.ts:226
msgid "email.digest.by"
msgstr "par @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:266
msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:761
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -336,6 +500,11 @@ msgstr "Réduire le lecteur"
msgid "Color scheme"
msgstr "Thème de couleur"
#. js-lingui-explicit-id
#: api/mail/digest.ts:424
msgid "email.digest.community"
msgstr "La communauté"
#: src/components/profile/ChangePasswordModal.tsx:109
#: src/pages/ResetPassword.tsx:128
msgid "Confirm new password"
@@ -378,6 +547,10 @@ msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publi
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
#: src/pages/Unsubscribe.tsx:111
msgid "Couldn't unsubscribe"
msgstr "Désabonnement impossible"
#: src/components/social/FollowButton.tsx:112
msgid "Couldn't update. Try again."
msgstr "La mise à jour a échoué. Réessayez."
@@ -725,6 +898,11 @@ msgstr "Collections suivies"
msgid "Following"
msgstr "Abonné"
#. js-lingui-explicit-id
#: api/mail/digest.ts:342
msgid "email.digest.forYou"
msgstr "Pour vous"
#: src/pages/UserLogin.tsx:97
msgid "Forgot password?"
msgstr "Mot de passe oublié ?"
@@ -733,6 +911,11 @@ msgstr "Mot de passe oublié ?"
msgid "From people"
msgstr "De personnes"
#. js-lingui-explicit-id
#: api/mail/digest.ts:379
msgid "email.digest.network"
msgstr "De ceux que vous suivez"
#: src/components/feed/FollowedFeed.tsx:133
msgid "From playlists"
msgstr "De collections"
@@ -749,6 +932,11 @@ msgstr "Accueil"
msgid "Go to login"
msgstr "Aller à la connexion"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:32
msgid "email.reset.hi"
msgstr "Bonjour {username},"
#: src/components/feed/FeedTabBar.tsx:45
#: src/components/profile/ProfileSettingsTab.tsx:40
msgid "Hot"
@@ -758,6 +946,16 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:48
msgid "email.reset.fallback"
msgstr "Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur :"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:56
msgid "email.reset.ignore"
msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas."
#: src/utils/chat/message.ts:43
msgid "image"
msgstr "image"
@@ -766,6 +964,11 @@ msgstr "image"
msgid "In collections"
msgstr "Dans les collections"
#. js-lingui-explicit-id
#: api/mail/digest.ts:495
msgid "email.digest.inChat"
msgstr "Dans la tribune"
#: src/components/editor/TextEditor.tsx:544
#: src/components/editor/TextEditor.tsx:546
msgid "Insert emoji"
@@ -776,6 +979,7 @@ msgid "Invalid invite"
msgstr "Invitation invalide"
#: src/pages/ResetPassword.tsx:61
#: src/pages/Unsubscribe.tsx:61
msgid "Invalid link"
msgstr "Lien invalide"
@@ -788,6 +992,16 @@ msgstr "Lien d'invitation"
msgid "invited by"
msgstr "invité par"
#. js-lingui-explicit-id
#: api/mail/digest.ts:254
msgid "email.digest.invitedBy"
msgstr "sur invitation de @{username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:253
msgid "email.digest.invitedByYou"
msgstr "sur votre invitation"
#: src/components/profile/ProfileInviteesTab.tsx:76
#: src/pages/UserPublicProfile.tsx:192
msgid "Invitees"
@@ -909,8 +1123,10 @@ msgstr "Max {limit}"
msgid "Moderator"
msgstr "Modérateur"
#. js-lingui-explicit-id
#: api/mail/digest.ts:311
#: src/components/profile/ProfileSettingsTab.tsx:137
msgid "Monthly recap"
msgid "digest.monthly"
msgstr "Récap mensuel"
#: src/components/player/MediaPlayer.tsx:346
@@ -939,6 +1155,11 @@ msgstr "Nouveau"
msgid "New dump"
msgstr "Nouvelle reco"
#. js-lingui-explicit-id
#: api/mail/digest.ts:455
msgid "email.digest.newMembers"
msgstr "Nouveaux membres"
#: src/components/profile/ChangePasswordModal.tsx:95
#: src/pages/ResetPassword.tsx:113
msgid "New password"
@@ -949,6 +1170,11 @@ msgstr "Nouveau mot de passe"
msgid "New playlist"
msgstr "Nouvelle collection"
#. js-lingui-explicit-id
#: api/mail/digest.ts:475
msgid "email.digest.newPlaylists"
msgstr "Nouvelles collections publiques"
#: src/components/dump/create/DumpCreateModal.tsx:763
msgid "Next →"
msgstr "Suivant →"
@@ -1040,6 +1266,11 @@ msgstr "Désactivés"
msgid "On"
msgstr "Activés"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:49
msgid "email.welcome.digests"
msgstr "Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et chaque mois un récap des recos les plus votées. Vous pouvez désactiver l'un ou l'autre dans vos paramètres."
#: src/components/editor/TextEditor.tsx:359
msgid "Only images can be dropped here."
msgstr "Seules les images peuvent être déposées ici."
@@ -1048,6 +1279,11 @@ msgstr "Seules les images peuvent être déposées ici."
msgid "Only its poster or a moderator can edit this dump."
msgstr "Seuls son auteur et les modérateurs peuvent modifier ce dump."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:41
msgid "email.welcome.button"
msgstr "Ouvrir {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
msgid "Open on {0}"
@@ -1057,6 +1293,11 @@ msgstr "Ouvrir sur {0}"
msgid "Open source"
msgstr "Ouvrir la source"
#. js-lingui-explicit-id
#: api/mail/digest.ts:506
msgid "email.digest.openChat"
msgstr "Ouvrir la tribune"
#: src/components/form/FileDropZone.tsx:163
msgid "or <0>browse files</0>"
msgstr "ou <0>parcourir les fichiers</0>"
@@ -1240,6 +1481,11 @@ msgstr "Échec de la demande"
msgid "Reset failed"
msgstr "Échec de la réinitialisation"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:44
msgid "email.reset.button"
msgstr "Choisir un nouveau mot de passe"
#: src/pages/ResetPassword.tsx:34
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
@@ -1254,6 +1500,16 @@ msgstr "Réinitialiser par défaut"
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:71
msgid "email.reset.subject"
msgstr "Réinitialisez votre mot de passe {site}"
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:30
msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:307
#: src/pages/DumpEdit.tsx:210
@@ -1337,6 +1593,11 @@ msgstr "Serveur inaccessible"
msgid "Set new password"
msgstr "Définir un nouveau mot de passe"
#. js-lingui-explicit-id
#: api/mail/digest.ts:554
msgid "email.settings"
msgstr "Paramètres"
#: src/pages/UserPublicProfile.tsx:194
msgid "Settings"
msgstr "Paramètres"
@@ -1357,6 +1618,11 @@ msgstr "identifiant"
msgid "Some images couldn't be uploaded."
msgstr "Certaines images n'ont pas pu être envoyées."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:37
msgid "email.reset.body"
msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {site}. Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant {hours, plural, one {# heure} other {# heures}}."
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:729
@@ -1384,6 +1650,11 @@ msgstr "Lancer la recherche"
msgid "That file is over {limit}."
msgstr "Ce fichier dépasse {limit}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:351
msgid "email.digest.theChat"
msgstr "la tribune"
#: src/pages/Notifications.tsx:222
msgid "the chat"
msgstr "la tribune"
@@ -1396,6 +1667,21 @@ msgstr "Le dump n'a pas pu être retiré. Réessayez."
msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:76
msgid "email.reset.preheader"
msgstr "Le lien ne fonctionne qu'une fois, pendant {hours, plural, one {# heure} other {# heures}}."
#. js-lingui-explicit-id
#: api/mail/digest.ts:325
msgid "email.digest.monthly.title"
msgstr "Le mois sur {site}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:562
msgid "email.digest.monthly.subject"
msgstr "Le mois sur {site}, {range}"
#: src/hooks/playlist/usePlaylist.ts:307
msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
@@ -1424,6 +1710,10 @@ msgstr "Ce lien de réinitialisation est absent ou malformé."
msgid "This site doesn't send email, so no digest goes out."
msgstr "Ce site n'envoie pas d'e-mails, aucun résumé ne part donc."
#: src/pages/Unsubscribe.tsx:64
msgid "This unsubscribe link is missing or malformed."
msgstr "Ce lien de désabonnement est absent ou mal formé."
#: src/pages/DumpEdit.tsx:433
msgid "Thumbnail"
msgstr "Miniature"
@@ -1443,6 +1733,16 @@ msgstr "Un titre est requis."
msgid "Today"
msgstr "Aujourd'hui"
#. js-lingui-explicit-id
#: api/mail/digest.ts:406
msgid "email.digest.topMonth"
msgstr "Les plus votées du mois"
#. js-lingui-explicit-id
#: api/mail/digest.ts:407
msgid "email.digest.topWeek"
msgstr "Les plus votées de la semaine"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
msgid "Track {0}"
@@ -1452,6 +1752,30 @@ msgstr "Piste {0}"
msgid "Try again"
msgstr "Réessayer"
#. js-lingui-explicit-id
#: api/mail/digest.ts:542
#: src/pages/Unsubscribe.tsx:116
msgid "digest.monthly.off"
msgstr "Ne plus recevoir le récap mensuel"
#: src/pages/Unsubscribe.tsx:107
msgid "Turn off the monthly recap?"
msgstr "Ne plus recevoir le récap mensuel ?"
#: src/pages/Unsubscribe.tsx:106
msgid "Turn off the weekly digest?"
msgstr "Ne plus recevoir le résumé hebdomadaire ?"
#. js-lingui-explicit-id
#: api/mail/digest.ts:546
#: src/pages/Unsubscribe.tsx:114
msgid "digest.weekly.off"
msgstr "Ne plus recevoir le résumé hebdomadaire"
#: src/pages/Unsubscribe.tsx:112
msgid "Turning off…"
msgstr "Désactivation…"
#: src/components/chat/ChatModal.tsx:829
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1476,6 +1800,19 @@ msgstr "Ne plus suivre la collection"
msgid "Unmute"
msgstr "Rétablir le son"
#: src/pages/Unsubscribe.tsx:30
msgid "Unsubscribe"
msgstr "Se désabonner"
#: src/pages/Unsubscribe.tsx:78
msgid "Unsubscribed"
msgstr "Désabonné"
#. js-lingui-explicit-id
#: api/mail/digest.ts:250
msgid "email.digest.upload"
msgstr "fichier"
#: src/components/profile/ProfileHeader.tsx:70
msgid "Upload failed"
msgstr "Envoi échoué"
@@ -1552,10 +1889,17 @@ msgstr "Voir la reco →"
msgid "Volume"
msgstr "Volume"
#. js-lingui-explicit-id
#: api/mail/digest.ts:315
#: src/components/profile/ProfileSettingsTab.tsx:128
msgid "Weekly digest"
msgid "digest.weekly"
msgstr "Résumé hebdomadaire"
#. js-lingui-explicit-id
#: api/mail/welcome.ts:65
msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:711
#: src/pages/DumpEdit.tsx:515
msgid "What makes it worth it?"
@@ -1592,10 +1936,38 @@ msgstr "Hier"
msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même."
#: src/pages/Unsubscribe.tsx:89
msgid "You won't get the monthly recap any more. You can turn it back on in your settings."
msgstr "Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans vos paramètres."
#: src/pages/Unsubscribe.tsx:83
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
msgstr "Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres."
#: src/pages/Notifications.tsx:359
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#. js-lingui-explicit-id
#: api/mail/digest.ts:528
msgid "email.digest.monthly.reason"
msgstr "Vous recevez ce récap mensuel car il est activé dans vos paramètres."
#. js-lingui-explicit-id
#: api/mail/digest.ts:533
msgid "email.digest.weekly.reason"
msgstr "Vous recevez ce résumé hebdomadaire car il est activé dans vos paramètres."
#. js-lingui-explicit-id
#: api/mail/password-reset.ts:63
msgid "email.reset.reason"
msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:56
msgid "email.welcome.reason"
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
#: src/contexts/ws/WSProvider.tsx:910
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"
@@ -1604,6 +1976,11 @@ msgstr "Hors ligne — message non envoyé"
msgid "You've reached the end."
msgstr "Vous avez tout lu, tout vu, tout bu."
#. js-lingui-explicit-id
#: api/mail/welcome.ts:70
msgid "email.welcome.preheader"
msgstr "Votre compte est prêt."
#: src/pages/UserLogin.tsx:152
msgid "Your email address"
msgstr "Votre adresse e-mail"
@@ -1611,3 +1988,22 @@ msgstr "Votre adresse e-mail"
#: src/pages/ResetPassword.tsx:86
msgid "Your password has been changed. You can now log in."
msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."
#: src/pages/Unsubscribe.tsx:51
msgid "Your settings"
msgstr "Vos paramètres"
#. js-lingui-explicit-id
#: api/mail/digest.ts:567
msgid "email.digest.weekly.subject"
msgstr "Votre semaine sur {site}, {range}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:330
msgid "email.digest.weekly.title"
msgstr "Votre semaine, {username}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:293
msgid "email.digest.yours"
msgstr "à vous"

4
mod.ts
View File

@@ -39,6 +39,10 @@
* imports nothing from either half. The server sends `Date`s, and the client
* reads the same types through `Wire<T>`, where every `Date` is an ISO string.
*
* Beside it, `locales/` holds one Lingui catalogue per language for the whole
* app: the interface's messages, and the emails' that `api/mail/i18n.ts`
* loads compiled.
*
* ## Frontend, in `src/`
*
* The React app is built with Vite and translated with Lingui. `main.tsx`

21
shared/locale.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* The app is translated into a few languages, one Lingui catalogue each under
* `locales/`. This module lists them once for every reader: `lingui.config.ts`
* extracts and compiles a catalogue per locale, the client picks one per
* browser, and the server writes its emails in `GERBEUR_EMAIL_LOCALE`.
*
* @module
*/
/** Lists the locales the app is translated into. */
export const LOCALES = ["en", "fr"] as const;
/** One of the {@link LOCALES}. */
export type Locale = typeof LOCALES[number];
/** The locale the messages are written in, and the fallback for any other. */
export const SOURCE_LOCALE: Locale = "en";
/** Checks whether `value` is a {@link Locale}. */
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" &&
(LOCALES as readonly string[]).includes(value);
}

View File

@@ -41,3 +41,26 @@ export const DEFAULT_SETTINGS: UserSettings = {
digestWeekly: true,
digestMonthly: true,
};
/** Lists how often a digest can come: weekly, or monthly as a recap. */
export const DIGEST_CADENCES = ["weekly", "monthly"] as const;
/**
* One of the {@link DIGEST_CADENCES}. An unsubscribe link names one, which
* turns off its setting alone.
*/
export type DigestCadence = typeof DIGEST_CADENCES[number];
/** Checks whether `value` is a {@link DigestCadence}. */
export function isDigestCadence(value: unknown): value is DigestCadence {
return typeof value === "string" &&
(DIGEST_CADENCES as readonly string[]).includes(value);
}
/** Names the setting that switches each cadence of digest. */
export const DIGEST_SETTING: Record<
DigestCadence,
"digestWeekly" | "digestMonthly"
> = {
weekly: "digestWeekly",
monthly: "digestMonthly",
};

View File

@@ -22,10 +22,18 @@ export const STYLE_NAMES = [
/** One of the {@link STYLE_NAMES}. */
export type StyleName = typeof STYLE_NAMES[number];
/**
* The style the stylesheets draw when the root element has no `data-style`,
* and the one an unset or unknown `GERBEUR_DEFAULT_STYLE` means.
*/
export const BASE_STYLE: StyleName = "smooth";
/** Lists every color scheme. "auto" follows the system's. */
export const COLOR_SCHEMES = ["auto", "light", "dark"] as const;
/** One of the {@link COLOR_SCHEMES}. */
export type ColorScheme = typeof COLOR_SCHEMES[number];
/** The scheme of whoever hasn't picked one: the system's. */
export const DEFAULT_COLOR_SCHEME: ColorScheme = "auto";
/**
* Checks whether `value` is a {@link StyleName}, as a stored choice or an

View File

@@ -15,6 +15,7 @@
* - `/search` and `/search/~/:searchTab` are the search results (`Search`).
* - `/reset-password` sets a new password from an emailed link
* (`ResetPassword`).
* - `/unsubscribe` turns off a digest from its emailed link (`Unsubscribe`).
* - `/notifications` lists the user's notifications (`Notifications`), signed
* in only.
* - `/:categorySlug/:feedTab` is a category's feed (`Index`), and
@@ -106,6 +107,9 @@ const ResetPassword = lazy(() =>
default: m.ResetPassword,
}))
);
const Unsubscribe = lazy(() =>
import("./pages/Unsubscribe.tsx").then((m) => ({ default: m.Unsubscribe }))
);
const NotFound = lazy(() =>
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
);
@@ -211,6 +215,7 @@ function AppRoutes() {
<Route path="/search" element={<Search />} />
<Route path="/search/~/:searchTab" element={<Search />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
<Route
path="/notifications"
element={

View File

@@ -125,7 +125,7 @@ export function ProfileSettingsTab(): React.JSX.Element {
</ProfileSection>
<ProfileSection title={<Trans>Email</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Weekly digest</Trans>}>
<SettingRow label={<Trans id="digest.weekly">Weekly digest</Trans>}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestWeekly ?? true}
@@ -134,7 +134,7 @@ export function ProfileSettingsTab(): React.JSX.Element {
updateSettings({ digestWeekly: on }).catch(() => {})}
/>
</SettingRow>
<SettingRow label={<Trans>Monthly recap</Trans>}>
<SettingRow label={<Trans id="digest.monthly">Monthly recap</Trans>}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestMonthly ?? true}

View File

@@ -11,7 +11,7 @@
* @module
*/
import { isStyleName, type StyleName } from "../../shared/theme.ts";
import { BASE_STYLE, isStyleName, type StyleName } from "../../shared/theme.ts";
function readMeta(name: string): string | undefined {
return document.querySelector(`meta[name="${name}"]`)?.getAttribute(
@@ -21,11 +21,11 @@ function readMeta(name: string): string | undefined {
/**
* The instance's style, which applies until a visitor or their account picks
* one. A missing or unknown value means "smooth". `ThemeProvider` reads it.
* one. A missing or unknown value means `BASE_STYLE`. `ThemeProvider` reads it.
*/
export const DEFAULT_STYLE: StyleName = (() => {
const meta = readMeta("default-style");
return isStyleName(meta) ? meta : "smooth";
return isStyleName(meta) ? meta : BASE_STYLE;
})();
/**

View File

@@ -59,12 +59,30 @@ export function AuthProvider(
useEffect(() => {
if (!token) return;
const read = (path: string) =>
fetch(`${API_URL}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const dataOf = async (res: Response) => {
if (!res.ok) return null;
const json = await res.json();
return json?.success && json.data ? json.data : null;
};
// Only a change replaces the session, so an unchanged one keeps its
// identity and doesn't re-render the app.
const merge = (patch: Partial<Pick<Session, "user" | "settings">>) =>
setAuthResponse((prev) => {
if (!prev || prev.token !== token) return prev;
const next = { ...prev, ...patch };
if (JSON.stringify(next) === JSON.stringify(prev)) return prev;
storeSession(next);
return next;
});
const validate = async () => {
try {
const res = await fetch(`${API_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const res = await read("/api/users/me");
if (res.status === 401) {
// Only this token is dead. The session may have moved on to fresh
// tokens while we asked (a password change, here or in another tab).
@@ -73,45 +91,15 @@ export function AuthProvider(
return;
}
// The stored user dates from sign-in, and the role, avatar or email
// may have changed since, by an admin or in another browser. Only a
// change replaces it, so an unchanged session keeps its identity and
// doesn't re-render the app.
if (res.ok) {
const json = await res.json();
if (json?.success && json.data) {
const freshUser = deserializeSelfUser(json.data);
setAuthResponse((prev) => {
if (
!prev ||
JSON.stringify(prev.user) === JSON.stringify(freshUser)
) return prev;
const updated = { ...prev, user: freshUser };
storeSession(updated);
return updated;
});
}
}
// may have changed since, by an admin or in another browser.
const user = await dataOf(res);
if (user) merge({ user: deserializeSelfUser(user) });
// The settings follow the account, so another browser may have
// changed them. A failure leaves the ones the session has.
const settingsRes = await fetch(`${API_URL}/api/users/me/settings`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (settingsRes.ok) {
const json = await settingsRes.json();
if (json?.success && json.data) {
const settings = json.data as UserSettings;
setAuthResponse((prev) => {
if (
!prev || prev.token !== token ||
JSON.stringify(prev.settings) === JSON.stringify(settings)
) return prev;
const updated = { ...prev, settings };
storeSession(updated);
return updated;
});
}
}
const settings: UserSettings | null = await dataOf(
await read("/api/users/me/settings"),
);
if (settings) merge({ settings });
} catch {
// Offline is not signed out: only a 401 ends the session.
}

View File

@@ -6,7 +6,12 @@
*/
import { createContext } from "react";
import type { ColorScheme, StyleName } from "../../../shared/theme.ts";
import {
BASE_STYLE,
type ColorScheme,
DEFAULT_COLOR_SCHEME,
type StyleName,
} from "../../../shared/theme.ts";
/**
* Provided by `ThemeProvider` to `useTheme`. A setter applies the choice and
@@ -23,8 +28,8 @@ export interface ThemeContextValue {
export const ThemeContext: React.Context<ThemeContextValue> = createContext<
ThemeContextValue
>({
style: "smooth",
colorScheme: "auto",
style: BASE_STYLE,
colorScheme: DEFAULT_COLOR_SCHEME,
setStyle: () => {},
setColorScheme: () => {},
});

View File

@@ -8,11 +8,13 @@
* @module
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import { ThemeContext } from "./ThemeContext.ts";
import {
BASE_STYLE,
COLOR_SCHEMES,
type ColorScheme,
DEFAULT_COLOR_SCHEME,
STYLE_NAMES,
type StyleName,
} from "../../../shared/theme.ts";
@@ -20,120 +22,70 @@ import type { UpdateSettingsRequest } from "../../../shared/settings.ts";
import { DEFAULT_STYLE } from "../../config/instance.ts";
import { useSettings } from "../../hooks/auth/useSettings.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import {
type ThemeChoiceSpec,
useThemeChoice,
} from "../../hooks/theme/useThemeChoice.ts";
/** Passed to {@link ThemeProvider} by `App`. */
export interface ThemeProviderProps {
children: React.ReactNode;
}
/** Returns the stored `key` when it is one of `allowed`, else null. */
function readChoice<T extends string>(
key: string,
allowed: readonly T[],
): T | null {
try {
const stored = localStorage.getItem(key);
return (allowed as readonly string[]).includes(stored ?? "")
? stored as T
: null;
} catch {
return null;
}
}
const STYLE: ThemeChoiceSpec<StyleName> = {
storageKey: "style",
allowed: STYLE_NAMES,
setting: "style",
fallback: DEFAULT_STYLE,
attribute: "data-style",
base: BASE_STYLE,
};
function writeChoice(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Blocked storage only costs the choice at the next visit.
}
}
const SCHEME: ThemeChoiceSpec<ColorScheme> = {
storageKey: "color-scheme",
allowed: COLOR_SCHEMES,
setting: "colorScheme",
fallback: DEFAULT_COLOR_SCHEME,
attribute: "data-color-scheme",
base: DEFAULT_COLOR_SCHEME,
};
/**
* Provides {@link ThemeContext}. It sets the theme on the root element as
* `data-style` and `data-color-scheme`, which the stylesheets key on, and
* leaves them out for "smooth" and "auto".
*
* This browser's localStorage keeps the last choice made or read from the
* account, which an inline script in index.html applies before the first
* paint, so the page doesn't flash another theme. The two must agree. When an
* account has no choice yet and this browser has one, it is saved to the
* account, once per session.
* Provides {@link ThemeContext}. Each half of the theme goes through
* `useThemeChoice`, which sets it on the root element as `data-style` or
* `data-color-scheme` and mirrors it to localStorage, where an inline script
* in index.html applies it before the first paint, so the page doesn't flash
* another theme. The two must agree. When an account has no choice yet and
* this browser has one, it is saved to the account, once per session.
*/
export function ThemeProvider(
{ children }: ThemeProviderProps,
): React.JSX.Element {
const { settings, updateSettings } = useSettings();
const { token } = useAuth();
// An unknown stored value, such as "default", which "smooth" was once
// stored as, reads as no choice.
const [localStyle, setLocalStyle] = useState<StyleName | null>(() =>
readChoice("style", STYLE_NAMES)
);
const [localScheme, setLocalScheme] = useState<ColorScheme | null>(() =>
readChoice("color-scheme", COLOR_SCHEMES)
);
const accountStyle = settings?.style ?? null;
const accountScheme = settings?.colorScheme ?? null;
useEffect(() => {
if (accountStyle) writeChoice("style", accountStyle);
}, [accountStyle]);
useEffect(() => {
if (accountScheme) writeChoice("color-scheme", accountScheme);
}, [accountScheme]);
const style = useThemeChoice(STYLE);
const scheme = useThemeChoice(SCHEME);
const carriedOver = useRef<string | null>(null);
useEffect(() => {
if (!settings || !token || carriedOver.current === token) return;
carriedOver.current = token;
const patch: UpdateSettingsRequest = {};
if (settings.style === null && localStyle) patch.style = localStyle;
if (settings.colorScheme === null && localScheme) {
patch.colorScheme = localScheme;
if (settings.style === null && style.local) patch.style = style.local;
if (settings.colorScheme === null && scheme.local) {
patch.colorScheme = scheme.local;
}
if (Object.keys(patch).length > 0) {
updateSettings(patch).catch(() => {/* tried again next session */});
}
}, [settings, token, localStyle, localScheme, updateSettings]);
}, [settings, token, style.local, scheme.local, updateSettings]);
const style = accountStyle ?? localStyle ?? DEFAULT_STYLE;
const colorScheme = accountScheme ?? localScheme ?? "auto";
useEffect(() => {
const el = document.documentElement;
if (style === "smooth") {
el.removeAttribute("data-style");
} else {
el.setAttribute("data-style", style);
}
}, [style]);
useEffect(() => {
const el = document.documentElement;
if (colorScheme === "auto") {
el.removeAttribute("data-color-scheme");
} else {
el.setAttribute("data-color-scheme", colorScheme);
}
}, [colorScheme]);
const signedIn = settings !== null;
const setStyle = useCallback((next: StyleName) => {
setLocalStyle(next);
writeChoice("style", next);
if (signedIn) updateSettings({ style: next }).catch(() => {});
}, [signedIn, updateSettings]);
const setColorScheme = useCallback((next: ColorScheme) => {
setLocalScheme(next);
writeChoice("color-scheme", next);
if (signedIn) updateSettings({ colorScheme: next }).catch(() => {});
}, [signedIn, updateSettings]);
const value = useMemo(
() => ({ style, colorScheme, setStyle, setColorScheme }),
[style, colorScheme, setStyle, setColorScheme],
);
const value = useMemo(() => ({
style: style.value,
colorScheme: scheme.value,
setStyle: style.set,
setColorScheme: scheme.set,
}), [style.value, scheme.value, style.set, scheme.set]);
return (
<ThemeContext.Provider value={value}>

View File

@@ -0,0 +1,95 @@
/**
* Holds one half of the theme, the style or the color scheme, for
* `ThemeProvider`, which uses it once for each. Both halves work alike: the
* account's choice wins, then this browser's, then a fallback; the result is
* applied to the root element and mirrored to localStorage, where index.html's
* first-paint script reads it.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { useSettings } from "../auth/useSettings.ts";
/**
* Passed to {@link useThemeChoice} by `ThemeProvider`, as a constant: one for
* the style, one for the scheme.
*/
export interface ThemeChoiceSpec<T extends string> {
/** The localStorage key, which index.html's script reads too. */
storageKey: string;
allowed: readonly T[];
/** The account setting that holds the choice. */
setting: "style" | "colorScheme";
/** What applies when nobody chose. */
fallback: T;
/** The root element's attribute the stylesheets key on. */
attribute: string;
/** The value the stylesheets draw without the attribute, which is then left out. */
base: T;
}
/** Returned by {@link useThemeChoice} to `ThemeProvider`. */
export interface UseThemeChoiceResult<T extends string> {
value: T;
/** This browser's own choice, which an account without one takes. */
local: T | null;
/** Applies `next`, stores it here, and saves it to the account when signed in. */
set: (next: T) => void;
}
function readChoice<T extends string>(
key: string,
allowed: readonly T[],
): T | null {
try {
const stored = localStorage.getItem(key);
return (allowed as readonly string[]).includes(stored ?? "")
? stored as T
: null;
} catch {
return null;
}
}
function writeChoice(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Blocked storage only costs the choice at the next visit.
}
}
/**
* Returns the half of the theme `spec` describes. An unknown stored value,
* such as "default", which "smooth" was once stored as, reads as no choice.
*/
export function useThemeChoice<T extends string>(
spec: ThemeChoiceSpec<T>,
): UseThemeChoiceResult<T> {
const { settings, updateSettings } = useSettings();
const [local, setLocal] = useState<T | null>(() =>
readChoice(spec.storageKey, spec.allowed)
);
const account = (settings?.[spec.setting] ?? null) as T | null;
useEffect(() => {
if (account) writeChoice(spec.storageKey, account);
}, [account, spec.storageKey]);
const value = account ?? local ?? spec.fallback;
useEffect(() => {
const el = document.documentElement;
if (value === spec.base) el.removeAttribute(spec.attribute);
else el.setAttribute(spec.attribute, value);
}, [value, spec.base, spec.attribute]);
const signedIn = settings !== null;
const set = useCallback((next: T) => {
setLocal(next);
writeChoice(spec.storageKey, next);
if (signedIn) updateSettings({ [spec.setting]: next }).catch(() => {});
}, [signedIn, updateSettings, spec.storageKey, spec.setting]);
return { value, local, set };
}

View File

@@ -7,20 +7,14 @@
*/
import { i18n } from "@lingui/core";
/** The locales the interface is translated to, one `.po` file each. */
export const SUPPORTED: readonly ["en", "fr"] = ["en", "fr"] as const;
/** One of the {@link SUPPORTED} locales. */
export type Locale = (typeof SUPPORTED)[number];
import { isLocale, type Locale } from "../shared/locale.ts";
/**
* Returns the stored locale, else French for a French browser, else English.
*/
function detectLocale(): Locale {
const stored = localStorage.getItem("locale");
if (stored && (SUPPORTED as readonly string[]).includes(stored)) {
return stored as Locale;
}
if (isLocale(stored)) return stored;
return navigator.language.startsWith("fr") ? "fr" : "en";
}
@@ -32,7 +26,7 @@ function detectLocale(): Locale {
export async function loadCatalog(
locale: Locale = detectLocale(),
): Promise<void> {
const { messages } = await import(`./locales/${locale}.po`);
const { messages } = await import(`../locales/${locale}.po`);
i18n.load(locale, messages);
i18n.activate(locale);
localStorage.setItem("locale", locale);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

127
src/pages/Unsubscribe.tsx Normal file
View File

@@ -0,0 +1,127 @@
/**
* Renders the page a digest's unsubscribe link opens, where the reader turns
* that digest off with one click, signed in or not.
*
* @module
*/
import { useState } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../config/api.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { FormError } from "../components/form/FormError.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { expectOk } from "../utils/shared/api.ts";
import { readUnsubscribeCadence } from "../utils/auth/unsubscribe.ts";
/**
* Renders `/unsubscribe?token=`, for anyone holding a digest's link. It asks
* before turning the digest off, since mail scanners open every link in an
* email, then posts the token to `POST /api/users/unsubscribe`.
*/
export function Unsubscribe(): React.JSX.Element {
useDocumentTitle(t`Unsubscribe`);
const [params] = useSearchParams();
const { user } = useAuth();
const token = params.get("token") ?? "";
const cadence = readUnsubscribeCadence(token);
const [done, setDone] = useState(false);
const form = useApiForm<Record<string, never>>({ defaultValues: {} });
const onSubmit = form.submit(async () => {
await expectOk(
await fetch(
`${API_URL}/api/users/unsubscribe?token=${encodeURIComponent(token)}`,
{ method: "POST" },
),
);
setDone(true);
});
const settingsLink = user && (
<p className="auth-card-footer">
<Link to={`/users/${user.username}/~/settings`}>
<Trans>Your settings</Trans>
</Link>
</p>
);
if (!cadence) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Invalid link</Trans>
</h1>
<p>
<Trans>This unsubscribe link is missing or malformed.</Trans>
</p>
{settingsLink}
</div>
</PageShell>
);
}
const weekly = cadence === "weekly";
if (done) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Unsubscribed</Trans>
</h1>
<p>
{weekly
? (
<Trans>
You won't get the weekly digest any more. You can turn it back
on in your settings.
</Trans>
)
: (
<Trans>
You won't get the monthly recap any more. You can turn it back
on in your settings.
</Trans>
)}
</p>
{settingsLink}
</div>
</PageShell>
);
}
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
{weekly
? <Trans>Turn off the weekly digest?</Trans>
: <Trans>Turn off the monthly recap?</Trans>}
</h1>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Couldn't unsubscribe`} />
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
{weekly
? <Trans id="digest.weekly.off">Turn off weekly digests</Trans>
: (
<Trans id="digest.monthly.off">
Turn off monthly recaps
</Trans>
)}
</SubmitButton>
</form>
</FormProvider>
{settingsLink}
</div>
</PageShell>
);
}

View File

@@ -11,17 +11,22 @@ import { AUTH_STORAGE_KEY } from "../utils/auth/storage.ts";
let seq = 0;
const nextId = (prefix: string) => `${prefix}-${++seq}`;
/** An unsigned JWT-shaped token; the client only ever reads `exp`. */
export function makeToken(
{ expiresIn = 3600, sub = "u" }: { expiresIn?: number; sub?: string } = {},
): string {
/** An unsigned JWT-shaped token carrying `payload`, which the client reads. */
export function makeJwt(payload: object): string {
const b64 = (o: unknown) =>
btoa(JSON.stringify(o)).replace(/=+$/, "").replace(/\+/g, "-").replace(
/\//g,
"_",
);
return `${b64({ alg: "HS256" })}.${b64(payload)}.sig`;
}
/** A session token; the client only ever reads `exp`. */
export function makeToken(
{ expiresIn = 3600, sub = "u" }: { expiresIn?: number; sub?: string } = {},
): string {
const exp = Math.floor(Date.now() / 1000) + expiresIn;
return `${b64({ alg: "HS256" })}.${b64({ sub, exp, n: ++seq })}.sig`;
return makeJwt({ sub, exp, n: ++seq });
}
export function makeUser(over: Partial<PublicUser> = {}): PublicUser {

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Unsubscribe } from "../../pages/Unsubscribe.tsx";
import { calls, fail, route } from "../fetch.ts";
import { renderWithContexts } from "../render.tsx";
import { makeJwt } from "../fixtures.ts";
describe("Unsubscribe", () => {
function setup(token: string) {
const user = userEvent.setup();
renderWithContexts(<Unsubscribe />, {
route: `/unsubscribe?token=${encodeURIComponent(token)}`,
path: "/unsubscribe",
});
return user;
}
test("asks first, then turns the named digest off", async () => {
const token = makeJwt({
purpose: "unsubscribe",
userId: "u",
cadence: "monthly",
});
route("POST /api/users/unsubscribe", () => ({ cadence: "monthly" }));
const user = setup(token);
expect(
screen.getByRole("heading", { name: "Turn off the monthly recap?" }),
).toBeInTheDocument();
expect(calls("POST /api/users/unsubscribe")).toHaveLength(0);
await user.click(
screen.getByRole("button", { name: "Turn off monthly recaps" }),
);
expect(await screen.findByRole("heading", { name: "Unsubscribed" }))
.toBeInTheDocument();
expect(
calls("POST /api/users/unsubscribe")[0].url.searchParams.get("token"),
)
.toBe(token);
});
test("a refusal shows why", async () => {
route(
"POST /api/users/unsubscribe",
() => fail("Invalid unsubscribe link", 400),
);
const user = setup(
makeJwt({ purpose: "unsubscribe", userId: "u", cadence: "weekly" }),
);
await user.click(
screen.getByRole("button", { name: "Turn off weekly digests" }),
);
expect(await screen.findByText("Invalid unsubscribe link"))
.toBeInTheDocument();
});
test("a token that isn't an unsubscribe one is refused before asking", () => {
setup(makeJwt({ purpose: "media", userId: "u" }));
expect(screen.getByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument();
});
});

View File

@@ -4,7 +4,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, beforeEach, vi } from "vitest";
import { i18n } from "@lingui/core";
import { messages } from "../locales/en.po";
import { messages } from "../../locales/en.po";
import { FakeWebSocket } from "./ws.ts";
import { resetFetch } from "./fetch.ts";

View File

@@ -0,0 +1,29 @@
/**
* Reads which digest an emailed unsubscribe link turns off, so that the
* `/unsubscribe` page can name it before the reader confirms. The server
* checks the token's signature when the page posts it; this only reads it.
*
* @module
*/
import {
type DigestCadence,
isDigestCadence,
} from "../../../shared/settings.ts";
/**
* Returns the cadence `token` names, or null when it isn't an unsubscribe
* token at all.
*/
export function readUnsubscribeCadence(token: string): DigestCadence | null {
try {
const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(atob(part));
return payload?.purpose === "unsubscribe" &&
isDigestCadence(payload.cadence)
? payload.cadence
: null;
} catch {
return null;
}
}

View File

@@ -5,12 +5,12 @@ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { brotliCompressSync, constants as zlib, gzipSync } from "node:zlib";
import linguiConfig from "./lingui.config.ts";
import { isStyleName } from "./shared/theme.ts";
import { BASE_STYLE, isStyleName } from "./shared/theme.ts";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed";
const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : "smooth";
const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : BASE_STYLE;
const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off";
// Cache-busting token for the favicon URLs (see index.html). Changes when the

View File

@@ -17,7 +17,7 @@ export default mergeConfig(
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/test/**", "src/locales/**"],
exclude: ["src/test/**"],
reporter: ["text", "html", "lcov"],
reportsDirectory: "coverage/web",
},