359 lines
9.1 KiB
TypeScript
359 lines
9.1 KiB
TypeScript
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<HTMLInputElement>(null);
|
|
const { users, userMemberships } = useStores();
|
|
const isMobile = useMobile();
|
|
const [query, setQuery] = React.useState("");
|
|
const [picker, showPicker, hidePicker] = useBoolean();
|
|
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
|
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
|
|
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
|
|
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 && (
|
|
<NudeButton key="back" as={m.button} {...presence} onClick={hidePicker}>
|
|
<BackIcon />
|
|
</NudeButton>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const rightButton = picker ? (
|
|
pendingIds.length ? (
|
|
<ButtonSmall action={inviteAction} context={context} key="invite">
|
|
{t("Invite")}
|
|
</ButtonSmall>
|
|
) : null
|
|
) : (
|
|
<Tooltip
|
|
tooltip={t("Copy link")}
|
|
delay={500}
|
|
placement="top"
|
|
key="copy-link"
|
|
>
|
|
<CopyToClipboard
|
|
text={urlify(documentPath(document))}
|
|
onCopy={handleCopied}
|
|
>
|
|
<NudeButton type="button" disabled={!share} ref={linkButtonRef}>
|
|
<LinkIcon size={20} />
|
|
</NudeButton>
|
|
</CopyToClipboard>
|
|
</Tooltip>
|
|
);
|
|
|
|
return (
|
|
<Wrapper>
|
|
{can.manageUsers &&
|
|
(isMobile ? (
|
|
<Flex align="center" style={{ marginBottom: 12 }} auto>
|
|
{backButton}
|
|
<Input
|
|
key="input"
|
|
placeholder={`${t("Invite by name")}…`}
|
|
value={query}
|
|
onChange={handleQuery}
|
|
onClick={showPicker}
|
|
autoFocus
|
|
margin={0}
|
|
flex
|
|
>
|
|
{rightButton}
|
|
</Input>
|
|
</Flex>
|
|
) : (
|
|
<HeaderInput align="center" onClick={focusInput}>
|
|
<AnimatePresence initial={false}>
|
|
{backButton}
|
|
<NativeInput
|
|
key="input"
|
|
ref={inputRef}
|
|
placeholder={`${t("Invite by name")}…`}
|
|
value={query}
|
|
onChange={handleQuery}
|
|
onClick={showPicker}
|
|
style={{ padding: "6px 0" }}
|
|
/>
|
|
{rightButton}
|
|
</AnimatePresence>
|
|
</HeaderInput>
|
|
))}
|
|
|
|
{picker && (
|
|
<div>
|
|
<UserSuggestions
|
|
document={document}
|
|
query={query}
|
|
pendingIds={pendingIds}
|
|
addPendingId={handleAddPendingId}
|
|
removePendingId={handleRemovePendingId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: picker ? "none" : "block" }}>
|
|
<OtherAccess document={document}>
|
|
<DocumentMembersList
|
|
document={document}
|
|
invitedInSession={invitedInSession}
|
|
/>
|
|
</OtherAccess>
|
|
|
|
{team.sharing && can.share && !collectionSharingDisabled && (
|
|
<>
|
|
{document.members.length ? <Separator /> : null}
|
|
<PublicAccess
|
|
document={document}
|
|
share={share}
|
|
sharedParent={sharedParent}
|
|
onRequestClose={onRequestClose}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Wrapper>
|
|
);
|
|
}
|
|
|
|
// 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);
|