import { AnimatePresence, m } from "framer-motion"; import { observer } from "mobx-react"; import { BackIcon, LinkIcon, MoreIcon, QuestionMarkIcon, UserIcon, } from "outline-icons"; import { darken } from "polished"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled, { useTheme } from "styled-components"; import { s } from "@shared/styles"; import { CollectionPermission } from "@shared/types"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import Share from "~/models/Share"; import User from "~/models/User"; import CopyToClipboard from "~/components/CopyToClipboard"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; import useBoolean from "~/hooks/useBoolean"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import useKeyDown from "~/hooks/useKeyDown"; import useMobile from "~/hooks/useMobile"; import usePolicy from "~/hooks/usePolicy"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import useThrottledCallback from "~/hooks/useThrottledCallback"; import { hover } from "~/styles"; import { documentPath, urlify } from "~/utils/routeHelpers"; import Avatar from "../Avatar"; import { AvatarSize } from "../Avatar/Avatar"; import ButtonSmall from "../ButtonSmall"; import Empty from "../Empty"; import CollectionIcon from "../Icons/CollectionIcon"; import Input, { NativeInput } from "../Input"; import NudeButton from "../NudeButton"; import Squircle from "../Squircle"; import Tooltip from "../Tooltip"; import DocumentMembersList from "./DocumentMemberList"; import { InviteIcon, StyledListItem } from "./MemberListItem"; import PublicAccess from "./PublicAccess"; 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 useUsersInCollection(collection?: Collection) { const { users, memberships } = useStores(); const { request } = useRequest(() => memberships.fetchPage({ limit: 1, id: collection!.id }) ); React.useEffect(() => { if (collection && !collection.permission) { void request(); } }, [collection]); return collection ? collection.permission ? true : users.inCollection(collection.id).length > 1 : false; } function SharePopover({ document, share, sharedParent, onRequestClose, visible, }: Props) { const team = useCurrentTeam(); const { t } = useTranslation(); const can = usePolicy(document); const inputRef = React.useRef(null); const { 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 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) { 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 handleInvite = React.useCallback( async (user: User) => { setInvitedInSession((prev) => [...prev, user.id]); await userMemberships.create({ documentId: document.id, userId: user.id, }); toast.message( t("{{ userName }} was invited to the document", { userName: user.name }) ); }, [t, userMemberships, document.id] ); 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 backButton = ( <> {picker && ( )} ); const doneButton = picker ? ( invitedInSession.length ? ( {t("Done")} ) : null ) : ( ); return ( {can.manageUsers && (isMobile ? ( {backButton} {doneButton} ) : ( {backButton} {doneButton} ))} {picker && (
)}
{team.sharing && can.share && !collectionSharingDisabled && ( <> {document.members.length ? : null} )}
); } const Picker = observer( ({ document, query, onInvite, }: { document: Document; query: string; onInvite: (user: User) => Promise; }) => { const { users } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); const fetchUsersByQuery = useThrottledCallback( (query) => users.fetchPage({ query }), 250 ); const suggestions = React.useMemo( () => users.notInDocument(document.id, query).filter((u) => u.id !== user.id), [users, users.orderedData, document.id, document.members, user.id, query] ); React.useEffect(() => { if (query) { void fetchUsersByQuery(query); } }, [query, fetchUsersByQuery]); return suggestions.length ? ( <> {suggestions.map((suggestion) => ( onInvite(suggestion)} title={suggestion.name} subtitle={ suggestion.isSuspended ? t("Suspended") : suggestion.isInvited ? t("Invited") : suggestion.isViewer ? t("Viewer") : suggestion.email ? suggestion.email : t("Member") } image={ } actions={} /> ))} ) : ( {t("No matches")} ); } ); const DocumentOtherAccessList = observer( ({ document, children, }: { document: Document; children: React.ReactNode; }) => { const { t } = useTranslation(); const theme = useTheme(); const collection = document.collection; const usersInCollection = useUsersInCollection(collection); const user = useCurrentUser(); return ( <> {collection ? ( <> {collection.permission ? ( } title={t("All members")} subtitle={t("Everyone in the workspace")} actions={ {collection?.permission === CollectionPermission.ReadWrite ? t("Can edit") : t("Can view")} } /> ) : usersInCollection ? ( } title={collection.name} subtitle={t("Everyone in the collection")} actions={{t("Can view")}} /> ) : ( } title={user.name} subtitle={t("You have full access")} actions={{t("Can edit")}} /> )} {children} ) : document.isDraft ? ( <> } title={document.createdBy.name} actions={ {t("Can edit")} } /> {children} ) : ( <> {children} } title={t("Other people")} subtitle={t("Other workspace members may have access")} actions={ } /> )} ); } ); const AccessTooltip = ({ children, tooltip, }: { children?: React.ReactNode; tooltip?: string; }) => { const { t } = useTranslation(); return ( {children} ); }; // 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);