diff --git a/app/components/Sharing/Collection/PublicAccess.tsx b/app/components/Sharing/Collection/PublicAccess.tsx index 015ed72971..8b4337f9eb 100644 --- a/app/components/Sharing/Collection/PublicAccess.tsx +++ b/app/components/Sharing/Collection/PublicAccess.tsx @@ -60,6 +60,19 @@ function InnerPublicAccess( [share] ); + const handleSubscriptionsChanged = React.useCallback( + async (checked: boolean) => { + try { + await share?.save({ + allowSubscriptions: checked, + }); + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + const handleShowLastModifiedChanged = React.useCallback( async (checked: boolean) => { try { @@ -194,6 +207,31 @@ function InnerPublicAccess( /> } /> + + {t("Email subscriptions")}  + + + + + + + } + actions={ + + } + /> diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index 72a8e5ff52..cbe2782409 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -70,6 +70,19 @@ function PublicAccess( [share] ); + const handleSubscriptionsChanged = React.useCallback( + async (checked: boolean) => { + try { + await share?.save({ + allowSubscriptions: checked, + }); + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + const handleShowLastModifiedChanged = React.useCallback( async (checked: boolean) => { try { @@ -238,6 +251,31 @@ function PublicAccess( /> } /> + + {t("Email subscriptions")}  + + + + + + + } + actions={ + + } + /> diff --git a/app/components/Sharing/components/Actions.tsx b/app/components/Sharing/components/Actions.tsx index 0e82ae2d6f..c63723fb4d 100644 --- a/app/components/Sharing/components/Actions.tsx +++ b/app/components/Sharing/components/Actions.tsx @@ -1,9 +1,16 @@ import { observer } from "mobx-react"; -import { MoonIcon, SunIcon } from "outline-icons"; +import { MoonIcon, SunIcon, SubscribeIcon } from "outline-icons"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Action } from "~/components/Actions"; import Button from "~/components/Button"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "~/components/primitives/Popover"; import Tooltip from "~/components/Tooltip"; +import { ShareSubscribeForm } from "./ShareSubscribeForm"; import useStores from "~/hooks/useStores"; import { Theme } from "~/stores/UiStore"; @@ -42,3 +49,28 @@ export const AppearanceAction = observer(() => { ); }); + +export function SubscribeAction({ shareId }: { shareId: string }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + + + + + + + {status === "error" && {errorMessage}} + + + ); +} + +const FormContainer = styled.div` + padding: 4px 0; +`; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ErrorText = styled.p` + font-size: 13px; + color: ${s("danger")}; + margin: 0; +`; diff --git a/app/models/Share.ts b/app/models/Share.ts index 4c8903d04d..45250a417d 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -70,6 +70,10 @@ class Share extends Model implements Searchable { @observable allowIndexing: boolean; + @Field + @observable + allowSubscriptions: boolean; + @Field @observable showLastUpdated: boolean; diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index f9c8487b65..5b76b4fef9 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -38,7 +38,10 @@ import { documentEditPath } from "~/utils/routeHelpers"; import ObservingBanner from "./ObservingBanner"; import PublicBreadcrumb from "./PublicBreadcrumb"; import ShareButton from "./ShareButton"; -import { AppearanceAction } from "~/components/Sharing/components/Actions"; +import { + AppearanceAction, + SubscribeAction, +} from "~/components/Sharing/components/Actions"; import useShare from "@shared/hooks/useShare"; import { type Editor } from "~/editor"; import { ChangesNavigation } from "./ChangesNavigation"; @@ -92,7 +95,7 @@ function DocumentHeader({ const { hasHeadings, editor } = useDocumentContext(); const sidebarContext = useLocationSidebarContext(); const [measureRef, size] = useMeasure(); - const { isShare, shareId, sharedTree } = useShare(); + const { isShare, shareId, sharedTree, allowSubscriptions } = useShare(); const isMobile = isMobileMedia || size.width < 700; // We cache this value for as long as the component is mounted so that if you @@ -210,6 +213,9 @@ function DocumentHeader({ } actions={ <> + {allowSubscriptions !== false && ( + + )} {can.update && !isEditing ? editAction :
} diff --git a/app/scenes/Shared/Document.tsx b/app/scenes/Shared/Document.tsx index 7a3e50e889..c09ea8b3ab 100644 --- a/app/scenes/Shared/Document.tsx +++ b/app/scenes/Shared/Document.tsx @@ -1,16 +1,16 @@ import { observer } from "mobx-react"; +import { useEffect, useMemo, useRef } from "react"; import type { PublicTeam } from "@shared/types"; import { TOCPosition } from "@shared/types"; import type DocumentModel from "~/models/Document"; import DocumentComponent from "~/scenes/Document/components/Document"; +import Branding from "~/components/Branding"; import { useDocumentContext } from "~/components/DocumentContext"; import { useTeamContext } from "~/components/TeamContext"; -import { useEffect, useMemo, useRef } from "react"; -import { parseDomain } from "@shared/utils/domains"; import useCurrentUser from "~/hooks/useCurrentUser"; -import Branding from "~/components/Branding"; -import useShare from "@shared/hooks/useShare"; import useQuery from "~/hooks/useQuery"; +import useShare from "@shared/hooks/useShare"; +import { parseDomain } from "@shared/utils/domains"; type Props = { document: DocumentModel; diff --git a/app/scenes/Shared/index.tsx b/app/scenes/Shared/index.tsx index bc8f6257ee..7839c63e7c 100644 --- a/app/scenes/Shared/index.tsx +++ b/app/scenes/Shared/index.tsx @@ -250,6 +250,7 @@ function SharedScene() { value={{ shareId, sharedTree: share.tree, + allowSubscriptions: share.allowSubscriptions, }} > diff --git a/server/emails/templates/ShareDocumentUpdatedEmail.tsx b/server/emails/templates/ShareDocumentUpdatedEmail.tsx new file mode 100644 index 0000000000..40c4a875c4 --- /dev/null +++ b/server/emails/templates/ShareDocumentUpdatedEmail.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import { ShareSubscription } from "@server/models"; +import ShareSubscriptionHelper from "@server/models/helpers/ShareSubscriptionHelper"; +import type { EmailProps } from "./BaseEmail"; +import BaseEmail, { EmailMessageCategory } from "./BaseEmail"; +import Body from "./components/Body"; +import Button from "./components/Button"; +import EmailTemplate from "./components/EmailLayout"; +import EmptySpace from "./components/EmptySpace"; +import Footer from "./components/Footer"; +import Header from "./components/Header"; +import Heading from "./components/Heading"; + +type InputProps = EmailProps & { + shareSubscriptionId: string; + documentTitle: string; + shareUrl: string; +}; + +type BeforeSend = { + unsubscribeUrl: string; +}; + +type Props = InputProps & BeforeSend; + +/** + * Email sent to a share subscriber when the shared document is updated. + */ +export default class ShareDocumentUpdatedEmail extends BaseEmail< + InputProps, + BeforeSend +> { + protected get category() { + return EmailMessageCategory.Notification; + } + + protected async beforeSend(props: InputProps) { + const subscription = await ShareSubscription.findByPk( + props.shareSubscriptionId + ); + + if ( + !subscription || + subscription.isUnsubscribed || + !subscription.isConfirmed + ) { + return false; + } + + return { + unsubscribeUrl: ShareSubscriptionHelper.unsubscribeUrl(subscription), + }; + } + + protected unsubscribeUrl({ unsubscribeUrl }: Props) { + return unsubscribeUrl; + } + + protected subject({ documentTitle }: Props) { + return this.t(`"{{ documentTitle }}" updated`, { documentTitle }); + } + + protected preview({ documentTitle }: Props): string { + return this.t('"{{ documentTitle }}" has been updated.', { documentTitle }); + } + + protected renderAsText({ documentTitle, shareUrl }: Props): string { + return ` +${this.t(`"{{ documentTitle }}" updated`, { documentTitle })} + +${this.t("A document you subscribed to has been updated.")} + +${this.t("View Document")}: ${shareUrl} +`; + } + + protected render({ documentTitle, shareUrl, unsubscribeUrl }: Props) { + const documentLink = `${shareUrl}?ref=subscription-email`; + + return ( + +
+ + + + {this.t(`"{{ documentTitle }}" updated`, { documentTitle })} + +

+ {this.t("A document you subscribed to has been updated.")}{" "} + {this.t("Click below to view the latest version.")} +

+ +

+ +

+ + +