Compare commits

..

3 Commits

Author SHA1 Message Date
Tom Moor a508ec8380 wip 2024-11-16 13:34:04 -05:00
Tom Moor c7dde8fbd7 wip 2024-11-16 13:30:39 -05:00
Tom Moor 827d4e5ad9 Test using xlarge 2024-11-16 13:27:46 -05:00
136 changed files with 1825 additions and 2200 deletions
+3 -3
View File
@@ -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);
},
});
+1
View File
@@ -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);
+1 -2
View File
@@ -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);
+1 -1
View File
@@ -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 = (
+1
View File
@@ -201,6 +201,7 @@ const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
transition: ${s("backgroundTransition")};
&:after {
content: "";
+1
View File
@@ -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;
+16
View File
@@ -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;
}
}
/*
+2 -3
View File
@@ -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" />
-149
View File
@@ -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("Couldnt 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);
+5 -27
View File
@@ -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)
+97
View File
@@ -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);
+1 -1
View File
@@ -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")};
}
+1
View File
@@ -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;
+1
View File
@@ -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;
+2 -4
View File
@@ -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>{" "}
&middot;{" "}
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
{t("Dismiss")}
</Link>
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
</span>
</Flex>
</Wrapper>
+1
View File
@@ -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%;
+2
View File
@@ -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;
+1
View File
@@ -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"};
+13 -16
View File
@@ -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;
+2 -2
View File
@@ -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(() => {
+16 -14
View File
@@ -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,
+1
View File
@@ -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;
`;
+3 -2
View File
@@ -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;
+1
View File
@@ -45,6 +45,7 @@ const Sticky = styled.div`
margin: 0 -8px;
padding: 0 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
z-index: 1;
`;
+42
View File
@@ -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;
}
`;
+7
View File
@@ -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, {
+4 -6
View File
@@ -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();
+6 -25
View File
@@ -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
+6 -7
View File
@@ -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,
};
}
}
+15
View File
@@ -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.
*
+1 -1
View File
@@ -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;
}
+4 -5
View File
@@ -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);
}
+4 -13
View File
@@ -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"),
+2 -2
View File
@@ -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} />
) : (
+24
View File
@@ -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
*/
+4 -2
View File
@@ -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);
/**
+4
View File
@@ -6,6 +6,10 @@ import Field from "./decorators/Field";
class Group extends Model {
static modelName = "Group";
@Field
@observable
id: string;
@Field
@observable
name: string;
+4
View File
@@ -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.
*/
+4
View File
@@ -8,6 +8,10 @@ import Field from "./decorators/Field";
class Team extends Model {
static modelName = "Team";
@Field
@observable
id: string;
@Field
@observable
name: string;
+4
View File
@@ -22,6 +22,10 @@ import Field from "./decorators/Field";
class User extends ParanoidModel {
static modelName = "User";
@Field
@observable
id: string;
@Field
@observable
avatarUrl: string;
+4
View File
@@ -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 &&
+2 -2
View File
@@ -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);
+10 -18
View File
@@ -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) {
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments()}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
+1 -1
View File
@@ -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]);
+4 -13
View File
@@ -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;
}
+3 -15
View File
@@ -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;
`;
+1
View File
@@ -118,6 +118,7 @@ function Home() {
const Documents = styled.div`
position: relative;
background: ${s("background")};
transition: ${s("backgroundTransition")};
`;
export default observer(Home);
+1 -1
View File
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
<Trans>{{ collectionCount }} collections</Trans>
</strong>
</Tooltip>
.{" "}
.
</span>
) : undefined;
+1 -2
View File
@@ -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;
+1 -2
View File
@@ -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")};
+33 -6
View File
@@ -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>
-77
View File
@@ -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);
+3 -2
View File
@@ -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 })}{" "}
&middot;{" "}
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> &middot;{" "}
</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
View File
@@ -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,
+14
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -124,6 +124,7 @@ declare module "styled-components" {
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
link: string;
+28 -1
View File
@@ -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)));
}
-7
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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<
+1 -1
View File
@@ -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,
+13 -16
View File
@@ -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,
+37 -12
View File
@@ -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;
}
-2
View File
@@ -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
*
+53
View File
@@ -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;
}
+2 -2
View File
@@ -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,
+13 -18
View File
@@ -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 });
});
},
};
-2
View File
@@ -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`,
-6
View File
@@ -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;
+26 -1
View File
@@ -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");
});
});
});
+26
View File
@@ -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 -8
View File
@@ -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,
};
}
}
+1 -3
View File
@@ -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),
}),
});
+14 -25
View File
@@ -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,
+2 -4
View File
@@ -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;
}
}
-49
View File
@@ -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
*
-22
View File
@@ -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;
}
+1 -2
View File
@@ -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