/** * 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 { DIGEST_MESSAGES } from "../../shared/digest-messages.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>; 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_MESSAGES.countUpvotes, values: { n } }); case "followers": return i18n._({ id: "email.digest.count.followers", message: "{n, plural, one {new follower} other {new followers}}", values: { n }, }); case "comments": return i18n._({ ...DIGEST_MESSAGES.countComments, values: { n } }); case "mentions": return i18n._({ id: "email.digest.count.mentions", message: "{n, plural, one {mention} other {mentions}}", values: { n }, }); case "likes": return i18n._({ id: "email.digest.count.likes", message: "{n, plural, one {comment like} other {comment likes}}", values: { n }, }); case "playlistFollows": return i18n._({ id: "email.digest.count.playlistFollows", message: "{n, plural, one {playlist follow} other {playlist follows}}", values: { n }, }); } } /** * Returns the sentence of a highlight, `who` and `target` already formatted * for the part it goes in (HTML or Markdown). */ function highlightSentence( kind: HighlightKind, who: string, target: string, ): string { const values = { who, target }; switch (kind) { case "mentionComment": return i18n._({ id: "email.digest.hl.mentionComment", message: "{who} mentioned you in a comment on {target}", values, }); case "mentionDump": case "mentionChat": return i18n._({ id: "email.digest.hl.mentionIn", message: "{who} mentioned you in {target}", values, }); case "mentionPlaylist": return i18n._({ id: "email.digest.hl.mentionPlaylist", message: "{who} mentioned you in the playlist {target}", values, }); case "mentionEveryone": return i18n._({ id: "email.digest.hl.mentionEveryone", message: "{who} mentioned everyone in {target}", values, }); case "comment": return i18n._({ id: "email.digest.hl.comment", message: "{who} commented on your dump {target}", values, }); case "playlistFollow": return i18n._({ id: "email.digest.hl.playlistFollow", message: "{who} followed your playlist {target}", values, }); case "userFollow": return i18n._({ id: "email.digest.hl.userFollow", message: "{who} started following you", values, }); case "commentLike": return i18n._({ id: "email.digest.hl.commentLike", message: "{who} liked your comment on {target}", values, }); } } const byLine = (username: string) => i18n._({ id: "email.digest.by", message: "by @{username}", values: { username }, }); const addedToLine = (playlist: string) => i18n._({ id: "email.digest.addedTo", message: "added to {playlist}", values: { playlist }, }); const uploadLabel = () => i18n._({ id: "email.digest.upload", message: "upload" }); const invitedLine = (inviter: string, reader: string) => inviter === reader ? i18n._(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._({ id: "email.digest.playlistMeta", message: "by @{username} · {n, plural, one {# dump} other {# dumps}}", values: { username: owner, n }, }); /** * Returns the digest `content` describes, written as Markdown and drawn in * `look`, with its subject and hidden preheader. */ export function renderDigestEmail( content: DigestContent, look: EmailLook, ): RenderedEmail { const monthly = content.cadence === "monthly"; const nf = new Intl.NumberFormat(EMAIL_LOCALE); const range = (year: boolean) => new Intl.DateTimeFormat( EMAIL_LOCALE, year ? { month: "short", day: "numeric", year: "numeric" } : { month: "short", day: "numeric" }, ).formatRange(content.from, content.to); const day = (d: Date) => new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d); const figure = (n: number, label: string) => `**${nf.format(n)}** ${escMd(label)}`; const yours = i18n._({ id: "email.digest.yours", message: "yours" }); /** A dump as a list item: its thumbnail, title, pill and detail. */ const dumpItem = (d: DigestDump, bullet: string, image = true) => `${bullet} ${image && d.thumbnail ? `![](${d.thumbnail}) ` : ""}${ mdLink(d.title, d.url) }${d.mine ? ` *${escMd(yours)}*` : ""} · ${ escMd( `${d.addedTo ? addedToLine(d.addedTo) : byLine(d.poster)}${ d.addedTo ? "" : ` · ${d.domain ?? uploadLabel()}` } · ${nf.format(d.votes)} ${countLabel("upvotes", d.votes)} · ${ nf.format(d.comments) } ${countLabel("comments", d.comments)}`, ) }`; const md: string[] = [ `*${ escMd( `${ monthly ? i18n._(DIGEST_MESSAGES.monthly) : i18n._(DIGEST_MESSAGES.weekly) } · ${range(true)}`, ) }*`, `# ${ escMd( monthly ? i18n._({ id: "email.digest.monthly.title", message: "The month on {site}", values: { site: OG_SITE_NAME }, }) : i18n._({ id: "email.digest.weekly.title", message: "Your week, {username}", values: { username: content.reader }, }), ) }`, ]; const { activity, network, community } = content; if (activity) { md.push( `## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`, ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0) .map((k) => figure(activity.counts[k]!, countLabel(k, activity.counts[k]!)) ) .join(" · "), activity.highlights.map((h) => { const target = h.kind === "mentionChat" || h.kind === "mentionEveryone" ? mdLink( i18n._({ id: "email.digest.theChat", message: "the chat" }), 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._({ id: "email.digest.seeAll", message: "{n, plural, one {See the notification} other {See all # notifications}}", values: { n: activity.total }, }), content.links.notifications, ) } →`, ); } if (network) { md.push( `## ${ escMd( i18n._({ id: "email.digest.network", message: "From people you follow", }), ) }`, network.dumps.map((d) => dumpItem(d, "-")).join("\n"), ); if (network.more > 0) { md.push(`${ mdLink( i18n._({ id: "email.digest.more", message: "{n} more on your Followed feed", values: { n: network.more }, }), content.links.followed, ) } →`); } } if (content.top.length > 0) { const [first] = content.top; md.push( `## ${ escMd( monthly ? i18n._(DIGEST_MESSAGES.topMonth) : i18n._(DIGEST_MESSAGES.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_MESSAGES.community))}`, ); if (monthly) { md.push( [ figure( dumps, i18n._({ ...DIGEST_MESSAGES.statDumps, values: { n: dumps } }), ), figure(upvotes, countLabel("upvotes", upvotes)), figure(comments, countLabel("comments", comments)), figure( community.chatMessages, i18n._({ ...DIGEST_MESSAGES.statChat, values: { n: community.chatMessages }, }), ), ].join(" · "), ); } if (community.members.length > 0) { md.push( `### ${ escMd( i18n._(DIGEST_MESSAGES.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_MESSAGES.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._({ id: "email.digest.inChat", message: "In the chat" })) }`, `${ escMd( i18n._({ id: "email.digest.chatWeek", message: "{n, plural, one {# message this week} other {# messages this week}}", values: { n: community.chatMessages }, }), ) } · ${ mdLink( i18n._({ id: "email.digest.openChat", message: "Open the chat" }), content.links.chat, ) }`, ); } if (!monthly && dumps + upvotes + comments > 0) { md.push( `*${ escMd(i18n._({ id: "email.digest.totals", message: "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.", values: { dumps, upvotes, comments }, })) }*`, ); } md.push(`${ mdLink( i18n._({ id: "email.digest.communityPage", message: "See it all on the community page", }), content.links.community, ) } →`); } md.push( "---", escMd( monthly ? i18n._({ id: "email.digest.monthly.reason", message: "You're getting the monthly recap because it's on in your settings.", }) : i18n._({ id: "email.digest.weekly.reason", message: "You're getting the weekly digest because it's on in your settings.", }), ), `${ mdLink( monthly ? i18n._(DIGEST_MESSAGES.monthlyOff) : i18n._(DIGEST_MESSAGES.weeklyOff), content.links.unsubscribe, ) } · ${ mdLink( i18n._({ id: "email.settings", message: "Settings" }), content.links.settings, ) }`, ); return new EmailKit(look).render({ subject: monthly ? i18n._({ id: "email.digest.monthly.subject", message: "The month on {site}, {range}", values: { site: OG_SITE_NAME, range: range(false) }, }) : i18n._({ id: "email.digest.weekly.subject", message: "Your week on {site}, {range}", values: { site: OG_SITE_NAME, range: range(false) }, }), preheader: preheader(content), markdown: md.join("\n\n"), }); } /** * Returns the line an inbox shows beside the subject: the counts that make * the digest worth opening, at most three of them. */ function preheader(content: DigestContent): string { const parts: string[] = []; if (content.cadence === "weekly") { for (const k of ["upvotes", "followers", "comments"] as const) { const n = content.activity?.counts[k] ?? 0; if (n > 0) parts.push(`${n} ${countLabel(k, n)}`); } const n = (content.network?.dumps.length ?? 0) + (content.network?.more ?? 0); if (n > 0) { parts.push( i18n._({ id: "email.digest.pre.network", message: "{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}", values: { n }, }), ); } } else { const members = (content.community?.members.length ?? 0) + (content.community?.membersMore ?? 0); const playlists = (content.community?.playlists.length ?? 0) + (content.community?.playlistsMore ?? 0); if (content.top.length > 0) { parts.push( i18n._({ id: "email.digest.pre.topMonth", message: "{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}", values: { n: content.top.length }, }), ); } if (members > 0) { parts.push( i18n._({ id: "email.digest.pre.members", message: "{n, plural, one {# new member} other {# new members}}", values: { n: members }, }), ); } if (playlists > 0) { parts.push( i18n._({ id: "email.digest.pre.playlists", message: "{n, plural, one {# new playlist} other {# new playlists}}", values: { n: playlists }, }), ); } } if (parts.length === 0 && content.top.length > 0) { parts.push( i18n._({ id: "email.digest.pre.topWeek", message: "{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}", values: { n: content.top.length }, }), ); } if (parts.length === 0) return ""; const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" }) .format(parts.slice(0, 3)); return `${line.charAt(0).toUpperCase()}${line.slice(1)}.`; }