import { AnimatePresence, m } from "framer-motion"; import { observer } from "mobx-react"; import { BackIcon, LinkIcon } from "outline-icons"; import { darken } from "polished"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled from "styled-components"; import { s } from "@shared/styles"; import Document from "~/models/Document"; import Share from "~/models/Share"; import CopyToClipboard from "~/components/CopyToClipboard"; import Flex from "~/components/Flex"; import { createAction } from "~/actions"; import { UserSection } from "~/actions/sections"; import useActionContext from "~/hooks/useActionContext"; import useBoolean from "~/hooks/useBoolean"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import useMobile from "~/hooks/useMobile"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; import { documentPath, urlify } from "~/utils/routeHelpers"; import ButtonSmall from "../ButtonSmall"; import Input, { NativeInput } from "../Input"; import NudeButton from "../NudeButton"; import Tooltip from "../Tooltip"; import DocumentMembersList from "./DocumentMemberList"; import { OtherAccess } from "./OtherAccess"; import PublicAccess from "./PublicAccess"; import { UserSuggestions } from "./UserSuggestions"; 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; /** Callback fired when the popover requests to be closed. */ onRequestClose: () => void; /** Whether the popover is visible. */ visible: boolean; }; const presence = { initial: { opacity: 0, width: 0, marginRight: 0, }, animate: { opacity: 1, width: "auto", marginRight: 8, transition: { type: "spring", duration: 0.2, bounce: 0, }, }, exit: { opacity: 0, width: 0, marginRight: 0, }, }; function SharePopover({ document, share, sharedParent, onRequestClose, visible, }: Props) { const team = useCurrentTeam(); const { t } = useTranslation(); const can = usePolicy(document); const inputRef = React.useRef(null); const { users, userMemberships } = useStores(); const isMobile = useMobile(); const [query, setQuery] = React.useState(""); const [picker, showPicker, hidePicker] = useBoolean(); const timeout = React.useRef>(); const linkButtonRef = React.useRef(null); const [invitedInSession, setInvitedInSession] = React.useState([]); const [pendingIds, setPendingIds] = React.useState([]); const collectionSharingDisabled = document.collection?.sharing === false; useKeyDown( "Escape", (ev) => { ev.preventDefault(); ev.stopImmediatePropagation(); if (picker) { hidePicker(); } else { onRequestClose(); } }, { allowInInput: true, } ); // Fetch sharefocus the link button when the popover is opened React.useEffect(() => { if (visible) { void document.share(); } }, [document, hidePicker, visible]); // Hide the picker when the popover is closed React.useEffect(() => { if (visible) { setPendingIds([]); hidePicker(); } }, [hidePicker, visible]); // Clear the query when picker is closed React.useEffect(() => { if (!picker) { setQuery(""); } }, [picker]); const handleCopied = React.useCallback(() => { onRequestClose(); timeout.current = setTimeout(() => { toast.message(t("Link copied to clipboard")); }, 100); return () => { if (timeout.current) { clearTimeout(timeout.current); } }; }, [onRequestClose, t]); const context = useActionContext(); const inviteAction = React.useMemo( () => createAction({ name: t("Invite"), section: UserSection, perform: async () => { await Promise.all( pendingIds.map((userId) => userMemberships.create({ documentId: document.id, userId, }) ) ); if (pendingIds.length === 1) { const user = users.get(pendingIds[0]); toast.message( t("{{ userName }} was invited to the document", { userName: user!.name, }) ); } else { toast.message( t("{{ count }} people invited to the document", { count: pendingIds.length, }) ); } setInvitedInSession((prev) => [...prev, ...pendingIds]); setPendingIds([]); hidePicker(); }, }), [document.id, hidePicker, pendingIds, t, users, userMemberships] ); const handleQuery = React.useCallback( (event) => { showPicker(); setQuery(event.target.value); }, [showPicker, setQuery] ); const focusInput = React.useCallback(() => { if (!picker) { inputRef.current?.focus(); showPicker(); } }, [picker, showPicker]); const handleAddPendingId = React.useCallback( (id: string) => { setPendingIds((prev) => [...prev, id]); }, [setPendingIds] ); const handleRemovePendingId = React.useCallback( (id: string) => { setPendingIds((prev) => prev.filter((i) => i !== id)); }, [setPendingIds] ); const backButton = ( <> {picker && ( )} ); const rightButton = picker ? ( pendingIds.length ? ( {t("Invite")} ) : null ) : ( ); return ( {can.manageUsers && (isMobile ? ( {backButton} {rightButton} ) : ( {backButton} {rightButton} ))} {picker && (
)}
{team.sharing && can.share && !collectionSharingDisabled && ( <> {document.members.length ? : null} )}
); } // TODO: Temp until Button/NudeButton styles are normalized const Wrapper = styled.div` ${NudeButton}:${hover}, ${NudeButton}[aria-expanded="true"] { background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)}; } `; const Separator = styled.div` border-top: 1px dashed ${s("divider")}; margin: 12px 0; `; const HeaderInput = styled(Flex)` position: sticky; z-index: 1; top: 0; background: ${s("menuBackground")}; color: ${s("textTertiary")}; border-bottom: 1px solid ${s("inputBorder")}; padding: 0 24px 12px; margin-top: 0; margin-left: -24px; margin-right: -24px; margin-bottom: 12px; cursor: text; &:before { content: ""; position: absolute; left: 0; right: 0; top: -20px; height: 20px; background: ${s("menuBackground")}; } `; export default observer(SharePopover);