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:
@@ -12,6 +12,7 @@
|
|||||||
import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
|
import { EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts";
|
||||||
import type { DigestCadence } from "../../shared/settings.ts";
|
import type { DigestCadence } from "../../shared/settings.ts";
|
||||||
import { i18n } from "./i18n.ts";
|
import { i18n } from "./i18n.ts";
|
||||||
|
import { DIGEST_MESSAGES } from "../../shared/digest-messages.ts";
|
||||||
import {
|
import {
|
||||||
EmailKit,
|
EmailKit,
|
||||||
type EmailLook,
|
type EmailLook,
|
||||||
@@ -127,17 +128,33 @@ const ACTIVITY_ORDER: ActivityKind[] = [
|
|||||||
function countLabel(kind: ActivityKind, n: number): string {
|
function countLabel(kind: ActivityKind, n: number): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "upvotes":
|
case "upvotes":
|
||||||
return i18n._("digest.count.upvotes", { n });
|
return i18n._({ ...DIGEST_MESSAGES.countUpvotes, values: { n } });
|
||||||
case "followers":
|
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":
|
case "comments":
|
||||||
return i18n._("digest.count.comments", { n });
|
return i18n._({ ...DIGEST_MESSAGES.countComments, values: { n } });
|
||||||
case "mentions":
|
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":
|
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":
|
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 };
|
const values = { who, target };
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "mentionComment":
|
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 "mentionDump":
|
||||||
case "mentionChat":
|
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":
|
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":
|
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":
|
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":
|
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":
|
case "userFollow":
|
||||||
return i18n._("email.digest.hl.userFollow", values);
|
return i18n._({
|
||||||
|
id: "email.digest.hl.userFollow",
|
||||||
|
message: "{who} started following you",
|
||||||
|
values,
|
||||||
|
});
|
||||||
case "commentLike":
|
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) =>
|
const addedToLine = (playlist: string) =>
|
||||||
i18n._("email.digest.addedTo", { playlist });
|
i18n._({
|
||||||
const uploadLabel = () => i18n._("email.digest.upload");
|
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) =>
|
const invitedLine = (inviter: string, reader: string) =>
|
||||||
inviter === reader
|
inviter === reader
|
||||||
? i18n._("digest.invitedByYou")
|
? i18n._(DIGEST_MESSAGES.invitedByYou)
|
||||||
: i18n._("digest.invitedBy", { username: inviter });
|
: i18n._({ ...DIGEST_MESSAGES.invitedBy, values: { username: inviter } });
|
||||||
const andMoreLine = (n: number) => i18n._("email.digest.andMore", { n });
|
const andMoreLine = (n: number) =>
|
||||||
|
i18n._({
|
||||||
|
id: "email.digest.andMore",
|
||||||
|
message: "and {n} more",
|
||||||
|
values: { n },
|
||||||
|
});
|
||||||
const playlistMeta = (owner: string, n: number) =>
|
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
|
* 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);
|
new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d);
|
||||||
const figure = (n: number, label: string) =>
|
const figure = (n: number, label: string) =>
|
||||||
`**${nf.format(n)}** ${escMd(label)}`;
|
`**${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. */
|
/** A dump as a list item: its thumbnail, title, pill and detail. */
|
||||||
const dumpItem = (d: DigestDump, bullet: string, image = true) =>
|
const dumpItem = (d: DigestDump, bullet: string, image = true) =>
|
||||||
`${bullet} ${image && d.thumbnail ? ` ` : ""}${
|
`${bullet} ${image && d.thumbnail ? ` ` : ""}${
|
||||||
@@ -223,16 +291,26 @@ export function renderDigestEmail(
|
|||||||
const md: string[] = [
|
const md: string[] = [
|
||||||
`*${
|
`*${
|
||||||
escMd(
|
escMd(
|
||||||
`${monthly ? i18n._("digest.monthly") : i18n._("digest.weekly")} · ${
|
`${
|
||||||
range(true)
|
monthly
|
||||||
}`,
|
? i18n._(DIGEST_MESSAGES.monthly)
|
||||||
|
: i18n._(DIGEST_MESSAGES.weekly)
|
||||||
|
} · ${range(true)}`,
|
||||||
)
|
)
|
||||||
}*`,
|
}*`,
|
||||||
`# ${
|
`# ${
|
||||||
escMd(
|
escMd(
|
||||||
monthly
|
monthly
|
||||||
? i18n._("email.digest.monthly.title", { site: OG_SITE_NAME })
|
? i18n._({
|
||||||
: i18n._("email.digest.weekly.title", { username: content.reader }),
|
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;
|
const { activity, network, community } = content;
|
||||||
if (activity) {
|
if (activity) {
|
||||||
md.push(
|
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)
|
ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0)
|
||||||
.map((k) =>
|
.map((k) =>
|
||||||
figure(activity.counts[k]!, countLabel(k, activity.counts[k]!))
|
figure(activity.counts[k]!, countLabel(k, activity.counts[k]!))
|
||||||
@@ -249,7 +327,7 @@ export function renderDigestEmail(
|
|||||||
activity.highlights.map((h) => {
|
activity.highlights.map((h) => {
|
||||||
const target = h.kind === "mentionChat" || h.kind === "mentionEveryone"
|
const target = h.kind === "mentionChat" || h.kind === "mentionEveryone"
|
||||||
? mdLink(
|
? mdLink(
|
||||||
i18n._("email.digest.theChat"),
|
i18n._({ id: "email.digest.theChat", message: "the chat" }),
|
||||||
content.links.chat,
|
content.links.chat,
|
||||||
)
|
)
|
||||||
: h.target?.url
|
: h.target?.url
|
||||||
@@ -264,7 +342,12 @@ export function renderDigestEmail(
|
|||||||
}).join("\n"),
|
}).join("\n"),
|
||||||
`${
|
`${
|
||||||
mdLink(
|
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,
|
content.links.notifications,
|
||||||
)
|
)
|
||||||
} →`,
|
} →`,
|
||||||
@@ -273,13 +356,24 @@ export function renderDigestEmail(
|
|||||||
|
|
||||||
if (network) {
|
if (network) {
|
||||||
md.push(
|
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"),
|
network.dumps.map((d) => dumpItem(d, "-")).join("\n"),
|
||||||
);
|
);
|
||||||
if (network.more > 0) {
|
if (network.more > 0) {
|
||||||
md.push(`${
|
md.push(`${
|
||||||
mdLink(
|
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,
|
content.links.followed,
|
||||||
)
|
)
|
||||||
} →`);
|
} →`);
|
||||||
@@ -291,7 +385,9 @@ export function renderDigestEmail(
|
|||||||
md.push(
|
md.push(
|
||||||
`## ${
|
`## ${
|
||||||
escMd(
|
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) {
|
if (community) {
|
||||||
const { dumps, upvotes, comments } = community.totals;
|
const { dumps, upvotes, comments } = community.totals;
|
||||||
md.push(
|
md.push(
|
||||||
`## ${escMd(i18n._("digest.community"))}`,
|
`## ${escMd(i18n._(DIGEST_MESSAGES.community))}`,
|
||||||
);
|
);
|
||||||
if (monthly) {
|
if (monthly) {
|
||||||
md.push(
|
md.push(
|
||||||
[
|
[
|
||||||
figure(
|
figure(
|
||||||
dumps,
|
dumps,
|
||||||
i18n._("digest.stat.dumps", { n: dumps }),
|
i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } }),
|
||||||
),
|
),
|
||||||
figure(upvotes, countLabel("upvotes", upvotes)),
|
figure(upvotes, countLabel("upvotes", upvotes)),
|
||||||
figure(comments, countLabel("comments", comments)),
|
figure(comments, countLabel("comments", comments)),
|
||||||
figure(
|
figure(
|
||||||
community.chatMessages,
|
community.chatMessages,
|
||||||
i18n._("digest.stat.chat", { n: community.chatMessages }),
|
i18n._({
|
||||||
|
...DIGEST_MESSAGES.statChat,
|
||||||
|
values: { n: community.chatMessages },
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
].join(" · "),
|
].join(" · "),
|
||||||
);
|
);
|
||||||
@@ -329,7 +428,7 @@ export function renderDigestEmail(
|
|||||||
md.push(
|
md.push(
|
||||||
`### ${
|
`### ${
|
||||||
escMd(
|
escMd(
|
||||||
i18n._("digest.newMembers"),
|
i18n._(DIGEST_MESSAGES.newMembers),
|
||||||
)
|
)
|
||||||
}`,
|
}`,
|
||||||
[
|
[
|
||||||
@@ -348,7 +447,7 @@ export function renderDigestEmail(
|
|||||||
}
|
}
|
||||||
if (community.playlists.length > 0) {
|
if (community.playlists.length > 0) {
|
||||||
md.push(
|
md.push(
|
||||||
`### ${escMd(i18n._("digest.newPlaylists"))}`,
|
`### ${escMd(i18n._(DIGEST_MESSAGES.newPlaylists))}`,
|
||||||
[
|
[
|
||||||
...community.playlists.map((p) =>
|
...community.playlists.map((p) =>
|
||||||
`- ${mdLink(p.title, p.url)} · ${
|
`- ${mdLink(p.title, p.url)} · ${
|
||||||
@@ -363,12 +462,21 @@ export function renderDigestEmail(
|
|||||||
}
|
}
|
||||||
if (!monthly && community.chatMessages > 0) {
|
if (!monthly && community.chatMessages > 0) {
|
||||||
md.push(
|
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(
|
mdLink(
|
||||||
i18n._("email.digest.openChat"),
|
i18n._({ id: "email.digest.openChat", message: "Open the chat" }),
|
||||||
content.links.chat,
|
content.links.chat,
|
||||||
)
|
)
|
||||||
}`,
|
}`,
|
||||||
@@ -377,13 +485,21 @@ export function renderDigestEmail(
|
|||||||
if (!monthly && dumps + upvotes + comments > 0) {
|
if (!monthly && dumps + upvotes + comments > 0) {
|
||||||
md.push(
|
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(`${
|
md.push(`${
|
||||||
mdLink(
|
mdLink(
|
||||||
i18n._("email.digest.communityPage"),
|
i18n._({
|
||||||
|
id: "email.digest.communityPage",
|
||||||
|
message: "See it all on the community page",
|
||||||
|
}),
|
||||||
content.links.community,
|
content.links.community,
|
||||||
)
|
)
|
||||||
} →`);
|
} →`);
|
||||||
@@ -393,17 +509,27 @@ export function renderDigestEmail(
|
|||||||
"---",
|
"---",
|
||||||
escMd(
|
escMd(
|
||||||
monthly
|
monthly
|
||||||
? i18n._("email.digest.monthly.reason")
|
? i18n._({
|
||||||
: i18n._("email.digest.weekly.reason"),
|
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(
|
mdLink(
|
||||||
monthly ? i18n._("digest.monthly.off") : i18n._("digest.weekly.off"),
|
monthly
|
||||||
|
? i18n._(DIGEST_MESSAGES.monthlyOff)
|
||||||
|
: i18n._(DIGEST_MESSAGES.weeklyOff),
|
||||||
content.links.unsubscribe,
|
content.links.unsubscribe,
|
||||||
)
|
)
|
||||||
} · ${
|
} · ${
|
||||||
mdLink(
|
mdLink(
|
||||||
i18n._("email.settings"),
|
i18n._({ id: "email.settings", message: "Settings" }),
|
||||||
content.links.settings,
|
content.links.settings,
|
||||||
)
|
)
|
||||||
}`,
|
}`,
|
||||||
@@ -411,13 +537,15 @@ export function renderDigestEmail(
|
|||||||
|
|
||||||
return new EmailKit(look).render({
|
return new EmailKit(look).render({
|
||||||
subject: monthly
|
subject: monthly
|
||||||
? i18n._("email.digest.monthly.subject", {
|
? i18n._({
|
||||||
site: OG_SITE_NAME,
|
id: "email.digest.monthly.subject",
|
||||||
range: range(false),
|
message: "The month on {site}, {range}",
|
||||||
|
values: { site: OG_SITE_NAME, range: range(false) },
|
||||||
})
|
})
|
||||||
: i18n._("email.digest.weekly.subject", {
|
: i18n._({
|
||||||
site: OG_SITE_NAME,
|
id: "email.digest.weekly.subject",
|
||||||
range: range(false),
|
message: "Your week on {site}, {range}",
|
||||||
|
values: { site: OG_SITE_NAME, range: range(false) },
|
||||||
}),
|
}),
|
||||||
preheader: preheader(content),
|
preheader: preheader(content),
|
||||||
markdown: md.join("\n\n"),
|
markdown: md.join("\n\n"),
|
||||||
@@ -438,7 +566,14 @@ function preheader(content: DigestContent): string {
|
|||||||
const n = (content.network?.dumps.length ?? 0) +
|
const n = (content.network?.dumps.length ?? 0) +
|
||||||
(content.network?.more ?? 0);
|
(content.network?.more ?? 0);
|
||||||
if (n > 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 {
|
} else {
|
||||||
const members = (content.community?.members.length ?? 0) +
|
const members = (content.community?.members.length ?? 0) +
|
||||||
@@ -447,18 +582,42 @@ function preheader(content: DigestContent): string {
|
|||||||
(content.community?.playlistsMore ?? 0);
|
(content.community?.playlistsMore ?? 0);
|
||||||
if (content.top.length > 0) {
|
if (content.top.length > 0) {
|
||||||
parts.push(
|
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) {
|
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) {
|
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) {
|
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 "";
|
if (parts.length === 0) return "";
|
||||||
const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" })
|
const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" })
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Lets the emails speak the instance's language from the same catalogs as
|
* Lets the emails speak the instance's language from the same catalogs as
|
||||||
* the interface. A template names each message by its id, as
|
* the interface. A template passes each message as a descriptor holding its
|
||||||
* `i18n._("email.reset.title", values)`, and its words, English included,
|
* id and its English, as `i18n._({ id: "email.reset.title", message: "Reset
|
||||||
* are written in `locales/<lang>.po` only. `lingui extract` lists the ids it
|
* your password" })`, or one of `shared/digest-messages.ts`, which a page
|
||||||
* finds there beside the interface's messages, and `lingui compile` turns
|
* says too. `lingui extract` reads them into `locales/<lang>.po` beside the
|
||||||
* the catalogs into the `locales/<lang>.mjs` imported here, failing when a
|
* interface's messages, and `lingui compile` turns the catalogs into the
|
||||||
* language lacks one. The ids are named rather than hashed from the English,
|
* `locales/<lang>.mjs` imported here, failing when a language lacks one. The
|
||||||
* since the server runs no Lingui macro.
|
* ids are named rather than hashed from the English, since the server runs
|
||||||
|
* no Lingui macro.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,23 +26,67 @@ export function renderPasswordResetEmail(
|
|||||||
hours: number,
|
hours: number,
|
||||||
): RenderedEmail {
|
): RenderedEmail {
|
||||||
const markdown = [
|
const markdown = [
|
||||||
`# ${escMd(i18n._("email.reset.title"))}`,
|
`# ${
|
||||||
escMd(i18n._("email.reset.hi", { username })),
|
escMd(i18n._({ id: "email.reset.title", message: "Reset your password" }))
|
||||||
escMd(i18n._("email.reset.body", { site: OG_SITE_NAME, hours })),
|
}`,
|
||||||
|
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(
|
mdLink(
|
||||||
i18n._("email.reset.button"),
|
i18n._({ id: "email.reset.button", message: "Reset password" }),
|
||||||
resetUrl,
|
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```",
|
"```\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");
|
].join("\n\n");
|
||||||
|
|
||||||
return new EmailKit(lookOf()).render({
|
return new EmailKit(lookOf()).render({
|
||||||
subject: i18n._("email.reset.subject", { site: OG_SITE_NAME }),
|
subject: i18n._({
|
||||||
preheader: i18n._("email.reset.preheader", { hours }),
|
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,
|
markdown,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,24 +29,52 @@ export function renderWelcomeEmail(username: string): RenderedEmail {
|
|||||||
? WELCOME_EMAIL_BODY
|
? WELCOME_EMAIL_BODY
|
||||||
.replaceAll("{{username}}", escMd(username))
|
.replaceAll("{{username}}", escMd(username))
|
||||||
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
|
.replaceAll("{{site_name}}", escMd(OG_SITE_NAME))
|
||||||
: i18n._("email.welcome.body", {
|
: i18n._({
|
||||||
site: escMd(OG_SITE_NAME),
|
id: "email.welcome.body",
|
||||||
username: escMd(username),
|
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 = [
|
const markdown = [
|
||||||
body,
|
body,
|
||||||
mdLink(
|
mdLink(
|
||||||
i18n._("email.welcome.button", { site: OG_SITE_NAME }),
|
i18n._({
|
||||||
|
id: "email.welcome.button",
|
||||||
|
message: "Open {site}",
|
||||||
|
values: { site: OG_SITE_NAME },
|
||||||
|
}),
|
||||||
FRONTEND_URL,
|
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");
|
].join("\n\n");
|
||||||
|
|
||||||
return new EmailKit(lookOf()).render({
|
return new EmailKit(lookOf()).render({
|
||||||
subject: i18n._("email.welcome.subject", { site: OG_SITE_NAME }),
|
subject: i18n._({
|
||||||
preheader: i18n._("email.welcome.preheader"),
|
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,
|
markdown,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"server:start": "deno run --env-file -A --watch api/main.ts",
|
"server:start": "deno run --env-file -A --watch api/main.ts",
|
||||||
"icons": "deno run --env-file -A api/scripts/generate-icons.ts",
|
"icons": "deno run --env-file -A api/scripts/generate-icons.ts",
|
||||||
"serve": "deno task build && deno task icons dist && deno task server:start",
|
"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",
|
"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",
|
"start": "deno task icons dist && deno run --env-file -A api/main.ts",
|
||||||
"test": "deno test -A api/ shared/",
|
"test": "deno test -A api/ shared/",
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ const config: LinguiConfig = {
|
|||||||
sourceLocale: SOURCE_LOCALE,
|
sourceLocale: SOURCE_LOCALE,
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
// One catalog per language for the whole app: the interface's messages
|
// One catalog per language for the whole app: the interface's messages,
|
||||||
// and the emails' (api/mail/), which the server imports compiled. A
|
// the emails' (api/mail/), which the server imports compiled, and those
|
||||||
// message named by id has its English in en.po only, which is why
|
// both say (shared/digest-messages.ts). Every message's English is in
|
||||||
// extract runs without --overwrite (it would write the id there).
|
// the code, so extract --overwrite writes en.po from it.
|
||||||
path: "locales/{locale}",
|
path: "locales/{locale}",
|
||||||
include: ["src", "api/mail"],
|
include: ["src", "api/mail", "shared"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
format: formatter(),
|
format: formatter(),
|
||||||
|
|||||||
825
locales/en.po
825
locales/en.po
File diff suppressed because it is too large
Load Diff
825
locales/fr.po
825
locales/fr.po
File diff suppressed because it is too large
Load Diff
80
shared/digest-messages.ts
Normal file
80
shared/digest-messages.ts
Normal 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>;
|
||||||
@@ -10,7 +10,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/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 { Avatar } from "../shared/Avatar.tsx";
|
||||||
import type { SelfUser } from "../../../shared/user.ts";
|
import type { SelfUser } from "../../../shared/user.ts";
|
||||||
import { can } from "../../utils/auth/permissions.ts";
|
import { can } from "../../utils/auth/permissions.ts";
|
||||||
@@ -29,6 +30,7 @@ export interface UserMenuProps {
|
|||||||
* Escape closes it.
|
* Escape closes it.
|
||||||
*/
|
*/
|
||||||
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
||||||
|
const { i18n } = useLingui();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -91,7 +93,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<Message id="digest.community" />
|
{i18n._(DIGEST_MESSAGES.community)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { i18n } from "@lingui/core";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/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 { Wire } from "../../shared/api.ts";
|
||||||
import type { CommunityStats } from "../../shared/community.ts";
|
import type { CommunityStats } from "../../shared/community.ts";
|
||||||
import type { DigestCadence } from "../../shared/settings.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. */
|
/** Renders the period's four figures, each over its label. */
|
||||||
function Figures({ stats }: { stats: CommunityStats }) {
|
function Figures({ stats }: { stats: CommunityStats }) {
|
||||||
|
const { i18n } = useLingui();
|
||||||
const { dumps, upvotes, comments } = stats.totals;
|
const { dumps, upvotes, comments } = stats.totals;
|
||||||
const figures: [number, string][] = [
|
const figures: [number, string][] = [
|
||||||
[dumps, i18n._("digest.stat.dumps", { n: dumps })],
|
[dumps, i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } })],
|
||||||
[upvotes, i18n._("digest.count.upvotes", { n: upvotes })],
|
[
|
||||||
[comments, i18n._("digest.count.comments", { n: comments })],
|
upvotes,
|
||||||
|
i18n._({ ...DIGEST_MESSAGES.countUpvotes, values: { n: upvotes } }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
comments,
|
||||||
|
i18n._({ ...DIGEST_MESSAGES.countComments, values: { n: comments } }),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
stats.chatMessages,
|
stats.chatMessages,
|
||||||
i18n._("digest.stat.chat", { n: stats.chatMessages }),
|
i18n._({
|
||||||
|
...DIGEST_MESSAGES.statChat,
|
||||||
|
values: { n: stats.chatMessages },
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +80,8 @@ function Figures({ stats }: { stats: CommunityStats }) {
|
|||||||
* A view no period names is `NotFound`.
|
* A view no period names is `NotFound`.
|
||||||
*/
|
*/
|
||||||
export function Community(): React.JSX.Element {
|
export function Community(): React.JSX.Element {
|
||||||
useDocumentTitle(i18n._("digest.community"));
|
const { i18n } = useLingui();
|
||||||
|
useDocumentTitle(i18n._(DIGEST_MESSAGES.community));
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const [period, setPeriod] = useTabParam<DigestCadence>(
|
const [period, setPeriod] = useTabParam<DigestCadence>(
|
||||||
communityPath(),
|
communityPath(),
|
||||||
@@ -97,7 +108,7 @@ export function Community(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell narrow>
|
<PageShell narrow>
|
||||||
<PageHeader icon="🏘️" title={<Message id="digest.community" />} />
|
<PageHeader icon="🏘️" title={i18n._(DIGEST_MESSAGES.community)} />
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: "weekly", label: <Trans>This week</Trans> },
|
{ key: "weekly", label: <Trans>This week</Trans> },
|
||||||
@@ -122,15 +133,15 @@ export function Community(): React.JSX.Element {
|
|||||||
<Figures stats={data} />
|
<Figures stats={data} />
|
||||||
{data.top.length > 0 && (
|
{data.top.length > 0 && (
|
||||||
<PageGroup
|
<PageGroup
|
||||||
label={weekly
|
label={i18n._(
|
||||||
? <Message id="digest.topWeek" />
|
weekly ? DIGEST_MESSAGES.topWeek : DIGEST_MESSAGES.topMonth,
|
||||||
: <Message id="digest.topMonth" />}
|
)}
|
||||||
>
|
>
|
||||||
<DumpCardList dumps={data.top} />
|
<DumpCardList dumps={data.top} />
|
||||||
</PageGroup>
|
</PageGroup>
|
||||||
)}
|
)}
|
||||||
{data.members.length > 0 && (
|
{data.members.length > 0 && (
|
||||||
<PageGroup label={<Message id="digest.newMembers" />}>
|
<PageGroup label={i18n._(DIGEST_MESSAGES.newMembers)}>
|
||||||
<ul className="user-card-list">
|
<ul className="user-card-list">
|
||||||
{data.members.map((m) => (
|
{data.members.map((m) => (
|
||||||
<UserCard
|
<UserCard
|
||||||
@@ -138,20 +149,18 @@ export function Community(): React.JSX.Element {
|
|||||||
user={m}
|
user={m}
|
||||||
note={m.invitedByUsername &&
|
note={m.invitedByUsername &&
|
||||||
(m.invitedByUsername === user?.username
|
(m.invitedByUsername === user?.username
|
||||||
? <Message id="digest.invitedByYou" />
|
? i18n._(DIGEST_MESSAGES.invitedByYou)
|
||||||
: (
|
: i18n._({
|
||||||
<Message
|
...DIGEST_MESSAGES.invitedBy,
|
||||||
id="digest.invitedBy"
|
values: { username: m.invitedByUsername },
|
||||||
values={{ username: m.invitedByUsername }}
|
}))}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</PageGroup>
|
</PageGroup>
|
||||||
)}
|
)}
|
||||||
{data.playlists.length > 0 && (
|
{data.playlists.length > 0 && (
|
||||||
<PageGroup label={<Message id="digest.newPlaylists" />}>
|
<PageGroup label={i18n._(DIGEST_MESSAGES.newPlaylists)}>
|
||||||
<PlaylistCardList playlists={data.playlists} />
|
<PlaylistCardList playlists={data.playlists} />
|
||||||
</PageGroup>
|
</PageGroup>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
// A label shared with the emails is named by its id, its words being in
|
// 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.
|
// 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 { ColorScheme, StyleName } from "../../shared/theme.ts";
|
||||||
import type { FeedTab } from "../../shared/paths.ts";
|
import type { FeedTab } from "../../shared/paths.ts";
|
||||||
import { useTheme } from "../hooks/theme/useTheme.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.
|
* default feed tab and sounds, for now, to this browser.
|
||||||
*/
|
*/
|
||||||
export function Settings(): React.JSX.Element {
|
export function Settings(): React.JSX.Element {
|
||||||
|
const { i18n } = useLingui();
|
||||||
useDocumentTitle(t`Settings`);
|
useDocumentTitle(t`Settings`);
|
||||||
const { user } = useRequiredAuth();
|
const { user } = useRequiredAuth();
|
||||||
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
|
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
|
||||||
@@ -142,7 +144,7 @@ export function Settings(): React.JSX.Element {
|
|||||||
</PageGroup>
|
</PageGroup>
|
||||||
<PageGroup label={<Trans>Email</Trans>}>
|
<PageGroup label={<Trans>Email</Trans>}>
|
||||||
<div className="page-card profile-appearance-grid">
|
<div className="page-card profile-appearance-grid">
|
||||||
<SettingRow label={<Message id="digest.weekly" />}>
|
<SettingRow label={i18n._(DIGEST_MESSAGES.weekly)}>
|
||||||
<Segmented
|
<Segmented
|
||||||
options={ON_OFF_OPTIONS}
|
options={ON_OFF_OPTIONS}
|
||||||
value={settings?.digestWeekly ?? true}
|
value={settings?.digestWeekly ?? true}
|
||||||
@@ -151,7 +153,7 @@ export function Settings(): React.JSX.Element {
|
|||||||
updateSettings({ digestWeekly: on }).catch(() => {})}
|
updateSettings({ digestWeekly: on }).catch(() => {})}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label={<Message id="digest.monthly" />}>
|
<SettingRow label={i18n._(DIGEST_MESSAGES.monthly)}>
|
||||||
<Segmented
|
<Segmented
|
||||||
options={ON_OFF_OPTIONS}
|
options={ON_OFF_OPTIONS}
|
||||||
value={settings?.digestMonthly ?? true}
|
value={settings?.digestMonthly ?? true}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
// A label shared with the emails is named by its id, its words being in
|
// 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.
|
// 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 { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
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`.
|
* email, then posts the token to `POST /api/unsubscribe`.
|
||||||
*/
|
*/
|
||||||
export function Unsubscribe(): React.JSX.Element {
|
export function Unsubscribe(): React.JSX.Element {
|
||||||
|
const { i18n } = useLingui();
|
||||||
useDocumentTitle(t`Unsubscribe`);
|
useDocumentTitle(t`Unsubscribe`);
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -113,9 +115,9 @@ export function Unsubscribe(): React.JSX.Element {
|
|||||||
<form onSubmit={onSubmit} className="form">
|
<form onSubmit={onSubmit} className="form">
|
||||||
<FormError title={t`Couldn't unsubscribe`} />
|
<FormError title={t`Couldn't unsubscribe`} />
|
||||||
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
|
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
|
||||||
{weekly
|
{i18n._(
|
||||||
? <Message id="digest.weekly.off" />
|
weekly ? DIGEST_MESSAGES.weeklyOff : DIGEST_MESSAGES.monthlyOff,
|
||||||
: <Message id="digest.monthly.off" />}
|
)}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user