Compare commits

..

7 Commits

Author SHA1 Message Date
Tom Moor 8262c91319 v0.82.1-3 2025-02-22 11:48:57 -05:00
Tom Moor 41347970f5 Selective tests 2025-02-22 11:48:37 -05:00
Tom Moor f0a04639ad v0.82.1-2 2025-02-22 11:33:40 -05:00
Tom Moor 12e4436e3a chore: Larger image 2025-02-22 11:33:32 -05:00
Tom Moor 78383a1fbf v0.82.1-1 2025-02-22 11:13:53 -05:00
Tom Moor 29482c8307 fix 2025-02-22 11:12:41 -05:00
Tom Moor 9ea218af9b v0.82.1-0 2025-02-22 10:56:52 -05:00
183 changed files with 1793 additions and 3789 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
name: Docker
name: Docker build
on:
push:
@@ -26,7 +26,7 @@ jobs:
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push base image
uses: docker/build-push-action@v5
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0
Licensed Work: Outline 0.82.1-3
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-02-15
Change Date: 2029-02-22
Change License: Apache License, Version 2.0
+14 -13
View File
@@ -15,6 +15,7 @@ import {
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
@@ -61,7 +62,7 @@ export const createCollection = createAction({
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
@@ -77,10 +78,10 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -103,10 +104,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -134,7 +135,7 @@ export const searchInCollection = createAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -159,7 +160,7 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -169,7 +170,7 @@ export const starCollection = createAction({
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -186,7 +187,7 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
@@ -196,7 +197,7 @@ export const unstarCollection = createAction({
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
perform: async ({ activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
@@ -338,13 +339,13 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
visible: ({ activeCollectionId }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
perform: ({ activeCollectionId, t }) => {
if (!activeCollectionId) {
return;
}
@@ -372,7 +373,7 @@ export const createTemplate = createAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
visible: ({ activeCollectionId }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
-2
View File
@@ -683,7 +683,6 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -1211,7 +1210,6 @@ export const rootDocumentActions = [
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
-2
View File
@@ -2,8 +2,6 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+1 -2
View File
@@ -3,7 +3,6 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -71,7 +70,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
<Key key={key}>{key}</Key>
))}
</React.Fragment>
))}
-6
View File
@@ -13,7 +13,6 @@ import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
@@ -32,7 +31,6 @@ type Props = {
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
@@ -92,7 +90,6 @@ const MenuItem = (
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
@@ -161,9 +158,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
+3 -14
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -78,10 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(items, ["title"], {
@@ -130,18 +126,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.filter((item) => item.type === "collection")
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -315,7 +304,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - baseDepth}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+2 -2
View File
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
) {
const { t } = useTranslation();
const OFFSET = 12;
const DISCLOSURE = 20;
const ICON_SIZE = 24;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
return (
<Node
+8
View File
@@ -0,0 +1,8 @@
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
export default Fade;
-24
View File
@@ -1,24 +0,0 @@
import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
type Props = {
children?: JSX.Element | null;
/** If true, children will be animated. */
animate: boolean;
};
/**
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
export default Fade;
+16 -13
View File
@@ -23,6 +23,7 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -34,6 +35,7 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -52,7 +54,9 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems.map((selected) => selected.label).join(", ")
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const renderItem = React.useCallback(
@@ -66,7 +70,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon}
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -159,16 +163,10 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<>
<div>
<MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -195,7 +193,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</>
</div>
);
};
@@ -233,7 +231,6 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -270,9 +267,15 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 28px;
line-height: 24px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+2 -5
View File
@@ -176,7 +176,6 @@ function Input(
if (ev.key === "Enter" && ev.metaKey) {
if (props.onRequestSubmit) {
props.onRequestSubmit(ev);
return;
}
}
@@ -231,11 +230,10 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
) : (
<NativeInput
@@ -245,12 +243,11 @@ function Input(
])}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
onKeyDown={handleKeyDown}
/>
)}
{children}
+4 -8
View File
@@ -33,7 +33,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -46,7 +45,6 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -85,9 +83,7 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -109,7 +105,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.sidebarActiveBackground,
background: theme.accent,
}}
{...rest}
{...rovingTabIndex}
@@ -212,10 +208,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Heading = styled.p<{ $small?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
${ellipsis()}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
+69 -5
View File
@@ -1,7 +1,24 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { locales } from "@shared/utils/date";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import { useLocaleTime } from "~/hooks/useLocaleTime";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
children?: React.ReactNode;
@@ -12,12 +29,59 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={rest.dateTime}>{children || content}</time>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
@@ -48,15 +48,6 @@ function Notifications(
notifications.approximateUnreadCount
);
}
// PWA badging
if ("setAppBadge" in navigator) {
if (notifications.approximateUnreadCount) {
void navigator.setAppBadge(notifications.approximateUnreadCount);
} else {
void navigator.clearAppBadge();
}
}
}, [notifications.approximateUnreadCount]);
return (
@@ -10,7 +10,6 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -22,6 +21,7 @@ import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -12,7 +12,6 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -29,6 +28,7 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
@@ -73,13 +73,15 @@ function EditableTitle(
return;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
}
},
[originalValue, value, onCancel, onSubmit]
@@ -125,10 +127,7 @@ function EditableTitle(
/>
</form>
) : (
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
{value}
</span>
)}
+1 -17
View File
@@ -1,14 +1,11 @@
import invariant from "invariant";
import find from "lodash/find";
import isObject from "lodash/isObject";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import semver from "semver";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import EDITOR_VERSION from "@shared/editor/version";
import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
@@ -117,23 +114,10 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("authenticated", (data) => {
this.socket.on("authenticated", () => {
if (this.socket) {
this.socket.authenticated = true;
}
if (isObject(data) && "editorVersion" in data) {
const parsedClientVersion = semver.parse(EDITOR_VERSION);
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
) {
window.location.reload();
}
}
});
this.socket.on("unauthorized", (err: Error) => {
+54 -134
View File
@@ -24,54 +24,6 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
}: KeyboardShortcutsProps) {
// Open popover
useKeyDown(
(ev) =>
isModKey(ev) &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
handleOpen({ withReplace: ev.altKey });
},
{ allowInInput: true }
);
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
},
{ allowInInput: true }
);
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
handleRegex();
},
{ allowInInput: true }
);
}
type Props = {
/** Whether the find and replace popover is open */
open: boolean;
@@ -137,48 +89,42 @@ export default function FindAndReplace({
}
}, [show]);
// Callbacks
const selectInputText = React.useCallback(() => {
inputRef.current?.focus();
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}, []);
const selectInputReplaceText = React.useCallback(() => {
setTimeout(() => {
inputReplaceRef.current?.focus();
inputReplaceRef.current?.setSelectionRange(
0,
inputReplaceRef.current?.value.length
);
}, 100);
}, []);
const handleOpen = React.useCallback(
({ withReplace }: { withReplace: boolean }) => {
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
} else {
selectInputText();
}
return;
}
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Keyboard shortcuts
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
selectionRef.current = window.getSelection()?.toString();
popover.show();
if (shouldShowReplace) {
setShowReplace(true);
}
},
[popover, readOnly, selectInputText, selectInputReplaceText]
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
},
{ allowInInput: true }
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
setCaseSensitive((state) => !state);
},
{ allowInInput: true }
);
// Callbacks
const handleMore = React.useCallback(() => {
setShowReplace((state) => !state);
setTimeout(() => inputReplaceRef.current?.focus(), 100);
@@ -186,65 +132,68 @@ export default function FindAndReplace({
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const isCaseSensitive = !state;
const caseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive: isCaseSensitive,
caseSensitive,
regexEnabled,
});
return isCaseSensitive;
return caseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const isRegexEnabled = !state;
const regexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled: isRegexEnabled,
regexEnabled,
});
return isRegexEnabled;
return regexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious() {
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
function selectInputText() {
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
}
switch (ev.key) {
case "Enter": {
ev.preventDefault();
nextPrevious();
nextPrevious(ev);
return;
}
case "g": {
if (ev.metaKey) {
ev.preventDefault();
nextPrevious();
nextPrevious(ev);
selectInputText();
}
return;
}
case "F3": {
ev.preventDefault();
nextPrevious();
nextPrevious(ev);
selectInputText();
return;
}
}
},
[editor.commands, selectInputText]
[editor.commands]
);
const handleReplace = React.useCallback(
@@ -294,15 +243,6 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
handleOpen,
handleCaseSensitive,
handleRegex,
});
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
@@ -345,7 +285,7 @@ export default function FindAndReplace({
<>
<Tooltip
content={t("Previous match")}
shortcut="Shift+Enter"
shortcut="shift+enter"
placement="bottom"
>
<ButtonLarge
@@ -355,7 +295,7 @@ export default function FindAndReplace({
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
@@ -414,11 +354,7 @@ export default function FindAndReplace({
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip
content={t("Replace options")}
shortcut={`${altDisplay}+${metaDisplay}+f`}
placement="bottom"
>
<Tooltip content={t("Replace options")} placement="bottom">
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
@@ -440,28 +376,12 @@ export default function FindAndReplace({
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Tooltip
content={t("Replace")}
shortcut="Enter"
placement="bottom"
>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip
content={t("Replace all")}
shortcut={`${metaDisplay}+Enter`}
placement="bottom"
>
<Button
onClick={handleReplaceAll}
disabled={disabled}
neutral
>
{t("Replace all")}
</Button>
</Tooltip>
<Button onClick={handleReplace} disabled={disabled} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
{t("Replace all")}
</Button>
</Flex>
)}
</ResizingHeightContainer>
+9 -21
View File
@@ -6,6 +6,7 @@ import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
@@ -40,8 +41,7 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const menuWidth = menuRef.current?.offsetWidth;
const menuHeight = menuRef.current?.offsetHeight;
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
return defaultPosition;
@@ -78,24 +78,13 @@ function usePosition({
// position at the top right of code blocks
const codeBlock = findParentNode(isCode)(view.state.selection);
const noticeBlock = findParentNode(
(node) => node.type.name === "container_notice"
)(view.state.selection);
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
const position = codeBlock
? codeBlock.pos
: noticeBlock
? noticeBlock.pos
: null;
if (position !== null) {
const element = view.nodeDOM(position);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
if (codeBlock && view.state.selection.empty) {
const element = view.nodeDOM(codeBlock.pos);
const bounds = (element as HTMLElement).getBoundingClientRect();
selectionBounds.top = bounds.top;
selectionBounds.left = bounds.right - menuWidth;
selectionBounds.right = bounds.right;
}
// tables are an oddity, and need their own positioning logic
@@ -199,8 +188,7 @@ function usePosition({
top: Math.round(top - offsetParent.top),
offset: Math.round(offset),
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
blockSelection:
codeBlock || isColSelection || isRowSelection || noticeBlock,
blockSelection: codeBlock || isColSelection || isRowSelection,
visible: true,
};
}
-5
View File
@@ -1,4 +1,3 @@
import { transparentize } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -14,10 +13,6 @@ const Input = styled.input`
flex-grow: 1;
min-width: 0;
&::placeholder {
color: ${(props) => transparentize(0.5, props.theme.text)};
}
@media (hover: none) and (pointer: coarse) {
font-size: 16px;
}
+124 -199
View File
@@ -1,24 +1,15 @@
import { observer } from "mobx-react";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { hideScrollbars, s } from "@shared/styles";
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
@@ -41,163 +32,142 @@ type Props = {
view: EditorView;
};
const LinkEditor: React.FC<Props> = ({
mark,
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
type State = {
value: string;
previousValue: string;
};
const trimmedQuery = query.trim();
const results = trimmedQuery
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
: [];
class LinkEditor extends React.Component<Props, State> {
discardInputValue = false;
initialValue = this.href;
initialSelectionLength = this.props.to - this.props.from;
inputRef = React.createRef<HTMLInputElement>();
const { request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
);
state: State = {
value: this.href,
previousValue: "",
};
useEffect(() => {
if (trimmedQuery) {
void request();
get href(): string {
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
}
componentDidMount(): void {
window.addEventListener("keydown", this.handleGlobalKeyDown);
}
componentWillUnmount = () => {
window.removeEventListener("keydown", this.handleGlobalKeyDown);
// If we discarded the changes then nothing to do
if (this.discardInputValue) {
return;
}
}, [trimmedQuery, request]);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
// If the link is the same as it was when the editor opened, nothing to do
if (this.state.value === this.initialValue) {
return;
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
// If the link is totally empty or only spaces then remove the mark
const href = (this.state.value || "").trim();
if (!href) {
return this.handleRemoveLink();
}
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
this.save(href, href);
};
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
handleGlobalKeyDown = (event: KeyboardEvent): void => {
if (event.key === "k" && event.metaKey) {
this.inputRef.current?.select();
}
};
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
save = (href: string, title?: string): void => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
this.props.onSelectLink({ href, title, from, to });
};
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
view.focus();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
handleKeyDown = (event: React.KeyboardEvent): void => {
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current >= maxIndex ? 0 : current + 1));
return;
}
case "ArrowUp": {
event.preventDefault();
const maxIndex = results.length - 1;
setSelectedIndex((current) => (current <= 0 ? maxIndex : current - 1));
return;
}
case "Enter": {
event.preventDefault();
const { value } = this.state;
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
const href = selectedDoc.url;
save(href, selectedDoc.title);
} else {
save(trimmedQuery, trimmedQuery);
this.save(value, value);
if (this.initialSelectionLength) {
this.moveSelectionToEnd();
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
if (this.initialValue) {
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
} else {
handleRemoveLink();
this.handleRemoveLink();
}
return;
}
}
};
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
setSelectedIndex(-1);
};
handleSearch = async (
event: React.ChangeEvent<HTMLInputElement>
): Promise<void> => {
const value = event.target.value;
const handlePaste = () => {
setTimeout(() => save(query, query), 0);
};
this.setState({
value,
});
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const trimmedValue = value.trim();
try {
onClickLink(getHref(), event);
} catch (err) {
toast.error(dictionary.openLinkError);
if (trimmedValue) {
try {
this.setState({
previousValue: trimmedValue,
});
} catch (err) {
Logger.error("Error searching for link", err);
}
}
};
const handleRemoveLink = () => {
discardRef.current = true;
handlePaste = (): void => {
setTimeout(() => this.save(this.state.value, this.state.value), 0);
};
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
toast.error(this.props.dictionary.openLinkError);
}
};
handleRemoveLink = (): void => {
this.discardInputValue = true;
const { from, to, mark, view, onRemoveLink } = this.props;
const { state, dispatch } = this.props.view;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
@@ -206,102 +176,57 @@ const LinkEditor: React.FC<Props> = ({
view.focus();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
moveSelectionToEnd = () => {
const { to, view } = this.props;
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
view.focus();
};
return (
<>
render() {
const { view, dictionary } = this.props;
const { value } = this.state;
const isInternal = isInternalUrl(value);
return (
<Wrapper>
<Input
ref={inputRef}
value={query}
placeholder={dictionary.searchOrPasteLink}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={handleSearch}
onFocus={handleSearch}
autoFocus={getHref() === ""}
ref={this.inputRef}
value={value}
placeholder={dictionary.enterLink}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onChange={this.handleSearch}
onFocus={this.handleSearch}
autoFocus={this.href === ""}
readOnly={!view.editable}
/>
<Tooltip
content={isInternal ? dictionary.goToLink : dictionary.openLink}
>
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
{isInternal ? <ArrowIcon /> : <OpenIcon />}
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip content={dictionary.removeLink}>
<ToolbarButton onClick={handleRemoveLink}>
<ToolbarButton onClick={this.handleRemoveLink}>
<CloseIcon />
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
<>
{results.map((doc, index) => (
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
key={doc.id}
subtitle={doc.collection?.name}
title={doc.title}
icon={
doc.icon ? (
<Icon value={doc.icon} color={doc.color ?? undefined} />
) : (
<DocumentIcon />
)
}
/>
))}
</>
)}
</ResizingHeightContainer>
</SearchResults>
</>
);
};
);
}
}
const Wrapper = styled(Flex)`
pointer-events: all;
gap: 8px;
`;
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
background: ${s("menuBackground")};
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
clip-path: inset(0px -100px -100px -100px);
position: absolute;
top: 100%;
width: 100%;
height: auto;
left: 0;
margin-top: -6px;
border-radius: 0 0 4px 4px;
padding: ${(props) => (props.$hasResults ? "6px" : "0")};
max-height: 240px;
pointer-events: all;
${hideScrollbars()}
@media (hover: none) and (pointer: coarse) {
position: fixed;
top: auto;
bottom: 40px;
border-radius: 0;
max-height: 50vh;
padding: 8px 8px 4px;
}
`;
export default observer(LinkEditor);
export default LinkEditor;
+14 -43
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import { DocumentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -10,13 +10,11 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import { DocumentsSection, UserSection } from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -44,19 +42,23 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const maxResultsInSection = search ? 25 : 5;
const { loading, request } = useRequest(
const { loading, request } = useRequest<{
documents: Document[];
users: User[];
}>(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
return {
documents: res.data.documents.map(documents.add),
users: res.data.users.map(users.add),
};
}, [search, documents, users])
);
@@ -125,34 +127,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -180,10 +154,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
if (item.attrs.type === MentionType.Document) {
return;
}
if (!documentId) {
@@ -4,7 +4,6 @@ import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
@@ -19,7 +18,6 @@ import getCodeMenuItems from "../menus/code";
import getDividerMenuItems from "../menus/divider";
import getFormattingMenuItems from "../menus/formatting";
import getImageMenuItems from "../menus/image";
import getNoticeMenuItems from "../menus/notice";
import getReadOnlyMenuItems from "../menus/readOnly";
import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
@@ -57,10 +55,6 @@ function useIsActive(state: EditorState) {
return true;
}
if (isInNotice(state) && selection.from > 0) {
return true;
}
if (!selection || selection.empty) {
return false;
}
@@ -190,7 +184,6 @@ export default function SelectionToolbar(props: Props) {
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
let items: MenuItem[] = [];
@@ -210,8 +203,6 @@ export default function SelectionToolbar(props: Props) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
items = getNoticeMenuItems(state, readOnly, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
}
@@ -11,8 +11,6 @@ export type Props = {
disabled?: boolean;
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** Callback when the item is hovered */
onPointerMove?: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactNode;
/** The title of the item */
@@ -27,7 +25,6 @@ function SuggestionsMenuItem({
selected,
disabled,
onClick,
onPointerMove,
title,
subtitle,
shortcut,
@@ -56,7 +53,6 @@ function SuggestionsMenuItem({
ref={ref}
active={selected}
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
icon={icon}
>
{title}
+1 -47
View File
@@ -20,9 +20,8 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
@@ -167,51 +166,6 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
+2
View File
@@ -13,11 +13,13 @@ export default function attachmentMenuItems(
name: "replaceAttachment",
tooltip: dictionary.replaceAttachment,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteAttachment",
tooltip: dictionary.deleteAttachment,
icon: <TrashIcon />,
visible: true,
},
{
name: "separator",
+7
View File
@@ -33,12 +33,14 @@ export default function imageMenuItems(
name: "alignLeft",
tooltip: dictionary.alignLeft,
icon: <AlignImageLeftIcon />,
visible: true,
active: isLeftAligned,
},
{
name: "alignCenter",
tooltip: dictionary.alignCenter,
icon: <AlignImageCenterIcon />,
visible: true,
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
@@ -49,16 +51,19 @@ export default function imageMenuItems(
name: "alignRight",
tooltip: dictionary.alignRight,
icon: <AlignImageRightIcon />,
visible: true,
active: isRightAligned,
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
visible: true,
active: isFullWidthAligned,
},
{
name: "separator",
visible: true,
},
{
name: "downloadImage",
@@ -70,11 +75,13 @@ export default function imageMenuItems(
name: "replaceImage",
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
visible: true,
},
{
name: "deleteImage",
tooltip: dictionary.deleteImage,
icon: <TrashIcon />,
visible: true,
},
];
}
-63
View File
@@ -1,63 +0,0 @@
import {
DoneIcon,
ExpandedIcon,
InfoIcon,
StarredIcon,
WarningIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
export default function noticeMenuItems(
state: EditorState,
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
const node = state.selection.$from.node(-1);
const currentStyle = node?.attrs.style as NoticeTypes;
const mapping = {
[NoticeTypes.Info]: dictionary.infoNotice,
[NoticeTypes.Warning]: dictionary.warningNotice,
[NoticeTypes.Success]: dictionary.successNotice,
[NoticeTypes.Tip]: dictionary.tipNotice,
};
return [
{
name: "container_notice",
visible: !readOnly,
label: mapping[currentStyle],
icon: <ExpandedIcon />,
children: [
{
name: NoticeTypes.Info,
icon: <InfoIcon />,
label: dictionary.infoNotice,
active: () => currentStyle === NoticeTypes.Info,
},
{
name: NoticeTypes.Success,
icon: <DoneIcon />,
label: dictionary.successNotice,
active: () => currentStyle === NoticeTypes.Success,
},
{
name: NoticeTypes.Warning,
icon: <WarningIcon />,
label: dictionary.warningNotice,
active: () => currentStyle === NoticeTypes.Warning,
},
{
name: NoticeTypes.Tip,
icon: <StarredIcon />,
label: dictionary.tipNotice,
active: () => currentStyle === NoticeTypes.Tip,
},
],
},
];
}
-83
View File
@@ -1,83 +0,0 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
dateTime: string;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
export const useLocaleTime = ({
addSuffix,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return {
content,
tooltipContent,
};
};
+1
View File
@@ -24,6 +24,7 @@ const NotificationMenu: React.FC = () => {
{
type: "button",
title: t("Notification settings"),
visible: true,
onClick: () => performAction(navigateToNotificationSettings, context),
},
],
+1
View File
@@ -28,6 +28,7 @@ function TableOfContentsMenu() {
const i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
-5
View File
@@ -92,11 +92,6 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
+1 -2
View File
@@ -188,10 +188,9 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@Relation(() => User)
@observable
createdBy: User | undefined;
@Relation(() => User)
@observable
updatedBy: User | undefined;
-2
View File
@@ -7,7 +7,6 @@ import {
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class FileOperation extends Model {
static modelName = "FileOperation";
@@ -28,7 +27,6 @@ class FileOperation extends Model {
format: FileOperationFormat;
@Relation(() => User)
user: User;
@computed
-5
View File
@@ -4,7 +4,6 @@ import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
@@ -20,10 +19,6 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** An optional name for the revision */
@Field
name: string | null;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
+2 -8
View File
@@ -1,13 +1,12 @@
import { computed, observable } from "mobx";
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model implements Searchable {
class Share extends Model {
static modelName = "Share";
@Field
@@ -66,11 +65,6 @@ class Share extends Model implements Searchable {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;
@@ -174,7 +174,6 @@ class DocumentScene extends React.Component<Props> {
if (template instanceof Document) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
if (!this.title) {
@@ -552,11 +551,6 @@ class DocumentScene extends React.Component<Props> {
>
<Notices document={document} readOnly={readOnly} />
{showContents && (
<PrintContentsContainer>
<Contents />
</PrintContentsContainer>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -671,19 +665,6 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
@media print {
display: none;
}
`;
const PrintContentsContainer = styled.div`
display: none;
margin: 0 -12px;
@media print {
display: block;
}
`;
type EditorContainerProps = {
+7 -13
View File
@@ -1,4 +1,3 @@
import isEqual from "fast-deep-equal";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
@@ -137,19 +136,14 @@ function History() {
"desc"
);
const latestRevisionEvent = merged.find(
(event) => event.name === "revisions.create"
);
const latestEvent = merged[0];
if (latestRevisionEvent && document) {
const latestRevision = revisions.get(latestRevisionEvent.id);
if (latestEvent && document) {
const latestRevisionEvent = merged.find(
(event) => event.name === "revisions.create"
);
const isDocUpdated =
latestRevision?.title !== document.title ||
!isEqual(latestRevision.data, document.data);
if (isDocUpdated) {
revisions.remove(RevisionHelper.latestId(document.id));
if (latestEvent.createdAt !== document.updatedAt) {
merged.unshift({
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
@@ -163,7 +157,7 @@ function History() {
}
return merged;
}, [revisions, document, revisionEvents, nonRevisionEvents]);
}, [document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (document) {
@@ -99,11 +99,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
presence.updateFromAwarenessChangeEvent(
documentId,
provider.awareness.clientID,
event
);
presence.updateFromAwarenessChangeEvent(documentId, event);
event.states.forEach(({ user, scrollY }) => {
if (user) {
+1 -1
View File
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
items: [
{
shortcut: "@",
label: t("Mention users and more"),
label: t("Mention user or document"),
},
{
shortcut: ":",
+1 -1
View File
@@ -105,7 +105,7 @@ function Message({ notice }: { notice: string }) {
case "authentication-provider-disabled":
return (
<Trans>
Authentication failed this login method was disabled by a workspace
Authentication failed this login method was disabled by a team
admin.
</Trans>
);
+10 -7
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hideScrollbars } from "@shared/styles";
import { hover, hideScrollbars } from "@shared/styles";
import {
DateFilter as TDateFilter,
StatusFilter as TStatusFilter,
@@ -60,10 +60,10 @@ function Search(props: Props) {
routeMatch.params.term ?? params.get("query") ?? ""
).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? "";
const userId = params.get("userId") ?? "";
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const documentId = params.get("documentId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const statusFilter = params.getAll("statusFilter")?.length
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
@@ -375,24 +375,27 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
height: 28px;
gap: 8px;
${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
`};
&: ${hover} {
opacity: 1;
}
`;
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 4px;
margin-top: 2px;
font-size: 14px;
font-weight: 400;
`;
@@ -21,13 +21,13 @@ function CollectionFilter(props: Props) {
const collectionOptions = collections.orderedData.map((collection) => ({
key: collection.id,
label: collection.name,
icon: <CollectionIcon collection={collection} size={24} />,
icon: <CollectionIcon collection={collection} size={18} />,
}));
return [
{
key: "",
label: t("Any collection"),
icon: <SVGCollectionIcon size={24} />,
icon: <SVGCollectionIcon size={18} />,
},
...collectionOptions,
];
@@ -39,6 +39,7 @@ function CollectionFilter(props: Props) {
selectedKeys={[collectionId]}
onSelect={onSelect}
defaultLabel={t("Any collection")}
selectedPrefix={`${t("Collection")}:`}
showFilter
/>
);
+1 -1
View File
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
() => [
{
key: "",
label: t("All time"),
label: t("Any time"),
},
{
key: "day",
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
<div>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.titleWithDefault}
{props.document.title}
</StyledButton>
</Tooltip>
</div>
@@ -51,9 +51,7 @@ const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:focus,
&:${hover} {
opacity: 1;
&:hover {
color: ${s("text")};
}
`;
@@ -63,11 +61,17 @@ const RecentSearch = styled(Link)`
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 8px;
padding: 1px 4px;
border-radius: 4px;
line-height: 24px;
position: relative;
font-size: 14px;
margin: 0 -8px;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
&:focus-visible {
outline: none;
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import { ConditionalFade } from "~/components/Fade";
import Fade from "~/components/Fade";
import useStores from "~/hooks/useStores";
import RecentSearchListItem from "./RecentSearchListItem";
@@ -19,6 +19,7 @@ function RecentSearches(
) {
const { searches } = useStores();
const { t } = useTranslation();
const [isPreloaded] = React.useState(searches.recent.length > 0);
React.useEffect(() => {
void searches.fetchPage({
@@ -47,11 +48,7 @@ function RecentSearches(
</>
) : null;
return (
<ConditionalFade animate={!searches.recent.length}>
{content}
</ConditionalFade>
);
return isPreloaded ? content : <Fade>{content}</Fade>;
}
const Heading = styled.h2`
@@ -59,7 +56,7 @@ const Heading = styled.h2`
font-size: 14px;
line-height: 1.5;
color: ${s("textSecondary")};
margin: 12px 0 0;
margin-bottom: 0;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
+5 -4
View File
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
icon: <Avatar model={user} size={AvatarSize.Small} />,
}));
return [
{
key: "",
label: t("Any author"),
icon: <UserIcon size={20} />,
icon: <NoAuthor size={20} />,
},
...userOptions,
];
@@ -43,6 +43,7 @@ function UserFilter(props: Props) {
selectedKeys={[userId]}
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
fetchQuery={users.fetchPage}
fetchQueryOptions={fetchQueryOptions}
showFilter
@@ -50,8 +51,8 @@ function UserFilter(props: Props) {
);
}
const StyledAvatar = styled(Avatar)`
margin: 2px;
const NoAuthor = styled(UserIcon)`
margin-left: -2px;
`;
export default observer(UserFilter);
+9 -12
View File
@@ -10,7 +10,6 @@ import Group from "~/models/Group";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -150,17 +149,15 @@ function Groups() {
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
<GroupsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</>
)}
</Scene>
+5 -5
View File
@@ -9,7 +9,7 @@ import styled from "styled-components";
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Fade from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -22,7 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { MembersTable } from "./components/MembersTable";
import { PeopleTable } from "./components/PeopleTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
import UserStatusFilter from "./components/UserStatusFilter";
@@ -163,8 +163,8 @@ function Members() {
onSelect={handleRoleFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<MembersTable
<Fade>
<PeopleTable
data={data ?? []}
sort={sort}
canManage={can.update}
@@ -174,7 +174,7 @@ function Members() {
fetchNext: next,
}}
/>
</ConditionalFade>
</Fade>
</Scene>
);
}
+17 -55
View File
@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link, useHistory, useLocation } from "react-router-dom";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { ConditionalFade } from "~/components/Fade";
import Fade from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
@@ -17,22 +16,17 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
import { StickyFilters } from "./components/StickyFilters";
function Shares() {
const team = useCurrentTeam();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const params = useQuery();
const [query, setQuery] = React.useState("");
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
@@ -50,44 +44,18 @@ function Shares() {
);
const { data, error, loading, next } = useTableRequest({
data: shares.findByQuery(reqParams.query ?? ""),
data: shares.orderedData,
sort,
reqFn: shares.fetchPage,
reqParams,
});
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
<Heading>{t("Shared Links")}</Heading>
@@ -115,26 +83,20 @@ function Shares() {
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<SharesTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
{data?.length ? (
<Fade>
<SharesTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
) : null}
</Scene>
);
}
+2 -8
View File
@@ -1,6 +1,5 @@
import { transparentize } from "polished";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
/**
@@ -9,9 +8,8 @@ import { s } from "@shared/styles";
export const ActionRow = styled.div`
position: sticky;
bottom: 0;
width: 100vw;
padding: 16px 12px;
margin-left: -12px;
padding: 16px 50vw;
margin: 0 -50vw;
background: ${s("background")};
@@ -19,8 +17,4 @@ export const ActionRow = styled.div`
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
${breakpoint("tablet")`
width: auto;
`}
`;
@@ -24,7 +24,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
export function MembersTable({ canManage, ...rest }: Props) {
export function PeopleTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
@@ -46,10 +46,10 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={8}>
<Flex align="center" gap={4}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} size={AvatarSize.Small} />
<Avatar model={share.createdBy} />
{share.createdBy.name}
</>
)}
+1 -3
View File
@@ -69,9 +69,7 @@ export default class CollectionsStore extends Store<Collection> {
*/
@computed
get nonPrivate(): Collection[] {
return this.all.filter(
(collection) => collection.isActive && !collection.isPrivate
);
return this.all.filter((collection) => !collection.isPrivate);
}
/**
+8 -42
View File
@@ -14,16 +14,17 @@ export default class PresenceStore {
@observable
data: Map<string, DocumentPresence> = new Map();
timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
offlineTimeout = 30000;
private rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
}
/**
* Removes a user from the presence store
*
* @param documentId ID of the document to remove the user from
* @param userId ID of the user to remove
*/
// called when a user leaves the document
@action
public leave(documentId: string, userId: string) {
const existing = this.data.get(documentId);
@@ -33,16 +34,8 @@ export default class PresenceStore {
}
}
/**
* Updates the presence store based on an awareness event from YJS
*
* @param documentId ID of the document the event is for
* @param clientId ID of the client the event is for
* @param event The awareness event
*/
public updateFromAwarenessChangeEvent(
documentId: string,
clientId: number,
event: AwarenessChangeEvent
) {
const presence = this.data.get(documentId);
@@ -52,13 +45,7 @@ export default class PresenceStore {
event.states.forEach((state) => {
const { user, cursor } = state;
// To avoid loops we only want to update the presence for the current user
// if it is also the current client.
const isCurrentUser = this.rootStore.auth.currentUserId === user?.id;
const isCurrentClient = clientId === state.clientId;
if (user && (!isCurrentUser || !isCurrentClient)) {
if (user && this.rootStore.auth.currentUserId !== user.id) {
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
}
@@ -69,14 +56,6 @@ export default class PresenceStore {
});
}
/**
* Updates the presence store to indicate that a user is present in a document
* and then removes the user after a timeout of inactivity.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
public touch(documentId: string, userId: string, isEditing: boolean) {
const id = `${documentId}-${userId}`;
let timeout = this.timeouts.get(id);
@@ -94,13 +73,6 @@ export default class PresenceStore {
this.timeouts.set(id, timeout);
}
/**
* Updates the presence store to indicate that a user is present in a document.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
@action
private update(documentId: string, userId: string, isEditing: boolean) {
const presence = this.data.get(documentId) || new Map();
@@ -123,10 +95,4 @@ export default class PresenceStore {
public clear() {
this.data.clear();
}
private timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
private offlineTimeout = 30000;
private rootStore: RootStore;
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
export default class RevisionsStore extends Store<Revision> {
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
actions = [RPCAction.List, RPCAction.Info];
constructor(rootStore: RootStore) {
super(rootStore, Revision);
+1 -24
View File
@@ -206,31 +206,8 @@ export type WebsocketEvent =
| WebsocketEntitiesEvent
| WebsocketCommentReactionEvent;
type CursorPosition = {
type: {
client: number;
clock: number;
};
tname: string | null;
item: {
client: number;
clock: number;
};
assoc: number;
};
type Cursor = {
anchor: CursorPosition;
head: CursorPosition;
};
export type AwarenessChangeEvent = {
states: {
clientId: number;
user?: { id: string };
cursor: Cursor;
scrollY: number | undefined;
}[];
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
};
export const EmptySelectValue = "__empty__";
+23 -24
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.758.0",
"@aws-sdk/lib-storage": "3.758.0",
"@aws-sdk/s3-presigned-post": "3.758.0",
"@aws-sdk/s3-request-presigner": "3.758.0",
"@aws-sdk/signature-v4-crt": "^3.758.0",
"@aws-sdk/client-s3": "3.749.0",
"@aws-sdk/lib-storage": "3.749.0",
"@aws-sdk/s3-presigned-post": "3.749.0",
"@aws-sdk/s3-request-presigner": "3.749.0",
"@aws-sdk/signature-v4-crt": "^3.749.0",
"@babel/core": "^7.26.9",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -61,8 +61,8 @@
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
@@ -88,7 +88,7 @@
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
@@ -108,7 +108,7 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.11.2",
"date-fns": "^3.6.0",
"dd-trace": "^5.41.0",
"dd-trace": "^3.58.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
@@ -184,11 +184,11 @@
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.38.1",
"prosemirror-view": "^1.37.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
@@ -217,7 +217,7 @@
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
"semver": "^7.6.2",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
@@ -225,7 +225,7 @@
"slug": "^5.3.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
@@ -299,8 +299,8 @@
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-avatar-editor": "^13.0.3",
"@types/react-color": "^3.0.12",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -331,7 +331,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
@@ -344,7 +344,7 @@
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"i18next-parser": "^7.9.0",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
@@ -355,8 +355,8 @@
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"terser": "^5.37.0",
"typescript": "^5.7.2",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -368,8 +368,7 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1",
"prismjs": "1.30.0"
"rollup": "^4.5.1"
},
"version": "0.82.0"
}
"version": "0.82.1-3"
}
@@ -190,56 +190,6 @@ describe("accountProvisioner", () => {
expect(error).toBeTruthy();
});
it("should prioritize enabled authentication provider", async () => {
const existingTeam = await buildTeam();
const existingProviders = await existingTeam.$get(
"authenticationProviders"
);
const team2 = await buildTeam();
const providers = await team2.$get("authenticationProviders");
const authenticationProvider = providers[0];
await authenticationProvider.update({
enabled: false,
providerId: existingProviders[0].providerId,
});
const existing = await buildUser({
teamId: existingTeam.id,
});
const authentications = await existing.$get("authentications");
const authentication = authentications[0];
const { isNewUser, isNewTeam } = await accountProvisioner({
ip,
user: {
name: existing.name,
email: existing.email!,
avatarUrl: existing.avatarUrl,
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
authenticationProvider: {
name: authenticationProvider.name,
providerId: authenticationProvider.providerId,
},
authentication: {
providerId: authentication.providerId,
accessToken: "123",
scopes: ["read"],
},
});
const auth = await UserAuthentication.findByPk(authentication.id);
expect(auth?.accessToken).toEqual("123");
expect(auth?.scopes.length).toEqual(1);
expect(auth?.scopes[0]).toEqual("read");
expect(isNewTeam).toEqual(false);
expect(isNewUser).toEqual(false);
});
it("should throw an error when the domain is not allowed", async () => {
const existingTeam = await buildTeam();
const admin = await buildAdmin({ teamId: existingTeam.id });
+1 -2
View File
@@ -102,7 +102,7 @@ async function accountProvisioner({
if (err.id === "invalid_authentication") {
const authenticationProvider = await AuthenticationProvider.findOne({
where: {
name: authenticationProviderParams.name,
name: authenticationProviderParams.name, // example: "google"
teamId: teamParams.teamId,
},
include: [
@@ -112,7 +112,6 @@ async function accountProvisioner({
required: true,
},
],
order: [["enabled", "DESC"]],
});
if (authenticationProvider) {
@@ -1,8 +1,10 @@
import isEqual from "fast-deep-equal";
import uniq from "lodash/uniq";
import { Node } from "prosemirror-model";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import { ProsemirrorData } from "@shared/types";
import { schema, serializer } from "@server/editor";
import Logger from "@server/logging/Logger";
import { Document, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -43,6 +45,8 @@ export default async function documentCollaborativeUpdater({
const state = Y.encodeStateAsUpdate(ydoc);
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
const node = Node.fromJSON(schema, content);
const text = serializer.serialize(node, undefined);
const isUnchanged = isEqual(document.content, content);
const lastModifiedById =
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
@@ -68,6 +72,7 @@ export default async function documentCollaborativeUpdater({
await document.update(
{
text,
content,
state: Buffer.from(state),
lastModifiedById,
+48 -54
View File
@@ -1,9 +1,8 @@
import { Optional } from "utility-types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
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 { APIContext } from "@server/types";
type Props = Optional<
@@ -82,58 +81,53 @@ export default async function documentCreator({
}
}
const titleWithReplacements =
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: "");
const contentWithReplacements = text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: templateDocument
? template
? templateDocument.content
: SharedProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
user
)
: content;
const document = Document.build({
id,
urlId,
parentDocumentId,
editorVersion,
collectionId,
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
template,
templateId,
publishedAt,
importId,
sourceMetadata,
fullWidth: fullWidth ?? templateDocument?.fullWidth,
icon: icon ?? templateDocument?.icon,
color: color ?? templateDocument?.color,
title: titleWithReplacements,
content: contentWithReplacements,
state,
});
document.text = DocumentHelper.toMarkdown(document, {
includeTitle: false,
});
await document.save({
silent: !!createdAt,
transaction,
});
const document = await Document.create(
{
id,
urlId,
parentDocumentId,
editorVersion,
collectionId,
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
template,
templateId,
publishedAt,
importId,
sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
icon: templateDocument ? templateDocument.icon : icon,
color: templateDocument ? templateDocument.color : color,
title:
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: ""),
text:
text ??
(templateDocument
? template
? templateDocument.text
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
: ""),
content: templateDocument
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
user
)
: content,
state,
},
{
silent: !!createdAt,
transaction,
}
);
await Event.create(
{
name: "documents.create",
+2
View File
@@ -52,6 +52,7 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(document),
["comment"]
),
text: document.text,
...sharedProperties,
});
@@ -85,6 +86,7 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(childDocument),
["comment"]
),
text: childDocument.text,
...sharedProperties,
});
-1
View File
@@ -61,7 +61,6 @@ async function teamProvisioner({
paranoid: false,
},
],
order: [["enabled", "DESC"]],
});
// This authentication provider already exists which means we have a team and
+1 -1
View File
@@ -63,7 +63,7 @@ export default async function userProvisioner({
const auth = authentication
? await UserAuthentication.findOne({
where: {
providerId: String(authentication.providerId),
providerId: "" + authentication.providerId,
},
include: [
{
@@ -1,6 +1,6 @@
import * as React from "react";
import { DocumentPermission } from "@shared/types";
import { Document, GroupMembership, UserMembership } from "@server/models";
import { Document, UserMembership } from "@server/models";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -11,14 +11,13 @@ import Heading from "./components/Heading";
type InputProps = EmailProps & {
userId: string;
documentId: string;
membershipId?: string;
actorName: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
membership: UserMembership | GroupMembership;
membership: UserMembership;
};
type Props = InputProps & BeforeSend;
@@ -34,20 +33,18 @@ export default class DocumentSharedEmail extends BaseEmail<
return EmailMessageCategory.Notification;
}
protected async beforeSend({ documentId, membershipId }: InputProps) {
if (!membershipId) {
return false;
}
protected async beforeSend({ documentId, userId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const membership =
(await UserMembership.findByPk(membershipId)) ??
(await GroupMembership.findByPk(membershipId));
const membership = await UserMembership.findOne({
where: {
documentId,
userId,
},
});
if (!membership) {
return false;
}
@@ -1,49 +0,0 @@
const tableName = "team_domains";
// because of this issue in Sequelize the foreign key constraint may be named differently depending
// on when the previous migrations were ran https://github.com/sequelize/sequelize/pull/9890
const constraintNames = [
"team_domains_createdById_fkey",
"createdById_foreign_idx"
];
module.exports = {
up: async (queryInterface, Sequelize) => {
let error;
for (const constraintName of constraintNames) {
try {
await queryInterface.sequelize.query(
`alter table "${tableName}" drop constraint "${constraintName}"`
);
await queryInterface.sequelize.query(`alter table "${tableName}"
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
on delete set null`);
return;
} catch (err) {
error = err;
}
}
throw error;
},
down: async (queryInterface, Sequelize) => {
let error;
for (const constraintName of constraintNames) {
try {
await queryInterface.sequelize.query(
`alter table "${tableName}" drop constraint "${constraintName}"`
);
await queryInterface.sequelize.query(`alter table "${tableName}"\
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
on delete no action`);
return;
} catch (err) {
error = err;
}
}
throw error;
},
};
@@ -1,14 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("notifications", "membershipId", {
type: Sequelize.UUID,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("notifications", "membershipId");
},
};
@@ -1,15 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("revisions", "name", {
type: Sequelize.STRING,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("revisions", "name");
},
};
+1 -3
View File
@@ -830,9 +830,7 @@ class Document extends ArchivableModel<
}
this.content = revision.content;
this.text = DocumentHelper.toMarkdown(revision, {
includeTitle: false,
});
this.text = revision.text;
this.title = revision.title;
this.icon = revision.icon;
this.color = revision.color;
-5
View File
@@ -177,10 +177,6 @@ class Notification extends Model<
@Column(DataType.UUID)
teamId: string;
@AllowNull
@Column(DataType.UUID)
membershipId: string;
@AfterCreate
static async createEvent(
model: Notification,
@@ -195,7 +191,6 @@ class Notification extends Model<
documentId: model.documentId,
collectionId: model.collectionId,
actorId: model.actorId,
membershipId: model.membershipId,
};
if (options.transaction) {
+1
View File
@@ -16,5 +16,6 @@ describe("#findLatest", () => {
await Revision.createFromDocument(document);
const revision = await Revision.findLatest(document.id);
expect(revision?.title).toBe("Changed 2");
expect(revision?.text).toBe("Content");
});
});
+9 -17
View File
@@ -15,7 +15,7 @@ import {
Length as SimpleLength,
} from "sequelize-typescript";
import type { ProsemirrorData } from "@shared/types";
import { DocumentValidation, RevisionValidation } from "@shared/validations";
import { DocumentValidation } from "@shared/validations";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
@@ -42,7 +42,6 @@ class Revision extends IdModel<
@Column(DataType.SMALLINT)
version?: number | null;
/** The editor version at the time of the revision */
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,
@@ -50,7 +49,6 @@ class Revision extends IdModel<
@Column
editorVersion: string;
/** The document title at the time of the revision */
@Length({
max: DocumentValidation.maxTitleLength,
msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
@@ -58,29 +56,22 @@ class Revision extends IdModel<
@Column
title: string;
/** An optional name for the revision */
@Length({
max: RevisionValidation.maxNameLength,
msg: `Revision name must be ${RevisionValidation.maxNameLength} characters or less`,
})
@Column
name: string | null;
/**
* The content of the revision as Markdown.
*
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if
* exporting lossy markdown. This column will be removed in a future migration
* and is no longer being written.
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
* This column will be removed in a future migration.
*/
@Column(DataType.TEXT)
text: string;
/** The content of the revision as JSON. */
/**
* The content of the revision as JSON.
*/
@Column(DataType.JSONB)
content: ProsemirrorData | null;
/** The icon at the time of the revision. */
/** An icon to use as the document icon. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -88,7 +79,7 @@ class Revision extends IdModel<
@Column
icon: string | null;
/** The color at the time of the revision. */
/** The color of the icon. */
@IsHexColor
@Column
color: string | null;
@@ -135,6 +126,7 @@ class Revision extends IdModel<
static buildFromDocument(document: Document) {
return this.build({
title: document.title,
text: document.text,
icon: document.icon,
color: document.color,
content: document.content,
-3
View File
@@ -79,7 +79,6 @@ describe("user model", () => {
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should return read collections", async () => {
const team = await buildTeam();
const user = await buildUser({
@@ -93,7 +92,6 @@ describe("user model", () => {
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
it("should not return private collections", async () => {
const team = await buildTeam();
const user = await buildUser({
@@ -106,7 +104,6 @@ describe("user model", () => {
const response = await user.collectionIds();
expect(response.length).toEqual(0);
});
it("should not return private collection with membership", async () => {
const team = await buildTeam();
const user = await buildUser({
-1
View File
@@ -614,7 +614,6 @@ class User extends ParanoidModel<
where: { email: this.email },
},
],
order: [["createdAt", "ASC"]],
});
// hooks
+2 -10
View File
@@ -147,15 +147,10 @@ export class DocumentHelper {
* Returns the document as Markdown. This is a lossy conversion and should only be used for export.
*
* @param document The document or revision to convert
* @param options Options for the conversion
* @returns The document title and content as a Markdown string
*/
static toMarkdown(
document: Document | Revision | Collection | ProsemirrorData,
options?: {
/** Whether to include the document title (default: true) */
includeTitle?: boolean;
}
document: Document | Revision | Collection | ProsemirrorData
) {
const text = serializer
.serialize(DocumentHelper.toProsemirror(document))
@@ -170,10 +165,7 @@ export class DocumentHelper {
return text;
}
if (
(document instanceof Document || document instanceof Revision) &&
options?.includeTitle !== false
) {
if (document instanceof Document || document instanceof Revision) {
const iconType = determineIconType(document.icon);
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
@@ -1,6 +1,5 @@
import { NotificationEventType } from "@shared/types";
import {
buildComment,
buildDocument,
buildSubscription,
buildUser,
@@ -8,112 +7,6 @@ import {
import NotificationHelper from "./NotificationHelper";
describe("NotificationHelper", () => {
describe("getCommentNotificationRecipients", () => {
it("should return users who have notification enabled for comment creation and are subscribed to the document in case of parent comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const notificationDisabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: false },
});
await Promise.all([
buildSubscription({
userId: documentAuthor.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationEnabledUser.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationDisabledUser.id,
documentId: document.id,
}),
]);
const comment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUserInThread = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const notificationEnabledUserNotInThread = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const notificationDisabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: {
[NotificationEventType.CreateComment]: false,
},
});
await Promise.all([
buildSubscription({
userId: documentAuthor.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationEnabledUserInThread.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationEnabledUserNotInThread.id,
documentId: document.id,
}),
buildSubscription({
userId: notificationDisabledUser.id,
documentId: document.id,
}),
]);
const parentComment = await buildComment({
documentId: document.id,
userId: notificationEnabledUserInThread.id,
});
const childComment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
parentCommentId: parentComment.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
childComment,
childComment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
});
});
describe("getDocumentNotificationRecipients", () => {
it("should return all users who have notification enabled for the event", async () => {
const documentAuthor = await buildUser();
+1 -1
View File
@@ -62,7 +62,7 @@ export default class NotificationHelper {
): Promise<User[]> => {
let recipients = await this.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.CreateComment,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: !comment.parentCommentId,
actorId,
});
@@ -3,7 +3,7 @@ import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc } from "@server/test/factories";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
describe("ProsemirrorHelper", () => {
describe("ProseMirrorHelper", () => {
describe("getNodeForMentionEmail", () => {
it("should return the paragraph node", () => {
const mentionAttrs: MentionAttrs = {
+2 -5
View File
@@ -118,13 +118,10 @@ export class ProsemirrorHelper {
/**
* Converts a plain object into a Prosemirror Node.
*
* @param data The ProsemirrorData object or string to parse.
* @param data The object to parse
* @returns The content as a Prosemirror Node
*/
static toProsemirror(data: ProsemirrorData | string) {
if (typeof data === "string") {
return parser.parse(data);
}
static toProsemirror(data: ProsemirrorData) {
return Node.fromJSON(schema, data);
}
@@ -861,51 +861,6 @@ describe("SearchHelper", () => {
});
});
describe("#searchCollectionsForUser", () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Test Collection",
});
await buildCollection({
teamId: team.id,
userId: user.id,
name: "Other Collection",
});
const results = await SearchHelper.searchCollectionsForUser(user, {
query: "test",
});
expect(results.length).toBe(1);
expect(results[0].id).toBe(collection1.id);
});
test("should return all collections when no query provided", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Alpha",
});
const collection2 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Beta",
});
const results = await SearchHelper.searchCollectionsForUser(user);
expect(results.length).toBe(2);
expect(results[0].id).toBe(collection1.id);
expect(results[1].id).toBe(collection2.id);
});
});
describe("webSearchQuery", () => {
test("should correctly sanitize query", () => {
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
+19 -31
View File
@@ -203,35 +203,6 @@ export default class SearchHelper {
});
}
public static async searchCollectionsForUser(
user: User,
options: SearchOptions = {}
): Promise<Collection[]> {
const { limit = 15, offset = 0, query } = options;
const collectionIds = await user.collectionIds();
return Collection.findAll({
where: {
[Op.and]: query
? {
[Op.or]: [
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
),
],
}
: {},
id: collectionIds,
teamId: user.teamId,
},
order: [["name", "ASC"]],
replacements: { query: `%${query}%` },
limit,
offset,
});
}
public static async searchForUser(
user: User,
options: SearchOptions = {}
@@ -630,8 +601,6 @@ export default class SearchHelper {
}
private static removeStopWords(query: string): string {
// Based on:
// https://github.com/postgres/postgres/blob/fc0d0ce978752493868496be6558fa17b7c4c3cf/src/backend/snowball/stopwords/english.stop
const stopwords = [
"i",
"me",
@@ -696,6 +665,7 @@ export default class SearchHelper {
"because",
"as",
"until",
"while",
"of",
"at",
"by",
@@ -703,6 +673,7 @@ export default class SearchHelper {
"with",
"about",
"against",
"between",
"into",
"through",
"during",
@@ -710,12 +681,18 @@ export default class SearchHelper {
"after",
"above",
"below",
"to",
"from",
"up",
"down",
"in",
"out",
"on",
"off",
"over",
"under",
"again",
"further",
"then",
"once",
"here",
@@ -723,15 +700,22 @@ export default class SearchHelper {
"when",
"where",
"why",
"how",
"all",
"any",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
@@ -739,8 +723,12 @@ export default class SearchHelper {
"very",
"s",
"t",
"can",
"will",
"just",
"don",
"should",
"now",
];
return query
.split(" ")
+36 -1
View File
@@ -6,8 +6,18 @@ import Koa from "koa";
import escape from "lodash/escape";
import isNil from "lodash/isNil";
import snakeCase from "lodash/snakeCase";
import {
ValidationError as SequelizeValidationError,
EmptyResultError as SequelizeEmptyResultError,
} from "sequelize";
import env from "@server/env";
import { ClientClosedRequestError, InternalError } from "@server/errors";
import {
AuthorizationError,
ClientClosedRequestError,
InternalError,
NotFoundError,
ValidationError,
} from "@server/errors";
import { requestErrorHandler } from "@server/logging/sentry";
let errorHtmlCache: Buffer | undefined;
@@ -22,6 +32,16 @@ export default function onerror(app: Koa) {
err = wrapInNativeError(err);
if (err instanceof SequelizeValidationError) {
if (err.errors && err.errors[0]) {
err = ValidationError(
`${err.errors[0].message} (${err.errors[0].path})`
);
} else {
err = ValidationError();
}
}
// Client aborted errors are a 500 by default, but 499 is more appropriate
if (err instanceof formidable.errors.FormidableError) {
if (err.internalCode === 1002) {
@@ -29,6 +49,21 @@ export default function onerror(app: Koa) {
}
}
if (
err.code === "ENOENT" ||
err instanceof SequelizeEmptyResultError ||
/Not found/i.test(err.message)
) {
err = NotFoundError();
}
if (
!(err instanceof AuthorizationError) &&
/Authorization error/i.test(err.message)
) {
err = AuthorizationError();
}
// Push only unknown and 500 status errors to sentry
if (
typeof err.status !== "number" ||
-1
View File
@@ -12,7 +12,6 @@ import "./fileOperation";
import "./integration";
import "./pins";
import "./reaction";
import "./revision";
import "./searchQuery";
import "./share";
import "./star";
-11
View File
@@ -1,11 +0,0 @@
import { User, Revision } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamMutable, or } from "./utils";
allow(User, ["update"], Revision, (actor, revision) =>
and(
//
or(actor.id === revision?.userId, actor.isAdmin),
isTeamMutable(actor)
)
);
+1 -1
View File
@@ -42,7 +42,7 @@ async function presentDocument(
const text =
!asData || options?.includeText
? DocumentHelper.toMarkdown(data, { includeTitle: false })
? document.text || DocumentHelper.toMarkdown(data)
: undefined;
const res: Record<string, any> = {
-1
View File
@@ -12,7 +12,6 @@ async function presentRevision(revision: Revision, diff?: string) {
id: revision.id,
documentId: revision.documentId,
title: strippedTitle,
name: revision.name,
data: await DocumentHelper.toJSON(revision),
icon: revision.icon ?? emoji,
color: revision.color,
@@ -6,17 +6,21 @@ import BaseProcessor from "./BaseProcessor";
export default class DocumentSubscriptionProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.add_user",
"documents.remove_user",
"documents.add_group",
"documents.remove_group",
];
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
switch (event.name) {
case "documents.add_user":
case "documents.remove_user": {
await DocumentSubscriptionTask.schedule(event);
return;
}
case "documents.add_group":
case "documents.remove_group":
return this.handleGroup(event);
@@ -25,6 +29,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
}
private async handleGroup(event: DocumentGroupEvent) {
const userEventName: DocumentUserEvent["name"] =
event.name === "documents.add_group"
? "documents.add_user"
: "documents.remove_user";
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
@@ -40,7 +49,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
groupUsers.map((groupUser) =>
DocumentSubscriptionTask.schedule({
...event,
name: "documents.remove_user",
name: userEventName,
userId: groupUser.userId,
})
)
@@ -56,7 +56,6 @@ export default class EmailsProcessor extends BaseProcessor {
to: notification.user.email,
userId: notification.userId,
documentId: notification.documentId,
membershipId: notification.membershipId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
@@ -2,7 +2,6 @@ import isEqual from "fast-deep-equal";
import revisionCreator from "@server/commands/revisionCreator";
import { Revision, Document, User } from "@server/models";
import { DocumentEvent, RevisionEvent, Event } from "@server/types";
import DocumentUpdateTextTask from "../tasks/DocumentUpdateTextTask";
import BaseProcessor from "./BaseProcessor";
export default class RevisionsProcessor extends BaseProcessor {
@@ -37,8 +36,6 @@ export default class RevisionsProcessor extends BaseProcessor {
return;
}
await DocumentUpdateTextTask.schedule(event);
const user = await User.findByPk(event.actorId, {
paranoid: false,
rejectOnEmpty: true,
@@ -10,7 +10,7 @@ import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
import { CommentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentCreatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -4,7 +4,7 @@ import { MentionType, NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -1,5 +1,6 @@
import { Op } from "sequelize";
import { GroupUser } from "@server/models";
import Logger from "@server/logging/Logger";
import { GroupUser, UserMembership } from "@server/models";
import { DocumentGroupEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
@@ -19,9 +20,26 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
async (groupUsers) => {
await Promise.all(
groupUsers.map(async (groupUser) => {
const userMembership = await UserMembership.findOne({
where: {
userId: groupUser.userId,
documentId: event.documentId,
},
});
if (userMembership) {
Logger.debug(
"task",
`Suppressing notification for user ${groupUser.userId} as they are already a member of the document`,
{
documentId: event.documentId,
userId: groupUser.userId,
}
);
return;
}
await DocumentAddUserNotificationsTask.schedule({
...event,
modelId: event.data.membershipId,
userId: groupUser.userId,
});
})
@@ -1,65 +1,27 @@
import { DocumentPermission, NotificationEventType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { NotificationEventType } from "@shared/types";
import { Notification, User } from "@server/models";
import { DocumentUserEvent } from "@server/types";
import { isElevatedPermission } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentAddUserNotificationsTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const permission = event.changes?.attributes.permission as
| DocumentPermission
| undefined;
if (!permission) {
Logger.info(
"task",
`permission not available in the DocumentAddUserNotificationsTask event`,
{
name: event.name,
modelId: event.modelId,
}
);
return;
}
const recipient = await User.findByPk(event.userId);
if (!recipient) {
return;
}
if (
!recipient ||
recipient.isSuspended ||
!recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
!recipient.isSuspended &&
recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
) {
return;
await Notification.create({
event: NotificationEventType.AddUserToDocument,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
});
}
const isElevated = await isElevatedPermission({
userId: recipient.id,
documentId: event.documentId,
permission,
skipMembershipId: event.modelId,
});
if (!isElevated) {
Logger.debug(
"task",
`Suppressing notification for user ${event.userId} as the new permission does not elevate user's permission to the document`,
{
documentId: event.documentId,
userId: event.userId,
permission,
}
);
return;
}
await Notification.create({
event: NotificationEventType.AddUserToDocument,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
membershipId: event.modelId,
});
}
public get options() {
@@ -4,7 +4,7 @@ import { Document, Notification, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
import { canUserAccessDocument } from "@server/utils/policies";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {

Some files were not shown because too many files have changed in this diff Show More