import invariant from "invariant"; import debounce from "lodash/debounce"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import styled from "styled-components"; import { s } from "@shared/styles"; import { dateLocale, dateToRelative } from "@shared/utils/date"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; import Document from "~/models/Document"; import Share from "~/models/Share"; import Button from "~/components/Button"; import CopyToClipboard from "~/components/CopyToClipboard"; import Flex from "~/components/Flex"; import Input, { StyledText } from "~/components/Input"; import Notice from "~/components/Notice"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useUserLocale from "~/hooks/useUserLocale"; type Props = { /** The document to share. */ document: Document; /** The existing share model, if any. */ share: Share | null | undefined; /** The existing share parent model, if any. */ sharedParent: Share | null | undefined; /** Whether to hide the title. */ hideTitle?: boolean; /** Callback fired when the popover requests to be closed. */ onRequestClose: () => void; /** Whether the popover is visible. */ visible: boolean; }; function SharePopover({ document, share, sharedParent, hideTitle, onRequestClose, visible, }: Props) { const team = useCurrentTeam(); const { t } = useTranslation(); const { shares, collections } = useStores(); const [expandedOptions, setExpandedOptions] = React.useState(false); const [isEditMode, setIsEditMode] = React.useState(false); const [slugValidationError, setSlugValidationError] = React.useState(""); const [urlSlug, setUrlSlug] = React.useState(""); const timeout = React.useRef>(); const buttonRef = React.useRef(null); const can = usePolicy(share ? share.id : ""); const documentAbilities = usePolicy(document); const collection = document.collectionId ? collections.get(document.collectionId) : undefined; const canPublish = can.update && !document.isTemplate && team.sharing && collection?.sharing && documentAbilities.share; const isPubliclyShared = team.sharing && ((share && share.published) || (sharedParent && sharedParent.published && !document.isDraft)); React.useEffect(() => { if (!visible && expandedOptions) { setExpandedOptions(false); } }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps useKeyDown("Escape", onRequestClose); React.useEffect(() => { if (visible) { void document.share(); buttonRef.current?.focus(); } return () => (timeout.current ? clearTimeout(timeout.current) : undefined); }, [document, visible]); React.useEffect(() => { if (!visible) { setUrlSlug(share?.urlId || ""); setSlugValidationError(""); } }, [share, visible]); const handlePublishedChange = React.useCallback( async (event) => { const share = shares.getByDocumentId(document.id); invariant(share, "Share must exist"); try { await share.save({ published: event.currentTarget.checked, }); } catch (err) { toast.error(err.message); } }, [document.id, shares] ); const handleChildDocumentsChange = React.useCallback( async (event) => { const share = shares.getByDocumentId(document.id); invariant(share, "Share must exist"); try { await share.save({ includeChildDocuments: event.currentTarget.checked, }); } catch (err) { toast.error(err.message); } }, [document.id, shares] ); const handleCopied = React.useCallback(() => { timeout.current = setTimeout(() => { onRequestClose(); toast.message(t("Share link copied")); }, 250); }, [t, onRequestClose]); const handleUrlSlugChange = React.useMemo( () => debounce(async (ev) => { const share = shares.getByDocumentId(document.id); invariant(share, "Share must exist"); const val = ev.target.value; setUrlSlug(val); if (val && !SHARE_URL_SLUG_REGEX.test(val)) { setSlugValidationError( t("Only lowercase letters, digits and dashes allowed") ); } else { setSlugValidationError(""); if (share.urlId !== val) { try { await share.save({ urlId: isEmpty(val) ? null : val, }); } catch (err) { if (err.message.includes("must be unique")) { setSlugValidationError( t("Sorry, this link has already been used") ); } } } } }, 500), [t, document.id, shares] ); const PublishToInternet = ({ canPublish }: { canPublish: boolean }) => { if (!canPublish) { return ( {t("Only members with permission can view")} ); } return ( {share?.published ? t("Anyone with the link can view this document") : t("Only members with permission can view")} {share?.lastAccessedAt && ( <> .{" "} {t("The shared link was last accessed {{ timeAgo }}.", { timeAgo: dateToRelative(Date.parse(share?.lastAccessedAt), { addSuffix: true, locale, }), })} )} ); }; const userLocale = useUserLocale(); const locale = userLocale ? dateLocale(userLocale) : undefined; let shareUrl = sharedParent?.url ? `${sharedParent.url}${document.url}` : share?.url ?? ""; if (isEditMode) { shareUrl += "?edit=true"; } const url = shareUrl.replace(/https?:\/\//, ""); const documentTitle = sharedParent?.documentTitle; return ( <> {!hideTitle && ( {isPubliclyShared ? ( ) : ( )} {t("Share this document")} )} {sharedParent && !document.isDraft && ( This document is shared because the parent{" "} {documentTitle} {" "} is publicly shared. )} {canPublish && !sharedParent?.published && ( )} {canPublish && share?.published && !document.isDraft && ( {share.includeChildDocuments ? t("Nested documents are publicly available") : t("Nested documents are not shared")} . )} {expandedOptions && ( <> {canPublish && sharedParent?.published && ( <> )} setIsEditMode(checked) } checked={isEditMode} disabled={!share} /> {isEditMode ? t( "Users with edit permission will be redirected to the main app" ) : t("All users see the same publicly shared view")} . {!slugValidationError && urlSlug && ( The document will be accessible at{" "} {{ url }} )} )} {expandedOptions || !canPublish ? ( ) : ( } onClick={() => setExpandedOptions(true)} neutral borderOnHover > {t("More options")} )} ); } const StyledLink = styled(Link)` color: ${s("textSecondary")}; text-decoration: underline; `; const Heading = styled.h2` display: flex; align-items: center; margin-top: 12px; gap: 8px; /* accounts for icon padding */ margin-left: -4px; `; const SwitchWrapper = styled.div` margin: 20px 0; `; const NoticeWrapper = styled.div` margin: 20px 0; `; const MoreOptionsButton = styled(Button)` background: none; font-size: 14px; color: ${s("textTertiary")}; margin-left: -8px; `; const Separator = styled.div` height: 1px; width: 100%; background-color: ${s("divider")}; `; const SwitchLabel = styled(Flex)` svg { flex-shrink: 0; } `; const SwitchText = styled(Text)` margin: 0; font-size: 15px; `; const DocumentLinkPreview = styled(StyledText)` margin-top: -12px; `; export default observer(SharePopover);