627 lines
18 KiB
TypeScript
627 lines
18 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 { 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<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_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 ? ` ` : ""}${
|
|
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(``);
|
|
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)}.`;
|
|
}
|