diff --git a/app/hooks/useQueryNotices.ts b/app/hooks/useQueryNotices.ts new file mode 100644 index 000000000..6a2e5f215 --- /dev/null +++ b/app/hooks/useQueryNotices.ts @@ -0,0 +1,31 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { QueryNotices } from "@shared/types"; +import useQuery from "./useQuery"; +import useToasts from "./useToasts"; + +/** + * Display a toast message based on a notice in the query string. This is usually + * used when redirecting from an external source to the client, such as OAuth, + * or emails. + */ +export default function useQueryNotices() { + const query = useQuery(); + const { t } = useTranslation(); + const { showToast } = useToasts(); + const notice = query.get("notice") as QueryNotices; + + React.useEffect(() => { + switch (notice) { + case QueryNotices.UnsubscribeDocument: { + showToast( + t("Unsubscribed from document", { + type: "success", + }) + ); + break; + } + default: + } + }, [t, showToast, notice]); +} diff --git a/app/routes/index.tsx b/app/routes/index.tsx index a7eee6306..89af24993 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect"; import DelayedMount from "~/components/DelayedMount"; import FullscreenLoading from "~/components/FullscreenLoading"; import Route from "~/components/ProfiledRoute"; +import useQueryNotices from "~/hooks/useQueryNotices"; import lazyWithRetry from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; @@ -14,6 +15,8 @@ const Login = lazyWithRetry(() => import("~/scenes/Login")); const Logout = lazyWithRetry(() => import("~/scenes/Logout")); export default function Routes() { + useQueryNotices(); + return ( + S extends Record | void = void > { private props: T; private metadata?: NotificationMetadata; @@ -106,7 +106,7 @@ export default abstract class BaseEmail< ), text: this.renderAsText(data), headCSS: this.headCSS?.(data), - unsubscribeUrl: data.unsubscribeUrl, + unsubscribeUrl: this.unsubscribeUrl?.(data), }); Metrics.increment("email.sent", { templateName, @@ -167,6 +167,14 @@ export default abstract class BaseEmail< */ protected abstract render(props: S & T): JSX.Element; + /** + * Returns the unsubscribe URL for the email. + * + * @param props Props in email constructor + * @returns The unsubscribe URL as a string + */ + protected unsubscribeUrl?(props: T): string; + /** * Allows injecting additional CSS into the head of the email. * diff --git a/server/emails/templates/CollectionCreatedEmail.tsx b/server/emails/templates/CollectionCreatedEmail.tsx index ad9a5fca3..b9196b402 100644 --- a/server/emails/templates/CollectionCreatedEmail.tsx +++ b/server/emails/templates/CollectionCreatedEmail.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { NotificationEventType } from "@shared/types"; -import { Collection, User } from "@server/models"; +import { Collection } from "@server/models"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; @@ -32,9 +32,9 @@ export default class CollectionCreatedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ userId, collectionId }: Props) { + protected async beforeSend(props: InputProps) { const collection = await Collection.scope("withUser").findByPk( - collectionId + props.collectionId ); if (!collection) { return false; @@ -42,13 +42,17 @@ export default class CollectionCreatedEmail extends BaseEmail< return { collection, - unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - await User.findByPk(userId, { rejectOnEmpty: true }), - NotificationEventType.CreateCollection - ), + unsubscribeUrl: this.unsubscribeUrl(props), }; } + protected unsubscribeUrl({ userId }: InputProps) { + return NotificationSettingsHelper.unsubscribeUrl( + userId, + NotificationEventType.CreateCollection + ); + } + protected subject({ collection }: Props) { return `“${collection.name}” created`; } @@ -67,12 +71,13 @@ Open Collection: ${teamUrl}${collection.url} `; } - protected render({ collection, teamUrl, unsubscribeUrl }: Props) { + protected render(props: Props) { + const { collection, teamUrl, unsubscribeUrl } = props; const collectionLink = `${teamUrl}${collection.url}`; return (
@@ -80,8 +85,7 @@ Open Collection: ${teamUrl}${collection.url} {collection.name}

- {collection.user.name} created the collection "{collection.name} - ". + {collection.user.name} created the collection "{collection.name}".

diff --git a/server/emails/templates/CommentCreatedEmail.tsx b/server/emails/templates/CommentCreatedEmail.tsx index 2642fb847..157c5607c 100644 --- a/server/emails/templates/CommentCreatedEmail.tsx +++ b/server/emails/templates/CommentCreatedEmail.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { NotificationEventType } from "@shared/types"; import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Collection, Comment, Document, User } from "@server/models"; +import { Collection, Comment, Document } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; @@ -44,7 +44,8 @@ export default class CommentCreatedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ documentId, userId, commentId }: InputProps) { + protected async beforeSend(props: InputProps) { + const { documentId, commentId } = props; const document = await Document.unscoped().findByPk(documentId); if (!document) { return false; @@ -99,13 +100,17 @@ export default class CommentCreatedEmail extends BaseEmail< isReply, isFirstComment, body, - unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - await User.findByPk(userId, { rejectOnEmpty: true }), - NotificationEventType.CreateComment - ), + unsubscribeUrl: this.unsubscribeUrl(props), }; } + protected unsubscribeUrl({ userId }: InputProps) { + return NotificationSettingsHelper.unsubscribeUrl( + userId, + NotificationEventType.CreateComment + ); + } + protected subject({ isFirstComment, document }: Props) { return `${isFirstComment ? "" : "Re: "}New comment on “${document.title}”`; } @@ -137,21 +142,22 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId} `; } - protected render({ - document, - actorName, - isReply, - collection, - teamUrl, - commentId, - unsubscribeUrl, - body, - }: Props) { + protected render(props: Props) { + const { + document, + actorName, + isReply, + collection, + teamUrl, + commentId, + unsubscribeUrl, + body, + } = props; const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`; return (

diff --git a/server/emails/templates/CommentMentionedEmail.tsx b/server/emails/templates/CommentMentionedEmail.tsx index 526bef481..b99aa4b1a 100644 --- a/server/emails/templates/CommentMentionedEmail.tsx +++ b/server/emails/templates/CommentMentionedEmail.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { NotificationEventType } from "@shared/types"; import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Collection, Comment, Document, User } from "@server/models"; +import { Collection, Comment, Document } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; @@ -42,7 +42,8 @@ export default class CommentMentionedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ documentId, commentId, userId }: InputProps) { + protected async beforeSend(props: InputProps) { + const { documentId, commentId } = props; const document = await Document.unscoped().findByPk(documentId); if (!document) { return false; @@ -86,13 +87,17 @@ export default class CommentMentionedEmail extends BaseEmail< document, collection, body, - unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - await User.findByPk(userId, { rejectOnEmpty: true }), - NotificationEventType.MentionedInComment - ), + unsubscribeUrl: this.unsubscribeUrl(props), }; } + protected unsubscribeUrl({ userId }: InputProps) { + return NotificationSettingsHelper.unsubscribeUrl( + userId, + NotificationEventType.MentionedInComment + ); + } + protected subject({ actorName, document }: Props) { return `${actorName} mentioned you in “${document.title}”`; } @@ -121,20 +126,21 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId} `; } - protected render({ - document, - collection, - actorName, - teamUrl, - commentId, - unsubscribeUrl, - body, - }: Props) { + protected render(props: Props) { + const { + document, + collection, + actorName, + teamUrl, + commentId, + unsubscribeUrl, + body, + } = props; const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`; return (
diff --git a/server/emails/templates/ConfirmTeamDeleteEmail.tsx b/server/emails/templates/ConfirmTeamDeleteEmail.tsx index 6d2d41be7..2cd24727b 100644 --- a/server/emails/templates/ConfirmTeamDeleteEmail.tsx +++ b/server/emails/templates/ConfirmTeamDeleteEmail.tsx @@ -16,10 +16,7 @@ type Props = EmailProps & { /** * Email sent to a user when they request to delete their workspace. */ -export default class ConfirmTeamDeleteEmail extends BaseEmail< - Props, - Record -> { +export default class ConfirmTeamDeleteEmail extends BaseEmail { protected subject() { return `Your workspace deletion request`; } diff --git a/server/emails/templates/ConfirmUserDeleteEmail.tsx b/server/emails/templates/ConfirmUserDeleteEmail.tsx index 9e38d40c5..ddd6e4a53 100644 --- a/server/emails/templates/ConfirmUserDeleteEmail.tsx +++ b/server/emails/templates/ConfirmUserDeleteEmail.tsx @@ -16,10 +16,7 @@ type Props = EmailProps & { /** * Email sent to a user when they request to delete their account. */ -export default class ConfirmUserDeleteEmail extends BaseEmail< - Props, - Record -> { +export default class ConfirmUserDeleteEmail extends BaseEmail { protected subject() { return `Your account deletion request`; } diff --git a/server/emails/templates/DocumentMentionedEmail.tsx b/server/emails/templates/DocumentMentionedEmail.tsx index d76c3e157..15b73d42e 100644 --- a/server/emails/templates/DocumentMentionedEmail.tsx +++ b/server/emails/templates/DocumentMentionedEmail.tsx @@ -57,12 +57,13 @@ Open Document: ${teamUrl}${document.url} `; } - protected render({ document, actorName, teamUrl }: Props) { + protected render(props: Props) { + const { document, actorName, teamUrl } = props; const documentLink = `${teamUrl}${document.url}?ref=notification-email`; return (
diff --git a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx index 56949be93..9d967c7ab 100644 --- a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx +++ b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx @@ -3,16 +3,17 @@ import * as React from "react"; import { NotificationEventType } from "@shared/types"; import { Day } from "@shared/utils/time"; import env from "@server/env"; -import { Document, Collection, User, Revision } from "@server/models"; +import { Document, Collection, Revision } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; +import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper"; import BaseEmail, { EmailProps } from "./BaseEmail"; import Body from "./components/Body"; import Button from "./components/Button"; import Diff from "./components/Diff"; import EmailTemplate from "./components/EmailLayout"; import EmptySpace from "./components/EmptySpace"; -import Footer from "./components/Footer"; +import Footer, { Link } from "./components/Footer"; import Header from "./components/Header"; import Heading from "./components/Heading"; @@ -44,12 +45,8 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail< InputProps, BeforeSend > { - protected async beforeSend({ - documentId, - revisionId, - eventType, - userId, - }: InputProps) { + protected async beforeSend(props: InputProps) { + const { documentId, revisionId } = props; const document = await Document.unscoped().findByPk(documentId, { includeState: true, }); @@ -91,13 +88,14 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail< document, collection, body, - unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( - await User.findByPk(userId, { rejectOnEmpty: true }), - eventType - ), + unsubscribeUrl: this.unsubscribeUrl(props), }; } + protected unsubscribeUrl({ userId, eventType }: InputProps) { + return NotificationSettingsHelper.unsubscribeUrl(userId, eventType); + } + eventName(eventType: NotificationEventType) { switch (eventType) { case NotificationEventType.PublishDocument: @@ -135,21 +133,22 @@ Open Document: ${teamUrl}${document.url} `; } - protected render({ - document, - actorName, - collection, - eventType, - teamUrl, - unsubscribeUrl, - body, - }: Props) { + protected render(props: Props) { + const { + document, + actorName, + collection, + eventType, + teamUrl, + unsubscribeUrl, + body, + } = props; const documentLink = `${teamUrl}${document.url}?ref=notification-email`; const eventName = this.eventName(eventType); return (
@@ -177,7 +176,16 @@ Open Document: ${teamUrl}${document.url}

-