/** * 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. 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>; 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._("email.digest.count.upvotes", { n }); case "followers": return i18n._("email.digest.count.followers", { n }); case "comments": return i18n._("email.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 "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._("email.digest.invitedByYou") : i18n._("email.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" ? 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._("email.digest.topMonth") : i18n._("email.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._("email.digest.community"))}`, ); if (monthly) { md.push( [ figure( dumps, i18n._("email.digest.stat.dumps", { n: dumps }), ), figure(upvotes, countLabel("upvotes", upvotes)), figure(comments, countLabel("comments", comments)), figure( community.chatMessages, i18n._("email.digest.stat.chat", { n: community.chatMessages }), ), ].join(" · "), ); } if (community.members.length > 0) { md.push( `### ${ escMd( i18n._("email.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._("email.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( "---", 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)}.`; }