mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee7911bee | |||
| 86714a353f |
@@ -45,7 +45,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
@@ -562,7 +562,7 @@ export const duplicateDocument = createAction({
|
||||
stores.dialogs.openModal({
|
||||
title: t("Copy document"),
|
||||
content: (
|
||||
<DocumentCopy
|
||||
<DuplicateDialog
|
||||
document={document}
|
||||
onSubmit={(response) => {
|
||||
stores.dialogs.closeAllModals();
|
||||
@@ -1054,7 +1054,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const Actions = styled(Flex)`
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to={logoutPath()} />;
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
|
||||
@@ -8,16 +8,18 @@ import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
type Props = {
|
||||
items: MenuInternalLink[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
function Breadcrumb({
|
||||
items,
|
||||
highlightFirstItem,
|
||||
children,
|
||||
max = 2,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
@@ -35,7 +37,7 @@ function Breadcrumb(
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
@@ -65,8 +67,6 @@ const Slash = styled(GoToIcon)`
|
||||
|
||||
const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
@@ -76,6 +76,7 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
height: 24px;
|
||||
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
|
||||
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
@@ -86,4 +87,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
export default Breadcrumb;
|
||||
|
||||
@@ -18,8 +18,6 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
document: Document;
|
||||
/** The maximum number of collaborators to display, defaults to 6 */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,7 +25,6 @@ type Props = {
|
||||
* and presence status.
|
||||
*/
|
||||
function Collaborators(props: Props) {
|
||||
const { limit = 6 } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
@@ -78,6 +75,8 @@ function Collaborators(props: Props) {
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const limit = 8;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
@@ -89,7 +88,6 @@ function Collaborators(props: Props) {
|
||||
>
|
||||
<Facepile
|
||||
limit={limit}
|
||||
overflow={collaborators.length - limit}
|
||||
users={collaborators}
|
||||
renderAvatar={(collaborator) => {
|
||||
const isPresent = presentIds.includes(collaborator.id);
|
||||
|
||||
@@ -201,6 +201,7 @@ const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -182,6 +182,7 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
|
||||
@@ -262,6 +262,22 @@ export const Position = styled.div`
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
|
||||
outline-color: ${s("accent")};
|
||||
outline-width: initial;
|
||||
outline-offset: -1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -57,10 +57,11 @@ function useCategory(document: Document): MenuInternalLink | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
document,
|
||||
children,
|
||||
onlyText,
|
||||
}: Props) => {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
@@ -139,11 +140,11 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
<Breadcrumb items={items} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: 2px;
|
||||
@@ -159,4 +160,4 @@ const SmallSlash = styled(GoToIcon)`
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(DocumentBreadcrumb));
|
||||
export default observer(DocumentBreadcrumb);
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title: document.title,
|
||||
collectionId: selectedPath.collectionId,
|
||||
...(selectedPath.type === "document"
|
||||
? { parentDocumentId: selectedPath.id }
|
||||
: {}),
|
||||
});
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
onSubmit(result);
|
||||
} catch (err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={copy}
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
values={{ location: selectedPath.title }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
|
||||
/** Items to be shown in explorer */
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -47,25 +47,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
null
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((node) => node.id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
React.RefObject<HTMLSpanElement>[]
|
||||
>([]);
|
||||
@@ -107,15 +94,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "./Input";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DuplicateDialog({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const defaultTitle = t(`Copy of {{ documentName }}`, {
|
||||
documentName: document.title,
|
||||
});
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [title, setTitle] = React.useState<string>(defaultTitle);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title,
|
||||
});
|
||||
onSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
name="title"
|
||||
label={t("Title")}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
defaultValue={defaultTitle}
|
||||
/>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DuplicateDialog);
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ const Scene = styled.div`
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
@@ -11,7 +10,6 @@ import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -23,22 +21,16 @@ export const HEADER_HEIGHT = 64;
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
actions?:
|
||||
| ((props: { isCompact: boolean }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
hasSidebar?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Header(
|
||||
{ left, title, actions, hasSidebar, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
function Header({ left, title, actions, hasSidebar, className }: Props) {
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const passThrough = !actions && !left && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
@@ -61,18 +53,8 @@ function Header(
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement) => {
|
||||
breadcrumbsRef.current = node.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
@@ -80,7 +62,7 @@ function Header(
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
<Breadcrumbs>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
@@ -92,7 +74,7 @@ function Header(
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled && !isCompact ? (
|
||||
{isScrolled ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
@@ -100,7 +82,7 @@ function Header(
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
{actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -148,6 +130,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
`};
|
||||
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: ${HEADER_HEIGHT}px;
|
||||
justify-content: flex-start;
|
||||
@@ -169,6 +152,7 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||
`};
|
||||
`;
|
||||
@@ -207,4 +191,4 @@ const MobileMenuButton = styled(Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(Header));
|
||||
export default observer(Header);
|
||||
|
||||
@@ -76,6 +76,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -174,6 +174,7 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -264,6 +265,7 @@ const Small = styled.div`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("modalBackground")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
box-shadow: ${s("modalShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
@@ -144,6 +144,7 @@ const EmojiButton = styled(NudeButton)<{
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
background: ${s("backgroundTertiary")};
|
||||
pointer-events: ${({ disabled }) => disabled && "none"};
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const Sidebar = styled(m.div)<{
|
||||
max-width: 80%;
|
||||
border-left: 1px solid ${s("divider")};
|
||||
transition: border-left 100ms ease-in-out;
|
||||
z-index: ${depths.sidebar};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
|
||||
@@ -298,8 +298,9 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
|
||||
@@ -112,9 +112,8 @@ const NavLink = ({
|
||||
!rest.target &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!isActive,
|
||||
[rest.target, isActive]
|
||||
!event.ctrlKey,
|
||||
[rest.target]
|
||||
);
|
||||
|
||||
const navigateTo = React.useCallback(() => {
|
||||
@@ -154,13 +153,14 @@ const NavLink = ({
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
// onMouseDown={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -105,6 +105,7 @@ const Button = styled(Flex)<{
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ function SidebarLink(
|
||||
|
||||
const activeStyle = React.useMemo(
|
||||
() => ({
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarActiveBackground,
|
||||
...style,
|
||||
@@ -201,10 +202,10 @@ const Link = styled(NavLink)<{
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 475;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
min-height: 32px;
|
||||
transition: background 50ms, color 50ms;
|
||||
user-select: none;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
|
||||
@@ -48,20 +48,13 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
} else if (
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
star.documentId,
|
||||
star.collectionId,
|
||||
ui.activeDocumentId,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
|
||||
@@ -31,6 +31,7 @@ const Background = styled.div<{ sticky?: boolean }>`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
@@ -253,7 +253,6 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
white-space: nowrap;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
@@ -310,13 +309,15 @@ const Row = styled.tr`
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 2px;
|
||||
padding: 6px 6px 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
cursor: var(--pointer) !important;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
|
||||
@@ -45,6 +45,7 @@ const Sticky = styled.div`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The size to render the indicator, defaults to 24px */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to show an animated typing indicator.
|
||||
*/
|
||||
export default function Typing({ size = 24 }: Props) {
|
||||
return (
|
||||
<Wrapper height={size} width={size}>
|
||||
<Circle cx={size / 4} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 2} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.svg`
|
||||
fill: ${s("textTertiary")};
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
animation: 1s blink infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
`;
|
||||
@@ -529,6 +529,13 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"user.typing",
|
||||
(event: { userId: string; documentId: string; commentId: string }) => {
|
||||
comments.setTyping(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.add_user", async (event: Membership) => {
|
||||
memberships.add(event);
|
||||
await collections.fetch(event.collectionId, {
|
||||
|
||||
@@ -131,15 +131,13 @@ function usePosition({
|
||||
|
||||
// Images need their own positioning to get the toolbar in the center
|
||||
if (isImageSelection) {
|
||||
const element = view.nodeDOM(selection.from);
|
||||
const element = view.nodeDOM(selection.from) as HTMLElement;
|
||||
|
||||
// Images are wrapped which impacts positioning - need to get the element
|
||||
// specifically tagged as the handle
|
||||
const imageElement = element
|
||||
? (element as HTMLElement).getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0]
|
||||
: undefined;
|
||||
const imageElement = element.getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0];
|
||||
if (imageElement) {
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
|
||||
|
||||
@@ -92,10 +92,6 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
public replace(replace: string): Command {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const result = this.results[this.currentResultIndex];
|
||||
|
||||
if (!result) {
|
||||
@@ -110,12 +106,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
public replaceAll(replace: string): Command {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const tr = state.tr;
|
||||
return ({ tr }, dispatch) => {
|
||||
let offset: number | undefined;
|
||||
|
||||
if (!this.results.length) {
|
||||
@@ -265,17 +256,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + m[0].length;
|
||||
const i = m.index > text.length ? m.index - text.length : m.index;
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.results.push({ from, to });
|
||||
this.results.push({
|
||||
from: pos + i,
|
||||
to: pos + i + m[0].length,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid RegExp
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
@@ -103,13 +104,11 @@ export default class Multiplayer extends Extension {
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
commands() {
|
||||
return {
|
||||
undo: () => undo,
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
import { redo, undo } from "prosemirror-history";
|
||||
import { inputRules, InputRule } from "prosemirror-inputrules";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { MarkdownParser } from "prosemirror-markdown";
|
||||
@@ -607,6 +608,20 @@ export class Editor extends React.PureComponent<
|
||||
this.props
|
||||
);
|
||||
|
||||
/**
|
||||
* Undo the last change in the editor.
|
||||
*
|
||||
* @returns True if the undo was successful
|
||||
*/
|
||||
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Redo the last change in the editor.
|
||||
*
|
||||
* @returns True if the change was successful
|
||||
*/
|
||||
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the editor is an empty string.
|
||||
*
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
|
||||
return;
|
||||
}
|
||||
if (isReloaded) {
|
||||
Logger.warn("Attempted to reload twice");
|
||||
Logger.error("lifecycle", new Error("Attempted to reload twice"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ export default function useComponentSize(
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const [size, setSize] = useState({
|
||||
width: ref.current?.clientWidth || 0,
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -21,10 +24,6 @@ export default function useComponentSize(
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -88,10 +87,10 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
name: t("API"),
|
||||
path: settingsPath("tokens"),
|
||||
component: ApiKeys,
|
||||
enabled: can.createApiKey,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
@@ -144,14 +143,6 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -12,17 +11,14 @@ import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
@@ -33,7 +29,7 @@ function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
const templateToMenuItem = React.useCallback(
|
||||
(tmpl: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
|
||||
title: replaceTitleVariables(tmpl.titleWithDefault, user),
|
||||
icon: tmpl.icon ? (
|
||||
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
|
||||
) : (
|
||||
@@ -83,13 +79,8 @@ function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
icon={isCompact ? <ShapesIcon /> : undefined}
|
||||
disclosure={!isCompact}
|
||||
neutral
|
||||
>
|
||||
{isCompact ? undefined : t("Templates")}
|
||||
<Button {...props} disclosure neutral>
|
||||
{t("Templates")}
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { subSeconds } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import uniq from "lodash/uniq";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
@@ -13,6 +15,17 @@ import Relation from "./decorators/Relation";
|
||||
class Comment extends Model {
|
||||
static modelName = "Comment";
|
||||
|
||||
/**
|
||||
* Map to keep track of which users are currently typing a reply in this
|
||||
* comments thread.
|
||||
*/
|
||||
@observable
|
||||
typingUsers: Map<string, Date> = new Map();
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The Prosemirror data representing the comment content
|
||||
*/
|
||||
@@ -94,6 +107,17 @@ class Comment extends Model {
|
||||
*/
|
||||
private reactedUsersLoading = false;
|
||||
|
||||
/**
|
||||
* An array of users that are currently typing a reply in this comments thread.
|
||||
*/
|
||||
@computed
|
||||
public get currentlyTypingUsers(): User[] {
|
||||
return Array.from(this.typingUsers.entries())
|
||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||
.filter(Boolean) as User[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
|
||||
@@ -65,6 +65,10 @@ export default class Document extends ArchivableModel {
|
||||
|
||||
store: DocumentsStore;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
@@ -573,8 +577,6 @@ export default class Document extends ArchivableModel {
|
||||
title?: string;
|
||||
publish?: boolean;
|
||||
recursive?: boolean;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}) => this.store.duplicate(this, options);
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,10 @@ import Field from "./decorators/Field";
|
||||
class Group extends Model {
|
||||
static modelName = "Group";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -18,6 +18,10 @@ import Relation from "./decorators/Relation";
|
||||
class Notification extends Model {
|
||||
static modelName = "Notification";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The date the notification was marked as read.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,10 @@ import Field from "./decorators/Field";
|
||||
class Team extends Model {
|
||||
static modelName = "Team";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -22,6 +22,10 @@ import Field from "./decorators/Field";
|
||||
class User extends ParanoidModel {
|
||||
static modelName = "User";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
avatarUrl: string;
|
||||
|
||||
@@ -5,6 +5,10 @@ import Field from "./decorators/Field";
|
||||
class WebhookSubscription extends Model {
|
||||
static modelName = "WebhookSubscription";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@@ -49,6 +49,8 @@ type Props = {
|
||||
highlightedText?: string;
|
||||
/** The text direction of the editor */
|
||||
dir?: "rtl" | "ltr";
|
||||
/** Callback when the user is typing in the editor */
|
||||
onTyping?: () => void;
|
||||
/** Callback when the editor is focused */
|
||||
onFocus?: () => void;
|
||||
/** Callback when the editor is blurred */
|
||||
@@ -61,6 +63,7 @@ function CommentForm({
|
||||
draft,
|
||||
onSubmit,
|
||||
onSaveDraft,
|
||||
onTyping,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
@@ -179,6 +182,7 @@ function CommentForm({
|
||||
) => {
|
||||
const text = value(true, true);
|
||||
onSaveDraft(text ? value(false, true) : undefined);
|
||||
onTyping?.();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
@@ -10,11 +10,14 @@ import { s } from "@shared/styles";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Typing from "~/components/Typing";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -37,12 +40,28 @@ type Props = {
|
||||
enableScroll: () => void;
|
||||
/** Disable scroll for the comments container */
|
||||
disableScroll: () => void;
|
||||
/** Number of replies before collapsing */
|
||||
collapseThreshold?: number;
|
||||
/** Number of replies to display when collapsed */
|
||||
collapseNumDisplayed?: number;
|
||||
};
|
||||
|
||||
function useTypingIndicator({
|
||||
document,
|
||||
comment,
|
||||
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
|
||||
const socket = React.useContext(WebsocketContext);
|
||||
|
||||
const setIsTyping = React.useMemo(
|
||||
() =>
|
||||
throttle(() => {
|
||||
socket?.emit("typing", {
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
}, 500),
|
||||
[socket, document.id, comment.id]
|
||||
);
|
||||
|
||||
return [undefined, setIsTyping];
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
comment: thread,
|
||||
document,
|
||||
@@ -50,19 +69,21 @@ function CommentThread({
|
||||
focused,
|
||||
enableScroll,
|
||||
disableScroll,
|
||||
collapseThreshold = 5,
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
|
||||
const [, setIsTyping] = useTypingIndicator({
|
||||
document,
|
||||
comment: thread,
|
||||
});
|
||||
const can = usePolicy(document);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
@@ -81,17 +102,6 @@ function CommentThread({
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew);
|
||||
|
||||
const [collapse, setCollapse] = React.useState(() => {
|
||||
const numReplies = commentsInThread.length - 1;
|
||||
if (numReplies >= collapseThreshold) {
|
||||
return {
|
||||
begin: 1,
|
||||
final: commentsInThread.length - collapseNumDisplayed - 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useOnClickOutside(topRef, (event) => {
|
||||
if (
|
||||
focused &&
|
||||
@@ -119,36 +129,6 @@ function CommentThread({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
setCollapse(null);
|
||||
};
|
||||
|
||||
const renderShowMore = (collapse: { begin: number; final: number }) => {
|
||||
const count = collapse.final - collapse.begin + 1;
|
||||
const createdBy = commentsInThread
|
||||
.slice(collapse.begin, collapse.final + 1)
|
||||
.map((c) => c.createdBy);
|
||||
const users = Array.from(new Set(createdBy));
|
||||
const limit = 3;
|
||||
const overflow = users.length - limit;
|
||||
|
||||
return (
|
||||
<ShowMore onClick={handleClickExpand} key="show-more">
|
||||
{t("Show {{ count }} reply", { count })}
|
||||
<Facepile
|
||||
users={users}
|
||||
limit={limit}
|
||||
overflow={overflow}
|
||||
size={AvatarSize.Medium}
|
||||
renderAvatar={(item) => (
|
||||
<Avatar size={AvatarSize.Medium} model={item} />
|
||||
)}
|
||||
/>
|
||||
</ShowMore>
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
@@ -212,17 +192,8 @@ function CommentThread({
|
||||
onClick={handleClickThread}
|
||||
>
|
||||
{commentsInThread.map((comment, index) => {
|
||||
if (collapse !== null) {
|
||||
if (index === collapse.begin) {
|
||||
return renderShowMore(collapse);
|
||||
} else if (index > collapse.begin && index <= collapse.final) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const firstOfAuthor =
|
||||
index === 0 ||
|
||||
(collapse && index === collapse.final + 1) ||
|
||||
comment.createdById !== commentsInThread[index - 1].createdById;
|
||||
const lastOfAuthor =
|
||||
index === commentsInThread.length - 1 ||
|
||||
@@ -248,6 +219,15 @@ function CommentThread({
|
||||
);
|
||||
})}
|
||||
|
||||
{thread.currentlyTypingUsers
|
||||
.filter((typing) => typing.id !== user.id)
|
||||
.map((typing) => (
|
||||
<Flex gap={8} key={typing.id}>
|
||||
<Avatar model={typing} size={24} />
|
||||
<Typing />
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
|
||||
{(focused || draft || commentsInThread.length === 0) && canReply && (
|
||||
<Fade timing={100}>
|
||||
@@ -257,6 +237,7 @@ function CommentThread({
|
||||
draft={draft}
|
||||
documentId={document.id}
|
||||
thread={thread}
|
||||
onTyping={setIsTyping}
|
||||
standalone={commentsInThread.length === 0}
|
||||
dir={document.dir}
|
||||
autoFocus={autoFocus}
|
||||
@@ -295,29 +276,6 @@ const Reply = styled.button`
|
||||
`}
|
||||
`;
|
||||
|
||||
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1px;
|
||||
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
|
||||
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
|
||||
padding: 8px 12px;
|
||||
color: ${s("textTertiary")};
|
||||
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
|
||||
cursor: var(--pointer);
|
||||
font-size: 13px;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
background: ${s("backgroundTertiary")};
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
|
||||
}
|
||||
`;
|
||||
|
||||
const Thread = styled.div<{
|
||||
$focused: boolean;
|
||||
$recessed: boolean;
|
||||
|
||||
@@ -264,19 +264,17 @@ function CommentThreadItem({
|
||||
<EventBoundary>
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
{!comment.isResolved && (
|
||||
<>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
</>
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
)}
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
@@ -421,7 +419,7 @@ export const Bubble = styled(Flex)<{
|
||||
min-width: 2em;
|
||||
margin-bottom: 1px;
|
||||
padding: 8px 12px;
|
||||
transition: color 100ms ease-out, background 100ms ease-out;
|
||||
transition: color 100ms ease-out, ${s("backgroundTransition")};
|
||||
|
||||
${({ $lastOfThread, $canReply }) =>
|
||||
$lastOfThread &&
|
||||
|
||||
@@ -44,7 +44,7 @@ function Comments() {
|
||||
const isAtBottom = React.useRef(true);
|
||||
const [showJumpToRecentBtn, setShowJumpToRecentBtn] = React.useState(false);
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
useKeyDown("Escape", () => document && ui.collapseComments(document?.id));
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document?.id}-new`,
|
||||
@@ -126,7 +126,7 @@ function Comments() {
|
||||
<CommentSortMenu />
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
onClose={() => ui.collapseComments(document?.id)}
|
||||
scrollable={false}
|
||||
>
|
||||
<Scrollable
|
||||
|
||||
@@ -87,6 +87,7 @@ const StickyWrapper = styled.div`
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -45,6 +44,7 @@ import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { SearchResult } from "~/editor/components/LinkEditor";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { replaceTitleVariables } from "~/utils/date";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
@@ -151,13 +151,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const { view, schema } = editorRef;
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
ProsemirrorHelper.replaceTemplateVariables(
|
||||
template.data,
|
||||
this.props.auth.user!
|
||||
)
|
||||
);
|
||||
const doc = Node.fromJSON(schema, template.data);
|
||||
|
||||
if (doc) {
|
||||
view.dispatch(
|
||||
@@ -174,9 +168,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
if (!this.title) {
|
||||
const title = TextHelper.replaceTemplateVariables(
|
||||
const title = replaceTitleVariables(
|
||||
template.title,
|
||||
this.props.auth.user!
|
||||
this.props.auth.user || undefined
|
||||
);
|
||||
this.title = title;
|
||||
this.props.document.title = title;
|
||||
@@ -221,15 +215,13 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
onUndoRedo = (event: KeyboardEvent) => {
|
||||
if (isModKey(event)) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (!this.props.readOnly) {
|
||||
this.editor.current?.commands.redo();
|
||||
if (this.editor.current?.redo()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (!this.props.readOnly) {
|
||||
this.editor.current?.commands.undo();
|
||||
if (this.editor.current?.undo()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,8 +410,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
tocPosition ??
|
||||
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
|
||||
@@ -704,6 +695,7 @@ const Footer = styled.div`
|
||||
const Background = styled(Container)`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
`;
|
||||
|
||||
const ReferencesWrapper = styled.div`
|
||||
|
||||
@@ -46,7 +46,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
•
|
||||
<CommentLink
|
||||
to={documentPath(document)}
|
||||
onClick={() => ui.toggleComments()}
|
||||
onClick={() => ui.toggleComments(document.id)}
|
||||
>
|
||||
<CommentIcon size={18} />
|
||||
{commentsCount
|
||||
|
||||
@@ -116,7 +116,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
state: { commentId: focusedComment.id },
|
||||
});
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
ui.expandComments(document.id);
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { navigateToTemplateSettings } from "~/actions/definitions/navigation";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
@@ -87,13 +86,11 @@ function DocumentHeader({
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { resolvedTheme } = ui;
|
||||
const isMobileMedia = useMobile();
|
||||
const isMobile = useMobile();
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const size = useComponentSize(ref);
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
const { editor } = useDocumentContext();
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -120,8 +117,7 @@ function DocumentHeader({
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
|
||||
const toc = (
|
||||
<Tooltip
|
||||
@@ -233,7 +229,6 @@ function DocumentHeader({
|
||||
return (
|
||||
<>
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
$hidden={isEditingFocus}
|
||||
hasSidebar
|
||||
left={
|
||||
@@ -241,11 +236,7 @@ function DocumentHeader({
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{document.isTemplate ? null : (
|
||||
<>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</>
|
||||
)}
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -258,7 +249,7 @@ function DocumentHeader({
|
||||
{document.isArchived && <Badge>{t("Archived")}</Badge>}
|
||||
</Flex>
|
||||
}
|
||||
actions={({ isCompact }) => (
|
||||
actions={
|
||||
<>
|
||||
<ObservingBanner />
|
||||
|
||||
@@ -266,15 +257,11 @@ function DocumentHeader({
|
||||
<Status>{t("Saving")}…</Status>
|
||||
)}
|
||||
{!isDeleted && !isRevision && can.listViews && (
|
||||
<Collaborators
|
||||
document={document}
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
<Collaborators document={document} />
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
@@ -314,7 +301,6 @@ function DocumentHeader({
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isCompact &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
@@ -386,7 +372,7 @@ function DocumentHeader({
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -273,7 +273,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
<>
|
||||
{showCache && (
|
||||
<Editor
|
||||
editorStyle={props.editorStyle}
|
||||
embedsDisabled={props.embedsDisabled}
|
||||
defaultValue={props.defaultValue}
|
||||
extensions={props.extensions}
|
||||
@@ -291,8 +290,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
style={
|
||||
showCache
|
||||
? {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -31,13 +31,11 @@ function References({ document }: Props) {
|
||||
: [];
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildDocuments = !!children.length;
|
||||
const shouldFade = React.useRef(!showBacklinks && !showChildDocuments);
|
||||
const isBacklinksTab = location.hash === "#backlinks" || !showChildDocuments;
|
||||
const height = Math.max(backlinks.length, children.length) * 40;
|
||||
const Component = shouldFade.current ? Fade : React.Fragment;
|
||||
|
||||
return showBacklinks || showChildDocuments ? (
|
||||
<Component>
|
||||
<Fade>
|
||||
<Tabs>
|
||||
{showChildDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
@@ -82,7 +80,7 @@ function References({ document }: Props) {
|
||||
</List>
|
||||
)}
|
||||
</Content>
|
||||
</Component>
|
||||
</Fade>
|
||||
) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,24 +28,12 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
// Recursively filter out the document itself and its existing parent doc, if any.
|
||||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||||
...node,
|
||||
children: node.children
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
// Filter out the document itself and its existing parent doc, if any.
|
||||
const nodes = flatten(collectionTrees.map(flattenTree))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.id !== document.id && node.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument)
|
||||
// Filter out collections that we don't have permission to create documents in.
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
@@ -120,21 +108,21 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export const StyledText = styled(Text)`
|
||||
const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
@@ -118,6 +118,7 @@ function Home() {
|
||||
const Documents = styled.div`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
`;
|
||||
|
||||
export default observer(Home);
|
||||
|
||||
@@ -231,8 +231,7 @@ function Login({ children }: Props) {
|
||||
config.providers.length === 1 &&
|
||||
config.providers[0].id === "oidc" &&
|
||||
!env.OIDC_DISABLE_REDIRECT &&
|
||||
!query.get("notice") &&
|
||||
!query.get("logout")
|
||||
!query.get("notice")
|
||||
) {
|
||||
window.location.href = getRedirectUrl(config.providers[0].authUrl);
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
|
||||
const Logout = () => {
|
||||
const { auth } = useStores();
|
||||
@@ -18,7 +17,7 @@ const Logout = () => {
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
return null; // user will be redirected to logout URI after logout
|
||||
}
|
||||
return <Redirect to={logoutPath()} />;
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
export default Logout;
|
||||
|
||||
@@ -59,6 +59,7 @@ const StyledInput = styled.input`
|
||||
outline: none;
|
||||
border: 0;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${s("text")};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
@@ -12,17 +13,36 @@ import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
(keyId: string) => {
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopiedKeyId(keyId);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopiedKeyId(null);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
@@ -42,11 +62,12 @@ function ApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API Keys")}</Heading>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="API keys can be used to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -60,10 +81,16 @@ function ApiKeys() {
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
isCopied={apiKey.id === copiedKeyId}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function PersonalApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
@@ -220,6 +220,9 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{!data.inviteRequired && (
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
)}
|
||||
{!data.inviteRequired && (
|
||||
<SettingRow
|
||||
label={t("Default role")}
|
||||
@@ -249,8 +252,6 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
|
||||
<h2>{t("Behavior")}</h2>
|
||||
<SettingRow
|
||||
label={t("Public document sharing")}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ActionRow = styled.div`
|
||||
margin: 0 -50vw;
|
||||
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
@supports (backdrop-filter: blur(20px)) {
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -10,29 +8,24 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The API key to display */
|
||||
apiKey: ApiKey;
|
||||
isCopied: boolean;
|
||||
onCopy: (keyId: string) => void;
|
||||
};
|
||||
|
||||
const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const subtitle = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
·{" "}
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> ·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
@@ -48,19 +41,9 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
}, [t]);
|
||||
onCopy(apiKey.id);
|
||||
}, [apiKey.id, onCopy]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -69,10 +52,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{apiKey.value && handleCopy && (
|
||||
{apiKey.value && (
|
||||
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
@@ -91,4 +74,4 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ApiKeyListItem);
|
||||
export default ApiKeyListItem;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -109,25 +110,29 @@ function DomainManagement({ onSuccess }: Props) {
|
||||
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
|
||||
{!allowedDomains.length ||
|
||||
allowedDomains[allowedDomains.length - 1] !== "" ? (
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{allowedDomains.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{allowedDomains.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Fade>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{showSaveChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveDomains}
|
||||
disabled={team.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
<Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveDomains}
|
||||
disabled={team.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
|
||||
@@ -150,6 +150,20 @@ export default class CommentsStore extends Store<Comment> {
|
||||
return this.data.get(res.data.id) as Comment;
|
||||
};
|
||||
|
||||
@action
|
||||
setTyping({
|
||||
commentId,
|
||||
userId,
|
||||
}: {
|
||||
commentId: string;
|
||||
userId: string;
|
||||
}): void {
|
||||
const comment = this.get(commentId);
|
||||
if (comment) {
|
||||
comment.typingUsers.set(userId, new Date());
|
||||
}
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Comment[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
|
||||
|
||||
+25
-5
@@ -75,7 +75,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsExpanded = false;
|
||||
commentsExpanded: string[] = [];
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -99,7 +99,7 @@ class UiStore {
|
||||
this.sidebarRightWidth =
|
||||
data.sidebarRightWidth || defaultTheme.sidebarRightWidth;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.commentsExpanded = !!data.commentsExpanded;
|
||||
this.commentsExpanded = data.commentsExpanded || [];
|
||||
this.theme = data.theme || Theme.System;
|
||||
|
||||
// system theme listeners
|
||||
@@ -142,9 +142,9 @@ class UiStore {
|
||||
startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
});
|
||||
});
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -218,8 +218,28 @@ class UiStore {
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = () => {
|
||||
this.set({ commentsExpanded: !this.commentsExpanded });
|
||||
collapseComments = (documentId: string) => {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
expandComments = (documentId: string) => {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = (documentId: string) => {
|
||||
if (this.commentsExpanded.includes(documentId)) {
|
||||
this.collapseComments(documentId);
|
||||
} else {
|
||||
this.expandComments(documentId);
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
Vendored
+1
@@ -124,6 +124,7 @@ declare module "styled-components" {
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
backgroundQuaternary: string;
|
||||
backgroundTransition: string;
|
||||
accent: string;
|
||||
accentText: string;
|
||||
link: string;
|
||||
|
||||
+28
-1
@@ -10,7 +10,16 @@ import {
|
||||
isPast,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { dateLocale, locales } from "@shared/utils/date";
|
||||
import startCase from "lodash/startCase";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
dateLocale,
|
||||
locales,
|
||||
} from "@shared/utils/date";
|
||||
import User from "~/models/User";
|
||||
|
||||
export function dateToHeading(
|
||||
dateTime: string,
|
||||
@@ -112,3 +121,21 @@ export function dateToExpiry(
|
||||
date: formatDate(date, "MMM dd, yyyy", { locale }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces template variables in the given text with the current date and time.
|
||||
*
|
||||
* @param text The text to replace the variables in
|
||||
* @param user The user to get the language/locale from
|
||||
* @returns The text with the variables replaced
|
||||
*/
|
||||
export function replaceTitleVariables(text: string, user?: User) {
|
||||
const locales = user?.language
|
||||
? unicodeCLDRtoBCP47(user.language)
|
||||
: undefined;
|
||||
|
||||
return text
|
||||
.replace("{date}", startCase(getCurrentDateAsString(locales)))
|
||||
.replace("{time}", startCase(getCurrentTimeAsString(locales)))
|
||||
.replace("{datetime}", startCase(getCurrentDateTimeAsString(locales)));
|
||||
}
|
||||
|
||||
@@ -8,13 +8,6 @@ export function homePath(): string {
|
||||
return env.ROOT_SHARE_ID ? "/" : "/home";
|
||||
}
|
||||
|
||||
export function logoutPath() {
|
||||
return {
|
||||
pathname: "/",
|
||||
search: "logout=true",
|
||||
};
|
||||
}
|
||||
|
||||
export function draftsPath(): string {
|
||||
return "/drafts";
|
||||
}
|
||||
|
||||
+3
-3
@@ -240,7 +240,7 @@
|
||||
"utility-types": "^3.10.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.11",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -306,7 +306,7 @@
|
||||
"@types/react-table": "^7.7.18",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.18",
|
||||
"@types/readable-stream": "^4.0.15",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
"@types/refractor": "^3.4.1",
|
||||
"@types/resolve-path": "^1.4.2",
|
||||
@@ -351,7 +351,7 @@
|
||||
"nodemon": "^3.1.7",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.36.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Optional } from "utility-types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function documentDuplicator({
|
||||
};
|
||||
|
||||
const duplicated = await documentCreator({
|
||||
parentDocumentId,
|
||||
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
template: document.template,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import pinCreator from "./pinCreator";
|
||||
|
||||
describe("pinCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create pin to home", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -11,13 +12,11 @@ describe("pinCreator", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await withAPIContext(user, (ctx) =>
|
||||
pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId: document.id,
|
||||
})
|
||||
);
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
@@ -37,14 +36,12 @@ describe("pinCreator", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const pin = await withAPIContext(user, (ctx) =>
|
||||
pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
})
|
||||
);
|
||||
const pin = await pinCreator({
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
|
||||
@@ -2,12 +2,10 @@ import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import { PinValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Pin, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { Pin, User, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
type Props = {
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
/** The user creating the pin */
|
||||
user: User;
|
||||
/** The document to pin */
|
||||
@@ -16,6 +14,8 @@ type Props = {
|
||||
collectionId?: string | null;
|
||||
/** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,10 +26,10 @@ type Props = {
|
||||
* @returns Pin The pin that was created
|
||||
*/
|
||||
export default async function pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
...rest
|
||||
}: Props): Promise<Pin> {
|
||||
let { index } = rest;
|
||||
@@ -62,13 +62,38 @@ export default async function pinCreator({
|
||||
index = fractionalIndex(pins.length ? pins[0].index : null, null);
|
||||
}
|
||||
|
||||
const pin = await Pin.createWithCtx(ctx, {
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
});
|
||||
const transaction = await sequelize.transaction();
|
||||
let pin;
|
||||
|
||||
try {
|
||||
pin = await Pin.create(
|
||||
{
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.create",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use pin.destroyWithCtx instead. This will be removed once document routes migrate to auto event insertion using APIContext.
|
||||
*
|
||||
* This command destroys a document pin. This just removes the pin itself and
|
||||
* does not touch the document
|
||||
*
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Event, Pin, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the pin */
|
||||
user: User;
|
||||
/** The existing pin */
|
||||
pin: Pin;
|
||||
/** The index to pin the document at */
|
||||
index: string;
|
||||
/** The IP address of the user creating the pin */
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a "pinned" document. A pin can only be moved to a new
|
||||
* index (reordered) once created.
|
||||
*
|
||||
* @param Props The properties of the pin to update
|
||||
* @returns Pin The updated pin
|
||||
*/
|
||||
export default async function pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
index,
|
||||
ip,
|
||||
}: Props): Promise<Pin> {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
pin.index = index;
|
||||
await pin.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "pins.update",
|
||||
modelId: pin.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: pin.documentId,
|
||||
collectionId: pin.collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return pin;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
|
||||
|
||||
type SendMailOptions = {
|
||||
to: string;
|
||||
from: EmailAddress;
|
||||
from?: EmailAddress | string;
|
||||
replyTo?: string;
|
||||
messageId?: string;
|
||||
references?: string[];
|
||||
@@ -143,7 +143,7 @@ export class Mailer {
|
||||
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: data.from,
|
||||
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
|
||||
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
|
||||
to: data.to,
|
||||
messageId: data.messageId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import addressparser, { EmailAddress } from "addressparser";
|
||||
import addressparser from "addressparser";
|
||||
import Bull from "bull";
|
||||
import invariant from "invariant";
|
||||
import { Node } from "prosemirror-model";
|
||||
@@ -184,22 +184,26 @@ export default abstract class BaseEmail<
|
||||
}
|
||||
}
|
||||
|
||||
private from(props: S & T): EmailAddress {
|
||||
private from(props: S & T) {
|
||||
invariant(
|
||||
env.SMTP_FROM_EMAIL,
|
||||
"SMTP_FROM_EMAIL is required to send emails"
|
||||
);
|
||||
|
||||
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
const name = this.fromName?.(props);
|
||||
|
||||
if (this.category === EmailMessageCategory.Authentication) {
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
return {
|
||||
name: name ?? parsedFrom.name,
|
||||
address: `noreply-${randomstring.generate(24)}@${domain}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.fromName?.(props) ?? parsedFrom.name,
|
||||
address:
|
||||
env.isCloudHosted &&
|
||||
this.category === EmailMessageCategory.Authentication
|
||||
? `noreply-${randomstring.generate(24)}@${domain}`
|
||||
: parsedFrom.address,
|
||||
name: name ?? parsedFrom.name,
|
||||
address: parsedFrom.address,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn("teams", "approximateTotalAttachmentsSize", {
|
||||
type: Sequelize.BIGINT,
|
||||
defaultValue: 0,
|
||||
}, { transaction });
|
||||
await queryInterface.addIndex("attachments", ["createdAt"], { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex("attachments", ["createdAt"], { transaction });
|
||||
await queryInterface.removeColumn("teams", "approximateTotalAttachmentsSize", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -20,8 +20,6 @@ class Pin extends IdModel<
|
||||
InferAttributes<Pin>,
|
||||
Partial<InferCreationAttributes<Pin>>
|
||||
> {
|
||||
static eventNamespace = "pins";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
AfterUpdate,
|
||||
BeforeUpdate,
|
||||
BeforeCreate,
|
||||
IsNumeric,
|
||||
} from "sequelize-typescript";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
|
||||
@@ -152,11 +151,6 @@ class Team extends ParanoidModel<
|
||||
@Column(DataType.STRING)
|
||||
defaultUserRole: UserRole;
|
||||
|
||||
/** Approximate size in bytes of all attachments in the team. */
|
||||
@IsNumeric
|
||||
@Column(DataType.BIGINT)
|
||||
approximateTotalAttachmentsSize: number;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.JSONB)
|
||||
preferences: TeamPreferences | null;
|
||||
|
||||
@@ -21,7 +21,9 @@ import { schema, parser } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import User from "@server/models/User";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
|
||||
export type HTMLOptions = {
|
||||
/** A title, if it should be included */
|
||||
@@ -262,6 +264,29 @@ export class ProsemirrorHelper {
|
||||
return removeMarksInner(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all template variables in the node.
|
||||
*
|
||||
* @param data The ProsemirrorData object to replace variables in
|
||||
* @param user The user to use for replacing variables
|
||||
* @returns The content with variables replaced
|
||||
*/
|
||||
static replaceTemplateVariables(data: ProsemirrorData, user: User) {
|
||||
function replace(node: ProsemirrorData) {
|
||||
if (node.type === "text" && node.text) {
|
||||
node.text = TextHelper.replaceTemplateVariables(node.text, user);
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
node.content.forEach(replace);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return replace(data);
|
||||
}
|
||||
|
||||
static async replaceInternalUrls(
|
||||
doc: Node | ProsemirrorData,
|
||||
basePath: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
|
||||
describe("TextHelper", () => {
|
||||
@@ -11,21 +12,18 @@ describe("TextHelper", () => {
|
||||
});
|
||||
|
||||
describe("replaceTemplateVariables", () => {
|
||||
const user = {
|
||||
name: "John Doe",
|
||||
language: "en",
|
||||
};
|
||||
|
||||
it("should replace {time} with current time", async () => {
|
||||
const user = await buildUser();
|
||||
const result = TextHelper.replaceTemplateVariables("Hello {time}", user);
|
||||
|
||||
expect(result).toBe("Hello 12:00 AM");
|
||||
expect(result).toBe("Hello 12 00 AM");
|
||||
});
|
||||
|
||||
it("should replace {date} with current date", async () => {
|
||||
const user = await buildUser();
|
||||
const result = TextHelper.replaceTemplateVariables("Hello {date}", user);
|
||||
|
||||
expect(result).toBe("Hello January 1, 2021");
|
||||
expect(result).toBe("Hello January 1 2021");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import startCase from "lodash/startCase";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
} from "@shared/utils/date";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import env from "@server/env";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
@@ -12,6 +19,25 @@ import parseImages from "@server/utils/parseImages";
|
||||
|
||||
@trace()
|
||||
export class TextHelper {
|
||||
/**
|
||||
* Replaces template variables in the given text with the current date and time.
|
||||
*
|
||||
* @param text The text to replace the variables in
|
||||
* @param user The user to get the language/locale from
|
||||
* @returns The text with the variables replaced
|
||||
*/
|
||||
static replaceTemplateVariables(text: string, user: User) {
|
||||
const locales = user.language
|
||||
? unicodeCLDRtoBCP47(user.language)
|
||||
: undefined;
|
||||
|
||||
return text
|
||||
.replace(/{date}/g, startCase(getCurrentDateAsString(locales)))
|
||||
.replace(/{time}/g, startCase(getCurrentTimeAsString(locales)))
|
||||
.replace(/{datetime}/g, startCase(getCurrentDateTimeAsString(locales)))
|
||||
.replace(/{author}/g, user.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts attachment urls in documents to signed equivalents that allow
|
||||
* direct access without a session cookie
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { ApiKey, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isOwner,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
} from "./utils";
|
||||
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
|
||||
|
||||
allow(User, "createApiKey", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -24,7 +18,6 @@ allow(User, "createApiKey", Team, (actor, team) =>
|
||||
allow(User, "listApiKeys", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isCloudHosted(),
|
||||
isTeamModel(actor, team),
|
||||
actor.isAdmin
|
||||
)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Attachment, Team } from "@server/models";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
/** The teamId to operate on */
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task that updates the team stats.
|
||||
*/
|
||||
export default class UpdateTeamAttachmentsSizeTask extends BaseTask<Props> {
|
||||
public async perform({ teamId }: Props) {
|
||||
const sizeInBytes = await Attachment.getTotalSizeForTeam(teamId);
|
||||
if (!sizeInBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Team.update(
|
||||
{ approximateTotalAttachmentsSize: sizeInBytes },
|
||||
{ where: { id: teamId } }
|
||||
);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 1,
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Attachment } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask";
|
||||
import UpdateTeamAttachmentsSizeTask from "./UpdateTeamAttachmentsSizeTask";
|
||||
|
||||
type Props = {
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
|
||||
static cron = TaskSchedule.Daily;
|
||||
|
||||
public async perform({ limit }: Props) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Recalculating attachment sizes for upto ${limit} teams…`
|
||||
);
|
||||
|
||||
// Find unique attachment teamIds created in the last day, update only
|
||||
// those teams' approximate attachment sizes
|
||||
await Attachment.findAllInBatches<Attachment>(
|
||||
{
|
||||
attributes: [
|
||||
[sequelize.fn("DISTINCT", sequelize.col("teamId")), "teamId"],
|
||||
],
|
||||
where: {
|
||||
createdAt: {
|
||||
[Op.gt]: subDays(new Date(), 1),
|
||||
},
|
||||
},
|
||||
batchLimit: 100,
|
||||
raw: true,
|
||||
},
|
||||
async (rows) => {
|
||||
const teamIds = rows.map((row) => row.teamId);
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 1,
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
|
||||
contentType: z.string().optional().default("application/octet-stream"),
|
||||
|
||||
/** Attachment type */
|
||||
preset: z
|
||||
.nativeEnum(AttachmentPreset)
|
||||
.default(AttachmentPreset.DocumentAttachment),
|
||||
preset: z.nativeEnum(AttachmentPreset),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op, Transaction } from "sequelize";
|
||||
import { Sequelize, Op } from "sequelize";
|
||||
import pinCreator from "@server/commands/pinCreator";
|
||||
import pinDestroyer from "@server/commands/pinDestroyer";
|
||||
import pinUpdater from "@server/commands/pinUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Collection, Document, Pin } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
@@ -21,21 +22,18 @@ router.post(
|
||||
"pins.create",
|
||||
auth(),
|
||||
validate(T.PinsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsCreateReq>) => {
|
||||
const { documentId, collectionId, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, { transaction });
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "update", collection);
|
||||
authorize(user, "pin", document);
|
||||
} else {
|
||||
@@ -43,10 +41,10 @@ router.post(
|
||||
}
|
||||
|
||||
const pin = await pinCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
|
||||
@@ -110,20 +108,13 @@ router.post(
|
||||
"pins.update",
|
||||
auth(),
|
||||
validate(T.PinsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsUpdateReq>) => {
|
||||
const { id, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const pin = await Pin.findByPk(id, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
let pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
@@ -132,7 +123,12 @@ router.post(
|
||||
authorize(user, "update", pin);
|
||||
}
|
||||
|
||||
await pin.updateWithCtx(ctx, { index });
|
||||
pin = await pinUpdater({
|
||||
user,
|
||||
pin,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentPin(pin),
|
||||
@@ -145,21 +141,14 @@ router.post(
|
||||
"pins.delete",
|
||||
auth(),
|
||||
validate(T.PinsDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PinsDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const pin = await Pin.findByPk(id, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const pin = await Pin.findByPk(id, { rejectOnEmpty: true });
|
||||
|
||||
const document = await Document.findByPk(pin.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (pin.collectionId) {
|
||||
@@ -168,7 +157,7 @@ router.post(
|
||||
authorize(user, "delete", pin);
|
||||
}
|
||||
|
||||
await pin.destroyWithCtx(ctx);
|
||||
await pinDestroyer({ user, pin, ip: ctx.request.ip });
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -145,13 +145,14 @@ export default class S3Storage extends BaseStorage {
|
||||
const params = {
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
Expires: expiresIn,
|
||||
};
|
||||
|
||||
if (isDocker) {
|
||||
return `${this.getPublicEndpoint()}/${key}`;
|
||||
} else {
|
||||
const command = new GetObjectCommand(params);
|
||||
const url = await getSignedUrl(this.client, command, { expiresIn });
|
||||
const url = await getSignedUrl(this.client, command);
|
||||
|
||||
if (env.AWS_S3_ACCELERATE_URL) {
|
||||
return url.replace(
|
||||
@@ -230,9 +231,6 @@ export default class S3Storage extends BaseStorage {
|
||||
if (env.AWS_S3_UPLOAD_BUCKET_NAME) {
|
||||
const url = new URL(env.AWS_S3_UPLOAD_BUCKET_URL);
|
||||
if (url.hostname.startsWith(env.AWS_S3_UPLOAD_BUCKET_NAME + ".")) {
|
||||
Logger.warn(
|
||||
"AWS_S3_UPLOAD_BUCKET_URL contains the bucket name, this configuration combination will always point to AWS.\nRename your bucket or hostname if not using AWS S3.\nSee: https://github.com/outline/outline/issues/8025"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Redlock from "redlock";
|
||||
import Redis from "@server/storage/redis";
|
||||
|
||||
export class MutexLock {
|
||||
// Default expiry time for acquiring lock in milliseconds
|
||||
public static defaultLockTimeout = 4000;
|
||||
// Default expiry time for qcuiring lock in milliseconds
|
||||
public static defaultLockTimeout = 5000;
|
||||
|
||||
/**
|
||||
* Returns the redlock instance
|
||||
@@ -11,8 +11,6 @@ export class MutexLock {
|
||||
public static get lock(): Redlock {
|
||||
this.redlock ??= new Redlock([Redis.defaultClient], {
|
||||
retryJitter: 10,
|
||||
retryCount: 20,
|
||||
retryDelay: 200,
|
||||
});
|
||||
|
||||
return this.redlock;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getCookieDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Event, Collection, View } from "@server/models";
|
||||
import { AuthenticationResult, AuthenticationType } from "@server/types";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
|
||||
/**
|
||||
* Parse and return the details from the "sessions" cookie in the request, if
|
||||
@@ -68,7 +68,6 @@ export async function signIn(
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
authType: AuthenticationType.APP,
|
||||
data: {
|
||||
name: user.name,
|
||||
service,
|
||||
|
||||
@@ -52,8 +52,6 @@ const mathStyle = (props: Props) => css`
|
||||
font-size: 0.95em;
|
||||
font-family: ${props.theme.fontFamilyMono};
|
||||
cursor: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.math-node.empty-math .math-render::before {
|
||||
@@ -703,10 +701,7 @@ img.ProseMirror-separator {
|
||||
}
|
||||
|
||||
.heading-name:first-child,
|
||||
// Edge case where multiplayer cursor is between start of cell and heading
|
||||
.heading-name:first-child + .ProseMirror-yjs-cursor,
|
||||
// Edge case where table grips are between start of cell and heading
|
||||
.heading-name:first-child + [role=button] + [role=button] {
|
||||
.heading-name:first-child + .ProseMirror-yjs-cursor {
|
||||
& + h1,
|
||||
& + h2,
|
||||
& + h3,
|
||||
@@ -1070,11 +1065,11 @@ a:hover {
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0.1em 0 ${props.staticHTML ? "0" : "-26px"};
|
||||
margin: 0 0.1em 0 -26px;
|
||||
padding: 0 0 0 48px;
|
||||
|
||||
&:dir(rtl) {
|
||||
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
|
||||
margin: 0 -26px 0 0.1em;
|
||||
padding: 0 48px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { history, undo, redo } from "prosemirror-history";
|
||||
import { undoInputRule } from "prosemirror-inputrules";
|
||||
import { Command } from "prosemirror-state";
|
||||
import Extension, { CommandFactory } from "../lib/Extension";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class History extends Extension {
|
||||
get name() {
|
||||
return "history";
|
||||
}
|
||||
|
||||
commands(): Record<string, CommandFactory> {
|
||||
keys() {
|
||||
return {
|
||||
undo: () => undo,
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
|
||||
keys(): Record<string, Command | CommandFactory> {
|
||||
return {
|
||||
"Mod-z": () => this.editor.commands.undo(),
|
||||
"Mod-y": () => this.editor.commands.redo(),
|
||||
"Shift-Mod-z": () => this.editor.commands.redo(),
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Shift-Mod-z": redo,
|
||||
Backspace: undoInputRule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Command, Plugin } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import type { Editor } from "../../../app/editor";
|
||||
|
||||
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
|
||||
export type CommandFactory = (
|
||||
attrs?: Record<string, Primitive>
|
||||
) => Command | void;
|
||||
|
||||
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
|
||||
|
||||
@@ -71,7 +73,7 @@ export default class Extension {
|
||||
keys(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, Command | CommandFactory> {
|
||||
}): Record<string, Command> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -251,13 +251,13 @@ export default class ExtensionManager {
|
||||
};
|
||||
|
||||
const handle = (_name: string, _value: CommandFactory) => {
|
||||
const values: CommandFactory[] = Array.isArray(_value)
|
||||
? _value
|
||||
: [_value];
|
||||
|
||||
// @ts-expect-error FIXME
|
||||
commands[_name] = (attrs: Record<string, Primitive>) =>
|
||||
values.forEach((callback) => apply(callback, attrs));
|
||||
if (Array.isArray(_value)) {
|
||||
commands[_name] = (attrs: Record<string, Primitive>) =>
|
||||
_value.forEach((callback) => apply(callback, attrs));
|
||||
} else if (typeof _value === "function") {
|
||||
commands[_name] = ((attrs: Record<string, Primitive>) =>
|
||||
apply(_value, attrs)) as CommandFactory;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof value === "object") {
|
||||
|
||||
@@ -36,12 +36,7 @@ this is code
|
||||
});
|
||||
|
||||
test("returns true for latex fence", () => {
|
||||
expect(isMarkdown(`\$i\$`)).toBe(true);
|
||||
expect(
|
||||
isMarkdown(`\$0.00
|
||||
random content
|
||||
\$1.00`)
|
||||
).toBe(false);
|
||||
expect(isMarkdown(`\$\$i\$\$`)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for non-closed fence", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user