v4: every message named by an id carries its English in the code — the emails and the four pages that share their words called Lingui with a bare id (i18n._("digest.community"), <Trans id="digest.community" />), so their English lived only in a hand-kept en.po, extract couldn't run with --overwrite, and a page reading the code couldn't tell what it said. The emails now pass descriptors, i18n._({ id, message, values }), the form Lingui reads without a macro, as the server needs. The fifteen messages the digest shares with the community page, Settings, the unsubscribe page and the avatar menu are declared once, as /*i18n*/ descriptors in shared/digest-messages.ts, which those pages translate with useLingui()'s i18n instead of the global one. lingui.config.ts scans shared/ too, and i18n:extract runs with --overwrite, so en.po is written from the code. Every other message already used the macros. The compiled catalogs are unchanged, key for key, in both languages. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 13:19:01 +00:00
parent c3594e4c27
commit c709b56911
13 changed files with 1252 additions and 959 deletions

View File

@@ -12,6 +12,7 @@
import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
import type { DigestCadence } from "../../shared/settings.ts";
import { i18n } from "./i18n.ts";
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
import {
EmailKit,
type EmailLook,
@@ -127,17 +128,33 @@ const ACTIVITY_ORDER: ActivityKind[] = [
function countLabel(kind: ActivityKind, n: number): string {
switch (kind) {
case "upvotes":
return i18n._("digest.count.upvotes", { n });
return i18n._({ ...DIGEST_MESSAGES.countUpvotes, values: { n } });
case "followers":
return i18n._("email.digest.count.followers", { n });
return i18n._({
id: "email.digest.count.followers",
message: "{n, plural, one {new follower} other {new followers}}",
values: { n },
});
case "comments":
return i18n._("digest.count.comments", { n });
return i18n._({ ...DIGEST_MESSAGES.countComments, values: { n } });
case "mentions":
return i18n._("email.digest.count.mentions", { n });
return i18n._({
id: "email.digest.count.mentions",
message: "{n, plural, one {mention} other {mentions}}",
values: { n },
});
case "likes":
return i18n._("email.digest.count.likes", { n });
return i18n._({
id: "email.digest.count.likes",
message: "{n, plural, one {comment like} other {comment likes}}",
values: { n },
});
case "playlistFollows":
return i18n._("email.digest.count.playlistFollows", { n });
return i18n._({
id: "email.digest.count.playlistFollows",
message: "{n, plural, one {playlist follow} other {playlist follows}}",
values: { n },
});
}
}
@@ -153,36 +170,87 @@ function highlightSentence(
const values = { who, target };
switch (kind) {
case "mentionComment":
return i18n._("email.digest.hl.mentionComment", values);
return i18n._({
id: "email.digest.hl.mentionComment",
message: "{who} mentioned you in a comment on {target}",
values,
});
case "mentionDump":
case "mentionChat":
return i18n._("email.digest.hl.mentionIn", values);
return i18n._({
id: "email.digest.hl.mentionIn",
message: "{who} mentioned you in {target}",
values,
});
case "mentionPlaylist":
return i18n._("email.digest.hl.mentionPlaylist", values);
return i18n._({
id: "email.digest.hl.mentionPlaylist",
message: "{who} mentioned you in the playlist {target}",
values,
});
case "mentionEveryone":
return i18n._("email.digest.hl.mentionEveryone", values);
return i18n._({
id: "email.digest.hl.mentionEveryone",
message: "{who} mentioned everyone in {target}",
values,
});
case "comment":
return i18n._("email.digest.hl.comment", values);
return i18n._({
id: "email.digest.hl.comment",
message: "{who} commented on your dump {target}",
values,
});
case "playlistFollow":
return i18n._("email.digest.hl.playlistFollow", values);
return i18n._({
id: "email.digest.hl.playlistFollow",
message: "{who} followed your playlist {target}",
values,
});
case "userFollow":
return i18n._("email.digest.hl.userFollow", values);
return i18n._({
id: "email.digest.hl.userFollow",
message: "{who} started following you",
values,
});
case "commentLike":
return i18n._("email.digest.hl.commentLike", values);
return i18n._({
id: "email.digest.hl.commentLike",
message: "{who} liked your comment on {target}",
values,
});
}
}
const byLine = (username: string) => i18n._("email.digest.by", { username });
const byLine = (username: string) =>
i18n._({
id: "email.digest.by",
message: "by @{username}",
values: { username },
});
const addedToLine = (playlist: string) =>
i18n._("email.digest.addedTo", { playlist });
const uploadLabel = () => i18n._("email.digest.upload");
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._("digest.invitedByYou")
: i18n._("digest.invitedBy", { username: inviter });
const andMoreLine = (n: number) => i18n._("email.digest.andMore", { n });
? i18n._(DIGEST_MESSAGES.invitedByYou)
: i18n._({ ...DIGEST_MESSAGES.invitedBy, 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._("email.digest.playlistMeta", { username: owner, n });
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
@@ -205,7 +273,7 @@ export function renderDigestEmail(
new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d);
const figure = (n: number, label: string) =>
`**${nf.format(n)}** ${escMd(label)}`;
const yours = i18n._("email.digest.yours");
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}) ` : ""}${
@@ -223,16 +291,26 @@ export function renderDigestEmail(
const md: string[] = [
`*${
escMd(
`${monthly ? i18n._("digest.monthly") : i18n._("digest.weekly")} · ${
range(true)
}`,
`${
monthly
? i18n._(DIGEST_MESSAGES.monthly)
: i18n._(DIGEST_MESSAGES.weekly)
} · ${range(true)}`,
)
}*`,
`# ${
escMd(
monthly
? i18n._("email.digest.monthly.title", { site: OG_SITE_NAME })
: i18n._("email.digest.weekly.title", { username: content.reader }),
? 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 },
}),
)
}`,
];
@@ -240,7 +318,7 @@ export function renderDigestEmail(
const { activity, network, community } = content;
if (activity) {
md.push(
`## ${escMd(i18n._("email.digest.forYou"))}`,
`## ${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]!))
@@ -249,7 +327,7 @@ export function renderDigestEmail(
activity.highlights.map((h) => {
const target = h.kind === "mentionChat" || h.kind === "mentionEveryone"
? mdLink(
i18n._("email.digest.theChat"),
i18n._({ id: "email.digest.theChat", message: "the chat" }),
content.links.chat,
)
: h.target?.url
@@ -264,7 +342,12 @@ export function renderDigestEmail(
}).join("\n"),
`${
mdLink(
i18n._("email.digest.seeAll", { n: activity.total }),
i18n._({
id: "email.digest.seeAll",
message:
"{n, plural, one {See the notification} other {See all # notifications}}",
values: { n: activity.total },
}),
content.links.notifications,
)
} →`,
@@ -273,13 +356,24 @@ export function renderDigestEmail(
if (network) {
md.push(
`## ${escMd(i18n._("email.digest.network"))}`,
`## ${
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._("email.digest.more", { n: network.more }),
i18n._({
id: "email.digest.more",
message: "{n} more on your Followed feed",
values: { n: network.more },
}),
content.links.followed,
)
} →`);
@@ -291,7 +385,9 @@ export function renderDigestEmail(
md.push(
`## ${
escMd(
monthly ? i18n._("digest.topMonth") : i18n._("digest.topWeek"),
monthly
? i18n._(DIGEST_MESSAGES.topMonth)
: i18n._(DIGEST_MESSAGES.topWeek),
)
}`,
);
@@ -307,20 +403,23 @@ export function renderDigestEmail(
if (community) {
const { dumps, upvotes, comments } = community.totals;
md.push(
`## ${escMd(i18n._("digest.community"))}`,
`## ${escMd(i18n._(DIGEST_MESSAGES.community))}`,
);
if (monthly) {
md.push(
[
figure(
dumps,
i18n._("digest.stat.dumps", { n: dumps }),
i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } }),
),
figure(upvotes, countLabel("upvotes", upvotes)),
figure(comments, countLabel("comments", comments)),
figure(
community.chatMessages,
i18n._("digest.stat.chat", { n: community.chatMessages }),
i18n._({
...DIGEST_MESSAGES.statChat,
values: { n: community.chatMessages },
}),
),
].join(" · "),
);
@@ -329,7 +428,7 @@ export function renderDigestEmail(
md.push(
`### ${
escMd(
i18n._("digest.newMembers"),
i18n._(DIGEST_MESSAGES.newMembers),
)
}`,
[
@@ -348,7 +447,7 @@ export function renderDigestEmail(
}
if (community.playlists.length > 0) {
md.push(
`### ${escMd(i18n._("digest.newPlaylists"))}`,
`### ${escMd(i18n._(DIGEST_MESSAGES.newPlaylists))}`,
[
...community.playlists.map((p) =>
`- ${mdLink(p.title, p.url)} · ${
@@ -363,12 +462,21 @@ export function renderDigestEmail(
}
if (!monthly && community.chatMessages > 0) {
md.push(
`### ${escMd(i18n._("email.digest.inChat"))}`,
`### ${
escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" }))
}`,
`${
escMd(i18n._("email.digest.chatWeek", { n: community.chatMessages }))
escMd(
i18n._({
id: "email.digest.chatWeek",
message:
"{n, plural, one {# message this week} other {# messages this week}}",
values: { n: community.chatMessages },
}),
)
} · ${
mdLink(
i18n._("email.digest.openChat"),
i18n._({ id: "email.digest.openChat", message: "Open the chat" }),
content.links.chat,
)
}`,
@@ -377,13 +485,21 @@ export function renderDigestEmail(
if (!monthly && dumps + upvotes + comments > 0) {
md.push(
`*${
escMd(i18n._("email.digest.totals", { dumps, upvotes, comments }))
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(`${
mdLink(
i18n._("email.digest.communityPage"),
i18n._({
id: "email.digest.communityPage",
message: "See it all on the community page",
}),
content.links.community,
)
} →`);
@@ -393,17 +509,27 @@ export function renderDigestEmail(
"---",
escMd(
monthly
? i18n._("email.digest.monthly.reason")
: i18n._("email.digest.weekly.reason"),
? 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._("digest.monthly.off") : i18n._("digest.weekly.off"),
monthly
? i18n._(DIGEST_MESSAGES.monthlyOff)
: i18n._(DIGEST_MESSAGES.weeklyOff),
content.links.unsubscribe,
)
} · ${
mdLink(
i18n._("email.settings"),
i18n._({ id: "email.settings", message: "Settings" }),
content.links.settings,
)
}`,
@@ -411,13 +537,15 @@ export function renderDigestEmail(
return new EmailKit(look).render({
subject: monthly
? i18n._("email.digest.monthly.subject", {
site: OG_SITE_NAME,
range: range(false),
? i18n._({
id: "email.digest.monthly.subject",
message: "The month on {site}, {range}",
values: { site: OG_SITE_NAME, range: range(false) },
})
: i18n._("email.digest.weekly.subject", {
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"),
@@ -438,7 +566,14 @@ function preheader(content: DigestContent): string {
const n = (content.network?.dumps.length ?? 0) +
(content.network?.more ?? 0);
if (n > 0) {
parts.push(i18n._("email.digest.pre.network", { n }));
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) +
@@ -447,18 +582,42 @@ function preheader(content: DigestContent): string {
(content.community?.playlistsMore ?? 0);
if (content.top.length > 0) {
parts.push(
i18n._("email.digest.pre.topMonth", { n: content.top.length }),
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._("email.digest.pre.members", { n: members }));
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._("email.digest.pre.playlists", { n: playlists }));
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._("email.digest.pre.topWeek", { n: content.top.length }));
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" })

View File

@@ -1,12 +1,13 @@
/**
* Lets the emails speak the instance's language from the same catalogs as
* the interface. A template names each message by its id, as
* `i18n._("email.reset.title", values)`, and its words, English included,
* are written in `locales/<lang>.po` only. `lingui extract` lists the ids it
* finds there beside the interface's messages, and `lingui compile` turns
* the catalogs into the `locales/<lang>.mjs` imported here, failing when a
* language lacks one. The ids are named rather than hashed from the English,
* since the server runs no Lingui macro.
* the interface. A template passes each message as a descriptor holding its
* id and its English, as `i18n._({ id: "email.reset.title", message: "Reset
* your password" })`, or one of `shared/digest-messages.ts`, which a page
* says too. `lingui extract` reads them into `locales/<lang>.po` beside the
* interface's messages, and `lingui compile` turns the catalogs into the
* `locales/<lang>.mjs` imported here, failing when a language lacks one. The
* ids are named rather than hashed from the English, since the server runs
* no Lingui macro.
*
* @module
*/

View File

@@ -26,23 +26,67 @@ export function renderPasswordResetEmail(
hours: number,
): RenderedEmail {
const markdown = [
`# ${escMd(i18n._("email.reset.title"))}`,
escMd(i18n._("email.reset.hi", { username })),
escMd(i18n._("email.reset.body", { site: OG_SITE_NAME, hours })),
`# ${
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._("email.reset.button"),
i18n._({ id: "email.reset.button", message: "Reset password" }),
resetUrl,
),
`*${escMd(i18n._("email.reset.fallback"))}*`,
`*${
escMd(
i18n._({
id: "email.reset.fallback",
message:
"If the button doesn't work, paste this link into your browser:",
}),
)
}*`,
"```\n" + resetUrl + "\n```",
`*${escMd(i18n._("email.reset.ignore"))}*`,
`*${
escMd(
i18n._({
id: "email.reset.ignore",
message:
"If you didn't ask for this, ignore this email. Your password stays the same.",
}),
)
}*`,
"---",
escMd(i18n._("email.reset.reason")),
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._("email.reset.subject", { site: OG_SITE_NAME }),
preheader: i18n._("email.reset.preheader", { hours }),
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

@@ -29,24 +29,52 @@ export function renderWelcomeEmail(username: string): RenderedEmail {
? WELCOME_EMAIL_BODY
.replaceAll("{{username}}", escMd(username))
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
: i18n._("email.welcome.body", {
site: escMd(OG_SITE_NAME),
username: escMd(username),
: 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._("email.welcome.button", { site: OG_SITE_NAME }),
i18n._({
id: "email.welcome.button",
message: "Open {site}",
values: { site: OG_SITE_NAME },
}),
FRONTEND_URL,
),
`*${escMd(i18n._("email.welcome.digests"))}*`,
`*${
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._("email.welcome.reason", { site: OG_SITE_NAME })),
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 new EmailKit(lookOf()).render({
subject: i18n._("email.welcome.subject", { site: OG_SITE_NAME }),
preheader: i18n._("email.welcome.preheader"),
subject: i18n._({
id: "email.welcome.subject",
message: "Welcome to {site}",
values: { site: OG_SITE_NAME },
}),
preheader: i18n._({
id: "email.welcome.preheader",
message: "Your account is ready.",
}),
markdown,
});
}

View File

@@ -5,7 +5,7 @@
"server:start": "deno run --env-file -A --watch api/main.ts",
"icons": "deno run --env-file -A api/scripts/generate-icons.ts",
"serve": "deno task build && deno task icons dist && deno task server:start",
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --workers 1",
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --strict --workers 1",
"start": "deno task icons dist && deno run --env-file -A api/main.ts",
"test": "deno test -A api/ shared/",

View File

@@ -7,12 +7,12 @@ const config: LinguiConfig = {
sourceLocale: SOURCE_LOCALE,
catalogs: [
{
// One catalog per language for the whole app: the interface's messages
// and the emails' (api/mail/), which the server imports compiled. A
// message named by id has its English in en.po only, which is why
// extract runs without --overwrite (it would write the id there).
// One catalog per language for the whole app: the interface's messages,
// the emails' (api/mail/), which the server imports compiled, and those
// both say (shared/digest-messages.ts). Every message's English is in
// the code, so extract --overwrite writes en.po from it.
path: "locales/{locale}",
include: ["src", "api/mail"],
include: ["src", "api/mail", "shared"],
},
],
format: formatter(),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

80
shared/digest-messages.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Words the digest emails share with the pages that show or govern them: the
* `/community` page shows the digests' figures and sections, Settings and the
* unsubscribe page name the digests, the avatar menu links the community
* page. `api/mail/digest.ts` and those pages translate them from here, so an
* email and a page always say the same.
*
* Each is a Lingui message descriptor with an explicit id, since the server
* runs no macro; the `i18n` comment has `lingui extract` read its English into
* the catalogs. Pass one to `i18n._`, with its values: on the client, the
* `i18n` of `useLingui()`.
*
* @module
*/
import type { MessageDescriptor } from "@lingui/core";
/** The shared messages, by name. */
export const DIGEST_MESSAGES = {
weekly: /*i18n*/ {
id: "digest.weekly",
message: "Weekly digest",
},
monthly: /*i18n*/ {
id: "digest.monthly",
message: "Monthly recap",
},
weeklyOff: /*i18n*/ {
id: "digest.weekly.off",
message: "Turn off weekly digests",
},
monthlyOff: /*i18n*/ {
id: "digest.monthly.off",
message: "Turn off monthly recaps",
},
community: /*i18n*/ {
id: "digest.community",
message: "Community",
},
topWeek: /*i18n*/ {
id: "digest.topWeek",
message: "Top this week",
},
topMonth: /*i18n*/ {
id: "digest.topMonth",
message: "Top this month",
},
newMembers: /*i18n*/ {
id: "digest.newMembers",
message: "New members",
},
newPlaylists: /*i18n*/ {
id: "digest.newPlaylists",
message: "New public playlists",
},
invitedBy: /*i18n*/ {
id: "digest.invitedBy",
message: "invited by @{username}",
},
invitedByYou: /*i18n*/ {
id: "digest.invitedByYou",
message: "invited by you",
},
statDumps: /*i18n*/ {
id: "digest.stat.dumps",
message: "{n, plural, one {dump} other {dumps}}",
},
statChat: /*i18n*/ {
id: "digest.stat.chat",
message: "{n, plural, one {chat message} other {chat messages}}",
},
countUpvotes: /*i18n*/ {
id: "digest.count.upvotes",
message: "{n, plural, one {upvote} other {upvotes}}",
},
countComments: /*i18n*/ {
id: "digest.count.comments",
message: "{n, plural, one {comment} other {comments}}",
},
} as const satisfies Record<string, MessageDescriptor>;

View File

@@ -10,7 +10,8 @@ import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Trans as Message } from "@lingui/react";
import { useLingui } from "@lingui/react";
import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
import { can } from "../../utils/auth/permissions.ts";
@@ -29,6 +30,7 @@ export interface UserMenuProps {
* Escape closes it.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const { i18n } = useLingui();
const { logout } = useAuth();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -91,7 +93,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
role="menuitem"
onClick={() => setOpen(false)}
>
<Message id="digest.community" />
{i18n._(DIGEST_MESSAGES.community)}
</Link>
<Link
to="/settings"

View File

@@ -9,10 +9,10 @@
*/
import { useCallback } from "react";
import { i18n } from "@lingui/core";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Trans as Message } from "@lingui/react";
import { useLingui } from "@lingui/react";
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
import type { Wire } from "../../shared/api.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type { DigestCadence } from "../../shared/settings.ts";
@@ -43,14 +43,24 @@ function isQuiet(stats: CommunityStats): boolean {
/** Renders the period's four figures, each over its label. */
function Figures({ stats }: { stats: CommunityStats }) {
const { i18n } = useLingui();
const { dumps, upvotes, comments } = stats.totals;
const figures: [number, string][] = [
[dumps, i18n._("digest.stat.dumps", { n: dumps })],
[upvotes, i18n._("digest.count.upvotes", { n: upvotes })],
[comments, i18n._("digest.count.comments", { n: comments })],
[dumps, i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } })],
[
upvotes,
i18n._({ ...DIGEST_MESSAGES.countUpvotes, values: { n: upvotes } }),
],
[
comments,
i18n._({ ...DIGEST_MESSAGES.countComments, values: { n: comments } }),
],
[
stats.chatMessages,
i18n._("digest.stat.chat", { n: stats.chatMessages }),
i18n._({
...DIGEST_MESSAGES.statChat,
values: { n: stats.chatMessages },
}),
],
];
return (
@@ -70,7 +80,8 @@ function Figures({ stats }: { stats: CommunityStats }) {
* A view no period names is `NotFound`.
*/
export function Community(): React.JSX.Element {
useDocumentTitle(i18n._("digest.community"));
const { i18n } = useLingui();
useDocumentTitle(i18n._(DIGEST_MESSAGES.community));
const { user, authFetch } = useAuth();
const [period, setPeriod] = useTabParam<DigestCadence>(
communityPath(),
@@ -97,7 +108,7 @@ export function Community(): React.JSX.Element {
return (
<PageShell narrow>
<PageHeader icon="🏘️" title={<Message id="digest.community" />} />
<PageHeader icon="🏘️" title={i18n._(DIGEST_MESSAGES.community)} />
<TabBar
tabs={[
{ key: "weekly", label: <Trans>This week</Trans> },
@@ -122,15 +133,15 @@ export function Community(): React.JSX.Element {
<Figures stats={data} />
{data.top.length > 0 && (
<PageGroup
label={weekly
? <Message id="digest.topWeek" />
: <Message id="digest.topMonth" />}
label={i18n._(
weekly ? DIGEST_MESSAGES.topWeek : DIGEST_MESSAGES.topMonth,
)}
>
<DumpCardList dumps={data.top} />
</PageGroup>
)}
{data.members.length > 0 && (
<PageGroup label={<Message id="digest.newMembers" />}>
<PageGroup label={i18n._(DIGEST_MESSAGES.newMembers)}>
<ul className="user-card-list">
{data.members.map((m) => (
<UserCard
@@ -138,20 +149,18 @@ export function Community(): React.JSX.Element {
user={m}
note={m.invitedByUsername &&
(m.invitedByUsername === user?.username
? <Message id="digest.invitedByYou" />
: (
<Message
id="digest.invitedBy"
values={{ username: m.invitedByUsername }}
/>
))}
? i18n._(DIGEST_MESSAGES.invitedByYou)
: i18n._({
...DIGEST_MESSAGES.invitedBy,
values: { username: m.invitedByUsername },
}))}
/>
))}
</ul>
</PageGroup>
)}
{data.playlists.length > 0 && (
<PageGroup label={<Message id="digest.newPlaylists" />}>
<PageGroup label={i18n._(DIGEST_MESSAGES.newPlaylists)}>
<PlaylistCardList playlists={data.playlists} />
</PageGroup>
)}

View File

@@ -10,7 +10,8 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
// A label shared with the emails is named by its id, its words being in
// locales/ only; the macro's Trans takes the English in place.
import { Trans as Message } from "@lingui/react";
import { useLingui } from "@lingui/react";
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
import type { ColorScheme, StyleName } from "../../shared/theme.ts";
import type { FeedTab } from "../../shared/paths.ts";
import { useTheme } from "../hooks/theme/useTheme.ts";
@@ -67,6 +68,7 @@ const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
* default feed tab and sounds, for now, to this browser.
*/
export function Settings(): React.JSX.Element {
const { i18n } = useLingui();
useDocumentTitle(t`Settings`);
const { user } = useRequiredAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
@@ -142,7 +144,7 @@ export function Settings(): React.JSX.Element {
</PageGroup>
<PageGroup label={<Trans>Email</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Message id="digest.weekly" />}>
<SettingRow label={i18n._(DIGEST_MESSAGES.weekly)}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestWeekly ?? true}
@@ -151,7 +153,7 @@ export function Settings(): React.JSX.Element {
updateSettings({ digestWeekly: on }).catch(() => {})}
/>
</SettingRow>
<SettingRow label={<Message id="digest.monthly" />}>
<SettingRow label={i18n._(DIGEST_MESSAGES.monthly)}>
<Segmented
options={ON_OFF_OPTIONS}
value={settings?.digestMonthly ?? true}

View File

@@ -11,7 +11,8 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
// A label shared with the emails is named by its id, its words being in
// locales/ only; the macro's Trans takes the English in place.
import { Trans as Message } from "@lingui/react";
import { useLingui } from "@lingui/react";
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../config/api.ts";
@@ -30,6 +31,7 @@ import { readUnsubscribeCadence } from "../utils/auth/unsubscribe.ts";
* email, then posts the token to `POST /api/unsubscribe`.
*/
export function Unsubscribe(): React.JSX.Element {
const { i18n } = useLingui();
useDocumentTitle(t`Unsubscribe`);
const [params] = useSearchParams();
const { user } = useAuth();
@@ -113,9 +115,9 @@ export function Unsubscribe(): React.JSX.Element {
<form onSubmit={onSubmit} className="form">
<FormError title={t`Couldn't unsubscribe`} />
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
{weekly
? <Message id="digest.weekly.off" />
: <Message id="digest.monthly.off" />}
{i18n._(
weekly ? DIGEST_MESSAGES.weeklyOff : DIGEST_MESSAGES.monthlyOff,
)}
</SubmitButton>
</form>
</FormProvider>