mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a508ec8380 | |||
| c7dde8fbd7 | |||
| 827d4e5ad9 |
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -39,7 +39,6 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -123,13 +122,13 @@ function DocumentCard(props: Props) {
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
pinnedToHome ? (
|
||||
!pin?.collectionId ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -130,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;
|
||||
|
||||
@@ -47,16 +47,14 @@ export default function LanguagePrompt() {
|
||||
<br />
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.set({ languagePromptDismissed: true });
|
||||
ui.setLanguagePromptDismissed();
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
|
||||
{t("Dismiss")}
|
||||
</Link>
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
|
||||
@@ -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"};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -10,7 +11,6 @@ import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
@@ -98,22 +98,15 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<ReactionIcon size={22} />
|
||||
</PopoverButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
@@ -158,4 +151,8 @@ const Placeholder = React.memo(
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
const PopoverButton = styled(NudeButton)`
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default ReactionPicker;
|
||||
|
||||
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
ui.setRightSidebarWidth(width);
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
|
||||
ui.setRightSidebarWidth(theme.sidebarRightWidth);
|
||||
}, [ui, theme.sidebarRightWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
|
||||
@@ -46,6 +46,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
@@ -61,13 +62,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
});
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
},
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -85,13 +86,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
ui.set({ sidebarWidth: width });
|
||||
setWidth(width);
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width]);
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
@@ -148,11 +149,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setWidth(minWidth);
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [ui, minWidth, isCollapsing]);
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
@@ -173,7 +174,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.set({ sidebarWidth: theme.sidebarWidth });
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -298,8 +299,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) {
|
||||
@@ -257,25 +248,15 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
// We construct a string with the text stripped of diacritics plus the original text for
|
||||
// search allowing to search for diacritics-insensitive matches easily.
|
||||
while ((m = search.exec(deburr(text) + text))) {
|
||||
while ((m = search.exec(deburr(text)))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 + m.index,
|
||||
to: pos + m.index + 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"),
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,6 +11,7 @@ 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 = {
|
||||
document: Document;
|
||||
@@ -29,7 +29,7 @@ function TemplatesMenu({ 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} />
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { differenceInMilliseconds } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,14 +16,10 @@ import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import ReactionList from "~/components/Reactions/ReactionList";
|
||||
import ReactionPicker from "~/components/Reactions/ReactionPicker";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -247,13 +242,11 @@ function CommentThreadItem({
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
picker={
|
||||
!comment.isResolved ? (
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
<StyledReactionPicker
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -265,21 +258,13 @@ function CommentThreadItem({
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{!comment.isResolved && (
|
||||
<>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
</>
|
||||
<StyledReactionPicker
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
/>
|
||||
)}
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
<StyledMenu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
@@ -293,38 +278,6 @@ function CommentThreadItem({
|
||||
);
|
||||
}
|
||||
|
||||
const ResolveButton = ({
|
||||
comment,
|
||||
onUpdate,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
@@ -355,13 +308,25 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
const StyledMenu = styled(CommentMenu)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionPicker = styled(ReactionPicker)`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
@@ -387,7 +352,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding-left: 4px;
|
||||
|
||||
&:has(${Action}[aria-expanded="true"]) {
|
||||
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -421,7 +386,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]);
|
||||
|
||||
|
||||
@@ -103,10 +103,6 @@ function DocumentHeader({
|
||||
});
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
@@ -117,8 +113,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
|
||||
@@ -134,7 +129,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleToggle}
|
||||
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
@@ -185,7 +180,7 @@ function DocumentHeader({
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
handleToggle,
|
||||
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
@@ -237,11 +232,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
|
||||
<Trans>{{ collectionCount }} collections</Trans>
|
||||
</strong>
|
||||
</Tooltip>
|
||||
.{" "}
|
||||
.
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
id: "isAdmin",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }: { row: { original: User } }) => (
|
||||
|
||||
+20
-9
@@ -7,19 +7,31 @@ import { CustomTheme } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { PartialExcept } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Store from "./base/Store";
|
||||
|
||||
type PersistedData = Pick<
|
||||
AuthStore,
|
||||
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
|
||||
>;
|
||||
type PersistedData = {
|
||||
user?: PartialExcept<User, "id">;
|
||||
team?: PartialExcept<Team, "id">;
|
||||
collaborationToken?: string;
|
||||
availableTeams?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
isSignedIn: boolean;
|
||||
}[];
|
||||
policies?: Policy[];
|
||||
};
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
@@ -153,10 +165,9 @@ export default class AuthStore extends Store<Team> {
|
||||
/** The current team's policies */
|
||||
@computed
|
||||
get policies() {
|
||||
const policy = this.currentTeamId
|
||||
? this.rootStore.policies.get(this.currentTeamId)
|
||||
: undefined;
|
||||
return policy ? [policy] : [];
|
||||
return this.currentTeamId
|
||||
? [this.rootStore.policies.get(this.currentTeamId)]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Whether the user is signed in */
|
||||
@@ -166,7 +177,7 @@ export default class AuthStore extends Store<Team> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get asJson(): PersistedData {
|
||||
get asJson() {
|
||||
return {
|
||||
user: this.user,
|
||||
team: this.team,
|
||||
|
||||
@@ -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");
|
||||
|
||||
+61
-28
@@ -1,4 +1,4 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -23,16 +23,15 @@ export enum SystemTheme {
|
||||
Dark = "dark",
|
||||
}
|
||||
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "commentsExpanded"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
| "sidebarCollapsed"
|
||||
| "tocVisible"
|
||||
>;
|
||||
type PersistedData = {
|
||||
languagePromptDismissed: boolean | undefined;
|
||||
theme: Theme;
|
||||
sidebarCollapsed: boolean;
|
||||
sidebarWidth: number;
|
||||
sidebarRightWidth: number;
|
||||
tocVisible: boolean | undefined;
|
||||
commentsExpanded: string[];
|
||||
};
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@@ -75,7 +74,7 @@ class UiStore {
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsExpanded = false;
|
||||
commentsExpanded: string[] = [];
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
@@ -99,7 +98,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
|
||||
@@ -135,6 +134,10 @@ class UiStore {
|
||||
this.tocVisible = newData.tocVisible;
|
||||
}
|
||||
});
|
||||
|
||||
autorun(() => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -142,9 +145,14 @@ class UiStore {
|
||||
startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
this.theme = theme;
|
||||
this.persist();
|
||||
});
|
||||
});
|
||||
Storage.set("theme", this.theme);
|
||||
};
|
||||
|
||||
@action
|
||||
setLanguagePromptDismissed = () => {
|
||||
this.languagePromptDismissed = true;
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -197,35 +205,64 @@ class UiStore {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (width: number): void => {
|
||||
this.sidebarWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
setRightSidebarWidth = (width: number): void => {
|
||||
this.sidebarRightWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseSidebar = () => {
|
||||
this.set({ sidebarCollapsed: true });
|
||||
this.sidebarCollapsed = true;
|
||||
};
|
||||
|
||||
@action
|
||||
expandSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.set({ sidebarCollapsed: false });
|
||||
this.sidebarCollapsed = false;
|
||||
};
|
||||
|
||||
@action
|
||||
set = (data: Partial<PersistedData>) => {
|
||||
for (const key in data) {
|
||||
// @ts-expect-error doesn't understand PersistedData is subset of keys
|
||||
this[key] = data[key];
|
||||
collapseComments = (documentId: string) => {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
};
|
||||
|
||||
@action
|
||||
expandComments = (documentId: string) => {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = () => {
|
||||
this.set({ commentsExpanded: !this.commentsExpanded });
|
||||
toggleComments = (documentId: string) => {
|
||||
if (this.commentsExpanded.includes(documentId)) {
|
||||
this.collapseComments(documentId);
|
||||
} else {
|
||||
this.expandComments(documentId);
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
};
|
||||
|
||||
@action
|
||||
showTableOfContents = () => {
|
||||
this.tocVisible = true;
|
||||
};
|
||||
|
||||
@action
|
||||
hideTableOfContents = () => {
|
||||
this.tocVisible = false;
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -287,10 +324,6 @@ class UiStore {
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
private persist = () => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
};
|
||||
}
|
||||
|
||||
export default UiStore;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
+10
-11
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.693.0",
|
||||
"@aws-sdk/lib-storage": "3.693.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@aws-sdk/client-s3": "3.616.0",
|
||||
"@aws-sdk/lib-storage": "3.616.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.616.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.616.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.616.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
@@ -79,11 +79,11 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/auth-app": "^6.1.2",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.119.0",
|
||||
"@sentry/node": "^7.117.0",
|
||||
"@sentry/react": "^7.119.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
@@ -208,7 +208,6 @@
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.10",
|
||||
"reakit": "^1.3.11",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
@@ -240,7 +239,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",
|
||||
@@ -255,7 +254,7 @@
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@relative-ci/agent": "^4.2.12",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -306,7 +305,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",
|
||||
|
||||
@@ -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";
|
||||
@@ -51,15 +51,6 @@ export default abstract class BaseEmail<
|
||||
* @returns A promise that resolves once the email is placed on the task queue
|
||||
*/
|
||||
public schedule(options?: Bull.JobOptions) {
|
||||
// No-op to schedule emails if SMTP is not configured
|
||||
if (!env.SMTP_FROM_EMAIL) {
|
||||
Logger.info(
|
||||
"email",
|
||||
`Email ${this.constructor.name} not sent due to missing SMTP configuration`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = this.constructor.name;
|
||||
|
||||
Metrics.increment("email.scheduled", {
|
||||
@@ -184,22 +175,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import HTMLHelper from "@server/models/helpers/HTMLHelper";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
|
||||
import { can } from "@server/policies";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
@@ -69,28 +68,21 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
|
||||
let body;
|
||||
if (revisionId && team?.getPreference(TeamPreference.PreviewsInEmails)) {
|
||||
body = await CacheHelper.getDataOrSet<string>(
|
||||
`diff:${revisionId}`,
|
||||
async () => {
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
return content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
return;
|
||||
},
|
||||
30
|
||||
);
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
body = content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
@@ -533,7 +558,7 @@ export class ProsemirrorHelper {
|
||||
// Inject Mermaid script
|
||||
if (mermaidElements.length) {
|
||||
element.innerHTML = `
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
fontFamily: "inherit",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Day } from "@shared/utils/time";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Redis from "@server/storage/redis";
|
||||
import { MutexLock } from "./MutexLock";
|
||||
|
||||
/**
|
||||
* A Helper class for server-side cache management
|
||||
@@ -10,54 +9,6 @@ export class CacheHelper {
|
||||
// Default expiry time for cache data in seconds
|
||||
private static defaultDataExpiry = Day.seconds;
|
||||
|
||||
/**
|
||||
* Given a key this method will attempt to get the data from cache store first
|
||||
* If data is not found, it will call the callback to get the data and save it in cache
|
||||
* using a distributed lock to prevent multiple writes.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @param callback Callback to get the data if not found in cache
|
||||
* @param expiry Cache data expiry in seconds
|
||||
*/
|
||||
public static async getDataOrSet<T>(
|
||||
key: string,
|
||||
callback: () => Promise<T | undefined>,
|
||||
expiry?: number
|
||||
): Promise<T | undefined> {
|
||||
let cache = await this.getData<T>(key);
|
||||
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Nothing in the cache, acquire a lock to prevent multiple writes
|
||||
let lock;
|
||||
const lockKey = `lock:${key}`;
|
||||
try {
|
||||
try {
|
||||
lock = await MutexLock.lock.acquire(
|
||||
[lockKey],
|
||||
MutexLock.defaultLockTimeout
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Could not acquire lock for ${key}`, err);
|
||||
}
|
||||
cache = await this.getData<T>(key);
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Get the data from the callback and save it in cache
|
||||
const value = await callback();
|
||||
if (value) {
|
||||
await this.setData<T>(key, value, expiry);
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
await lock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a key, gets the data from cache store
|
||||
*
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Returns the redlock instance
|
||||
*/
|
||||
public static get lock(): Redlock {
|
||||
this.redlock ??= new Redlock([Redis.defaultClient], {
|
||||
retryJitter: 10,
|
||||
retryCount: 20,
|
||||
retryDelay: 200,
|
||||
});
|
||||
|
||||
return this.redlock;
|
||||
}
|
||||
|
||||
private static redlock: 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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user