Files
gerbeur/api/mail/digest.ts
khannurien 134d1a0b97 v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:54:09 +00:00

468 lines
14 KiB
TypeScript

/**
* 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"
| "mentionEveryone"
| "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. Its
* `url` is null once it is deleted, and the title then shows unlinked.
*/
target?: { title: string; url: string | null };
/** 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;
/** The community page's view of the cadence's period. */
community: 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._("digest.count.upvotes", { n });
case "followers":
return i18n._("email.digest.count.followers", { n });
case "comments":
return i18n._("digest.count.comments", { n });
case "mentions":
return i18n._("email.digest.count.mentions", { n });
case "likes":
return i18n._("email.digest.count.likes", { n });
case "playlistFollows":
return i18n._("email.digest.count.playlistFollows", { 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._("email.digest.hl.mentionComment", values);
case "mentionDump":
case "mentionChat":
return i18n._("email.digest.hl.mentionIn", values);
case "mentionPlaylist":
return i18n._("email.digest.hl.mentionPlaylist", values);
case "mentionEveryone":
return i18n._("email.digest.hl.mentionEveryone", values);
case "comment":
return i18n._("email.digest.hl.comment", values);
case "playlistFollow":
return i18n._("email.digest.hl.playlistFollow", values);
case "userFollow":
return i18n._("email.digest.hl.userFollow", values);
case "commentLike":
return i18n._("email.digest.hl.commentLike", values);
}
}
const byLine = (username: string) => i18n._("email.digest.by", { username });
const addedToLine = (playlist: string) =>
i18n._("email.digest.addedTo", { playlist });
const uploadLabel = () => i18n._("email.digest.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 });
const playlistMeta = (owner: string, n: number) =>
i18n._("email.digest.playlistMeta", { 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._("email.digest.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._("digest.monthly") : i18n._("digest.weekly")} · ${
range(true)
}`,
)
}*`,
`# ${
escMd(
monthly
? i18n._("email.digest.monthly.title", { site: OG_SITE_NAME })
: i18n._("email.digest.weekly.title", { username: content.reader }),
)
}`,
];
const { activity, network, community } = content;
if (activity) {
md.push(
`## ${escMd(i18n._("email.digest.forYou"))}`,
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" || h.kind === "mentionEveryone"
? mdLink(
i18n._("email.digest.theChat"),
content.links.chat,
)
: h.target?.url
? mdLink(h.target.title, h.target.url)
: h.target
? escMd(h.target.title)
: "";
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._("email.digest.seeAll", { n: activity.total }),
content.links.notifications,
)
} →`,
);
}
if (network) {
md.push(
`## ${escMd(i18n._("email.digest.network"))}`,
network.dumps.map((d) => dumpItem(d, "-")).join("\n"),
);
if (network.more > 0) {
md.push(`${
mdLink(
i18n._("email.digest.more", { n: network.more }),
content.links.followed,
)
} →`);
}
}
if (content.top.length > 0) {
const [first] = content.top;
md.push(
`## ${
escMd(
monthly ? i18n._("digest.topMonth") : i18n._("digest.topWeek"),
)
}`,
);
// 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._("digest.community"))}`,
);
if (monthly) {
md.push(
[
figure(
dumps,
i18n._("digest.stat.dumps", { n: dumps }),
),
figure(upvotes, countLabel("upvotes", upvotes)),
figure(comments, countLabel("comments", comments)),
figure(
community.chatMessages,
i18n._("digest.stat.chat", { n: community.chatMessages }),
),
].join(" · "),
);
}
if (community.members.length > 0) {
md.push(
`### ${
escMd(
i18n._("digest.newMembers"),
)
}`,
[
...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._("digest.newPlaylists"))}`,
[
...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._("email.digest.inChat"))}`,
`${
escMd(i18n._("email.digest.chatWeek", { n: community.chatMessages }))
} · ${
mdLink(
i18n._("email.digest.openChat"),
content.links.chat,
)
}`,
);
}
if (!monthly && dumps + upvotes + comments > 0) {
md.push(
`*${
escMd(i18n._("email.digest.totals", { dumps, upvotes, comments }))
}*`,
);
}
md.push(`${
mdLink(
i18n._("email.digest.communityPage"),
content.links.community,
)
} →`);
}
md.push(
"---",
escMd(
monthly
? i18n._("email.digest.monthly.reason")
: i18n._("email.digest.weekly.reason"),
),
`${
mdLink(
monthly ? i18n._("digest.monthly.off") : i18n._("digest.weekly.off"),
content.links.unsubscribe,
)
} · ${
mdLink(
i18n._("email.settings"),
content.links.settings,
)
}`,
);
return new EmailKit(look).render({
subject: monthly
? i18n._("email.digest.monthly.subject", {
site: OG_SITE_NAME,
range: range(false),
})
: i18n._("email.digest.weekly.subject", {
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._("email.digest.pre.network", { 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._("email.digest.pre.topMonth", { n: content.top.length }),
);
}
if (members > 0) {
parts.push(i18n._("email.digest.pre.members", { n: members }));
}
if (playlists > 0) {
parts.push(i18n._("email.digest.pre.playlists", { n: playlists }));
}
}
if (parts.length === 0 && content.top.length > 0) {
parts.push(i18n._("email.digest.pre.topWeek", { 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)}.`;
}