mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c08fbc6b18 | |||
| 7f32f46755 | |||
| 70268a73df | |||
| 148be1025f | |||
| 2a17ac1908 | |||
| a70a67235d | |||
| ed5bb8f8d9 | |||
| a7731d9963 | |||
| 6f5e0b70bc | |||
| 856467fa0c | |||
| 280ec17f63 | |||
| 84b48167cb | |||
| c6f90b7647 | |||
| 42865b64d6 | |||
| e5b5cbaab7 | |||
| 463398e2c7 | |||
| 98c9af53c4 | |||
| f0864b5876 | |||
| c89535426b | |||
| 58c4a486f7 | |||
| d5462a92c8 | |||
| 7a90a909b3 | |||
| 189ad30138 | |||
| feb412b1fb | |||
| d551a1a10b | |||
| 2a3ea1254c | |||
| ddfd1b70e5 | |||
| a9b18ccf14 | |||
| 6d3b35ef6c | |||
| c7e96da95a | |||
| 3270ba7fa6 | |||
| fcff256586 | |||
| 0cfe0fc05b | |||
| 67b3e175ee | |||
| d3235250a8 | |||
| 237253afdb | |||
| 82cdebfb66 | |||
| bed0bf9ec8 | |||
| 4573b3fea2 | |||
| 110e489c30 | |||
| b34dd138cd | |||
| 3b1ce063bf | |||
| b1d8acbad1 | |||
| ae05520a25 | |||
| 6e30bf3c64 | |||
| 775b038359 | |||
| eecc7e3443 | |||
| 5fbc57f39a | |||
| 69029b305d | |||
| 35269d7d92 | |||
| 13f45e1a1c | |||
| 5c46bd13ed | |||
| e51f93f9a0 | |||
| d4fe240808 | |||
| 61c76e62ef | |||
| 499392c114 | |||
| af0651f243 | |||
| 7a54d5bb84 | |||
| cb4a978a87 | |||
| 66a5055e73 | |||
| a9ddbde02c | |||
| 6b25000adb | |||
| 0fb8971628 | |||
| 9f277a8f7b | |||
| 97e91eb06b | |||
| a87bda82b1 | |||
| 36a92d5393 | |||
| 50f9f414bf | |||
| 0d6026c21f | |||
| eea0e28630 | |||
| 4ad58b4ccd | |||
| 17f4dc58f1 | |||
| fe839acaeb | |||
| 210aefaf8f | |||
| 90f7a4272e | |||
| 978773ee27 | |||
| 59abc3355c | |||
| 72aa4cde3f |
@@ -59,8 +59,30 @@ jobs:
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
app:
|
||||
- 'app/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -75,7 +97,8 @@ jobs:
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: build
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
@@ -121,6 +144,7 @@ jobs:
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -133,4 +157,7 @@ jobs:
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
run: npx relative-ci-agent
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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";
|
||||
@@ -62,7 +61,7 @@ export const createCollection = createAction({
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
stores.dialogs.openModal({
|
||||
@@ -78,10 +77,10 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -104,10 +103,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +134,7 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -160,7 +159,7 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -170,7 +169,7 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +186,7 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -197,7 +196,7 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -339,13 +338,13 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t }) => {
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +372,7 @@ export const createTemplate = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
|
||||
@@ -683,6 +683,7 @@ export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+/`],
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -1210,6 +1211,7 @@ export const rootDocumentActions = [
|
||||
unpublishDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
|
||||
@@ -2,6 +2,8 @@ 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}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -70,7 +71,7 @@ function CommandBarItem(
|
||||
""
|
||||
)}
|
||||
{sc.split("+").map((key) => (
|
||||
<Key key={key}>{key}</Key>
|
||||
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -31,6 +32,7 @@ type Props = {
|
||||
const MenuItem = (
|
||||
{
|
||||
onClick,
|
||||
onPointerMove,
|
||||
children,
|
||||
active,
|
||||
selected,
|
||||
@@ -90,6 +92,7 @@ const MenuItem = (
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
disabled={disabled}
|
||||
hide={hide}
|
||||
{...rest}
|
||||
@@ -158,6 +161,9 @@ 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);
|
||||
|
||||
|
||||
@@ -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 } from "@shared/types";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -78,6 +78,10 @@ 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"], {
|
||||
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.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) => {
|
||||
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const ICON_SIZE = 24;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
|
||||
+11
-10
@@ -73,15 +73,13 @@ function EditableTitle(
|
||||
return;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit]
|
||||
@@ -127,7 +125,10 @@ function EditableTitle(
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
|
||||
<span
|
||||
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
|
||||
className={rest.className}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
@@ -1,8 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
@@ -23,7 +23,6 @@ type Props = {
|
||||
options: TFilterOption[];
|
||||
selectedKeys: (string | null | undefined)[];
|
||||
defaultLabel?: string;
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
@@ -35,7 +34,6 @@ const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
defaultLabel = "Filter options",
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -54,9 +52,7 @@ const FilterOptions = ({
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems
|
||||
.map((selected) => `${selectedPrefix} ${selected.label}`)
|
||||
.join(", ")
|
||||
? selectedItems.map((selected) => selected.label).join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
@@ -70,7 +66,7 @@ const FilterOptions = ({
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.icon}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
@@ -163,10 +159,16 @@ const FilterOptions = ({
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton {...props} className={className} neutral disclosure>
|
||||
<StyledButton
|
||||
{...props}
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
neutral
|
||||
disclosure
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
@@ -193,7 +195,7 @@ const FilterOptions = ({
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("menuBackground")};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
|
||||
}
|
||||
|
||||
${Inner} {
|
||||
line-height: 24px;
|
||||
line-height: 28px;
|
||||
min-height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
margin-right: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
|
||||
@@ -176,6 +176,7 @@ function Input(
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (props.onRequestSubmit) {
|
||||
props.onRequestSubmit(ev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +231,11 @@ 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
|
||||
@@ -243,11 +245,12 @@ 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}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
small?: boolean;
|
||||
/** Whether to enable keyboard navigation */
|
||||
keyboardNavigation?: boolean;
|
||||
ellipsis?: boolean;
|
||||
};
|
||||
|
||||
const ListItem = (
|
||||
@@ -45,6 +46,7 @@ const ListItem = (
|
||||
border,
|
||||
to,
|
||||
keyboardNavigation,
|
||||
ellipsis,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -83,7 +85,9 @@ const ListItem = (
|
||||
column={!compact}
|
||||
$selected={selected}
|
||||
>
|
||||
<Heading $small={small}>{title}</Heading>
|
||||
<Heading $small={small} $ellipsis={ellipsis}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle && (
|
||||
<Subtitle $small={small} $selected={selected}>
|
||||
{subtitle}
|
||||
@@ -105,7 +109,7 @@ const ListItem = (
|
||||
$border={border}
|
||||
$small={small}
|
||||
activeStyle={{
|
||||
background: theme.accent,
|
||||
background: theme.sidebarActiveBackground,
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Heading = styled.p<{ $small?: boolean }>`
|
||||
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 14 : 16)}px;
|
||||
font-weight: 500;
|
||||
${ellipsis()}
|
||||
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
|
||||
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
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);
|
||||
};
|
||||
}
|
||||
import { useLocaleTime } from "~/hooks/useLocaleTime";
|
||||
|
||||
export type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -29,59 +12,12 @@ export type Props = {
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
|
||||
const { tooltipContent, content } = useLocaleTime(rest);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} placement="bottom">
|
||||
<time dateTime={dateTime}>{children || content}</time>
|
||||
<time dateTime={rest.dateTime}>{children || content}</time>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,15 @@ 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,6 +10,7 @@ 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";
|
||||
@@ -21,7 +22,6 @@ 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,6 +12,7 @@ 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";
|
||||
@@ -28,7 +29,6 @@ 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";
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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";
|
||||
@@ -114,10 +117,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
this.socket.on("authenticated", (data) => {
|
||||
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) => {
|
||||
|
||||
@@ -24,6 +24,54 @@ 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;
|
||||
@@ -89,42 +137,48 @@ export default function FindAndReplace({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setRegex((state) => !state);
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
}
|
||||
},
|
||||
{ allowInInput: true }
|
||||
[popover, readOnly, selectInputText, selectInputReplaceText]
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -132,68 +186,65 @@ export default function FindAndReplace({
|
||||
|
||||
const handleCaseSensitive = React.useCallback(() => {
|
||||
setCaseSensitive((state) => {
|
||||
const caseSensitive = !state;
|
||||
const isCaseSensitive = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
caseSensitive: isCaseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return caseSensitive;
|
||||
return isCaseSensitive;
|
||||
});
|
||||
}, [regexEnabled, editor.commands, searchTerm]);
|
||||
|
||||
const handleRegex = React.useCallback(() => {
|
||||
setRegex((state) => {
|
||||
const regexEnabled = !state;
|
||||
const isRegexEnabled = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
regexEnabled: isRegexEnabled,
|
||||
});
|
||||
|
||||
return regexEnabled;
|
||||
return isRegexEnabled;
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
function nextPrevious() {
|
||||
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(ev);
|
||||
nextPrevious();
|
||||
return;
|
||||
}
|
||||
case "g": {
|
||||
if (ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "F3": {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands]
|
||||
[editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -243,6 +294,15 @@ export default function FindAndReplace({
|
||||
[handleReplace]
|
||||
);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
@@ -285,7 +345,7 @@ export default function FindAndReplace({
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
shortcut="Shift+Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge
|
||||
@@ -295,7 +355,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()}
|
||||
@@ -354,7 +414,11 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
@@ -376,12 +440,28 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
<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>
|
||||
</Flex>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -41,7 +40,8 @@ function usePosition({
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
@@ -78,13 +78,24 @@ 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 && 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
@@ -188,7 +199,8 @@ 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,
|
||||
blockSelection:
|
||||
codeBlock || isColSelection || isRowSelection || noticeBlock,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
@@ -13,6 +14,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon, CloseIcon, DocumentIcon, 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 Logger from "~/utils/Logger";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Input from "./Input";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
@@ -32,152 +41,87 @@ type Props = {
|
||||
view: EditorView;
|
||||
};
|
||||
|
||||
type State = {
|
||||
value: string;
|
||||
previousValue: string;
|
||||
};
|
||||
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();
|
||||
|
||||
class LinkEditor extends React.Component<Props, State> {
|
||||
discardInputValue = false;
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
const trimmedQuery = query.trim();
|
||||
const results = trimmedQuery
|
||||
? documents.findByQuery(trimmedQuery, { maxResults: 25 })
|
||||
: [];
|
||||
|
||||
state: State = {
|
||||
value: this.href,
|
||||
previousValue: "",
|
||||
};
|
||||
const { request } = useRequest(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
);
|
||||
|
||||
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;
|
||||
useEffect(() => {
|
||||
if (trimmedQuery) {
|
||||
void request();
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
// If the link is the same as it was when the editor opened, nothing to do
|
||||
if (this.state.value === this.initialValue) {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
const href = (this.state.value || "").trim();
|
||||
if (!href) {
|
||||
return this.handleRemoveLink();
|
||||
}
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
|
||||
this.save(href, href);
|
||||
};
|
||||
// If we discarded the changes then nothing to do
|
||||
if (discardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
this.inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
// If the link is the same as it was when the editor opened, nothing to do
|
||||
if (trimmedQuery === initialValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
save = (href: string, title?: string): void => {
|
||||
// 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) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.discardInputValue = true;
|
||||
const { from, to } = this.props;
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
this.props.onSelectLink({ href, title, from, to });
|
||||
onSelectLink({ href, title, from, to });
|
||||
};
|
||||
|
||||
handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
const { value } = this.state;
|
||||
|
||||
this.save(value, value);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.initialValue) {
|
||||
this.setState({ value: this.initialValue }, this.moveSelectionToEnd);
|
||||
} else {
|
||||
this.handleRemoveLink();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleSearch = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): Promise<void> => {
|
||||
const value = event.target.value;
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue) {
|
||||
try {
|
||||
this.setState({
|
||||
previousValue: trimmedValue,
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Error searching for link", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
view.focus();
|
||||
};
|
||||
|
||||
moveSelectionToEnd = () => {
|
||||
const { to, view } = this.props;
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
const nextSelection = Selection.findFrom(state.tr.doc.resolve(to), 1, true);
|
||||
if (nextSelection) {
|
||||
@@ -186,47 +130,178 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { view, dictionary } = this.props;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
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();
|
||||
|
||||
return (
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
const selectedDoc = results[selectedIndex];
|
||||
const href = selectedDoc.url;
|
||||
save(href, selectedDoc.title);
|
||||
} else {
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
}
|
||||
|
||||
if (initialSelectionLength) {
|
||||
moveSelectionToEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
|
||||
if (initialValue) {
|
||||
setQuery(initialValue);
|
||||
moveSelectionToEnd();
|
||||
} else {
|
||||
handleRemoveLink();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setQuery(newValue);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const handlePaste = () => {
|
||||
setTimeout(() => save(query, query), 0);
|
||||
};
|
||||
|
||||
const handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
onClickLink(getHref(), event);
|
||||
} catch (err) {
|
||||
toast.error(dictionary.openLinkError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
view.focus();
|
||||
};
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={dictionary.enterLink}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
onChange={this.handleSearch}
|
||||
onFocus={this.handleSearch}
|
||||
autoFocus={this.href === ""}
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder={dictionary.searchOrPasteLink}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleSearch}
|
||||
onFocus={handleSearch}
|
||||
autoFocus={getHref() === ""}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={isInternal ? dictionary.goToLink : dictionary.openLink}
|
||||
>
|
||||
<ToolbarButton onClick={this.handleOpenLink} disabled={!value}>
|
||||
<ToolbarButton onClick={handleOpenLink} disabled={!query}>
|
||||
{isInternal ? <ArrowIcon /> : <OpenIcon />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.removeLink}>
|
||||
<ToolbarButton onClick={this.handleRemoveLink}>
|
||||
<ToolbarButton onClick={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;
|
||||
`;
|
||||
|
||||
export default LinkEditor;
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -10,11 +10,13 @@ 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 } from "~/actions/sections";
|
||||
import {
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -42,23 +44,19 @@ 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 } = useStores();
|
||||
const { auth, documents, users, collections } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
const { loading, request } = useRequest<{
|
||||
documents: Document[];
|
||||
users: User[];
|
||||
}>(
|
||||
const { loading, request } = useRequest(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
return {
|
||||
documents: res.data.documents.map(documents.add),
|
||||
users: res.data.users.map(users.add),
|
||||
};
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
@@ -127,6 +125,34 @@ 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",
|
||||
@@ -154,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
if (
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!documentId) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -18,6 +19,7 @@ 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";
|
||||
@@ -55,6 +57,10 @@ function useIsActive(state: EditorState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,6 +190,7 @@ 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[] = [];
|
||||
|
||||
@@ -203,6 +210,8 @@ 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,6 +11,8 @@ 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 */
|
||||
@@ -25,6 +27,7 @@ function SuggestionsMenuItem({
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
onPointerMove,
|
||||
title,
|
||||
subtitle,
|
||||
shortcut,
|
||||
@@ -53,6 +56,7 @@ function SuggestionsMenuItem({
|
||||
ref={ref}
|
||||
active={selected}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onPointerMove={disabled ? undefined : onPointerMove}
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -20,8 +20,9 @@ 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 { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
@@ -166,6 +167,51 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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\)$/, "™️");
|
||||
|
||||
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
|
||||
name: "replaceAttachment",
|
||||
tooltip: dictionary.replaceAttachment,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteAttachment",
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -33,14 +33,12 @@ 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) &&
|
||||
@@ -51,19 +49,16 @@ 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",
|
||||
@@ -75,13 +70,11 @@ export default function imageMenuItems(
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -24,7 +24,6 @@ const NotificationMenu: React.FC = () => {
|
||||
{
|
||||
type: "button",
|
||||
title: t("Notification settings"),
|
||||
visible: true,
|
||||
onClick: () => performAction(navigateToNotificationSettings, context),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,7 +28,6 @@ function TableOfContentsMenu() {
|
||||
const i = [
|
||||
{
|
||||
type: "heading",
|
||||
visible: true,
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
|
||||
@@ -92,6 +92,11 @@ 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 {
|
||||
|
||||
@@ -188,9 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
collaboratorIds: string[];
|
||||
|
||||
@observable
|
||||
@Relation(() => User)
|
||||
createdBy: User | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
@observable
|
||||
updatedBy: User | undefined;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -27,6 +28,7 @@ class FileOperation extends Model {
|
||||
|
||||
format: FileOperationFormat;
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
@computed
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -19,6 +20,10 @@ 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;
|
||||
|
||||
|
||||
+8
-2
@@ -1,12 +1,13 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, 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 {
|
||||
class Share extends Model implements Searchable {
|
||||
static modelName = "Share";
|
||||
|
||||
@Field
|
||||
@@ -65,6 +66,11 @@ class Share extends Model {
|
||||
/** 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,6 +174,7 @@ 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) {
|
||||
@@ -551,6 +552,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
>
|
||||
<Notices document={document} readOnly={readOnly} />
|
||||
|
||||
{showContents && (
|
||||
<PrintContentsContainer>
|
||||
<Contents />
|
||||
</PrintContentsContainer>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
key={embedsDisabled ? "disabled" : "enabled"}
|
||||
@@ -665,6 +671,19 @@ 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 = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -136,14 +137,19 @@ function History() {
|
||||
"desc"
|
||||
);
|
||||
|
||||
const latestEvent = merged[0];
|
||||
const latestRevisionEvent = merged.find(
|
||||
(event) => event.name === "revisions.create"
|
||||
);
|
||||
|
||||
if (latestEvent && document) {
|
||||
const latestRevisionEvent = merged.find(
|
||||
(event) => event.name === "revisions.create"
|
||||
);
|
||||
if (latestRevisionEvent && document) {
|
||||
const latestRevision = revisions.get(latestRevisionEvent.id);
|
||||
|
||||
if (latestEvent.createdAt !== document.updatedAt) {
|
||||
const isDocUpdated =
|
||||
latestRevision?.title !== document.title ||
|
||||
!isEqual(latestRevision.data, document.data);
|
||||
|
||||
if (isDocUpdated) {
|
||||
revisions.remove(RevisionHelper.latestId(document.id));
|
||||
merged.unshift({
|
||||
id: RevisionHelper.latestId(document.id),
|
||||
name: "revisions.create",
|
||||
@@ -157,7 +163,7 @@ function History() {
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [document, revisionEvents, nonRevisionEvents]);
|
||||
}, [revisions, document, revisionEvents, nonRevisionEvents]);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
if (document) {
|
||||
|
||||
@@ -99,7 +99,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
});
|
||||
|
||||
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
|
||||
presence.updateFromAwarenessChangeEvent(documentId, event);
|
||||
presence.updateFromAwarenessChangeEvent(
|
||||
documentId,
|
||||
provider.awareness.clientID,
|
||||
event
|
||||
);
|
||||
|
||||
event.states.forEach(({ user, scrollY }) => {
|
||||
if (user) {
|
||||
|
||||
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
|
||||
items: [
|
||||
{
|
||||
shortcut: "@",
|
||||
label: t("Mention user or document"),
|
||||
label: t("Mention users and more"),
|
||||
},
|
||||
{
|
||||
shortcut: ":",
|
||||
|
||||
@@ -105,7 +105,7 @@ function Message({ notice }: { notice: string }) {
|
||||
case "authentication-provider-disabled":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
Authentication failed – this login method was disabled by a workspace
|
||||
admin.
|
||||
</Trans>
|
||||
);
|
||||
|
||||
@@ -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 { hover, hideScrollbars } from "@shared/styles";
|
||||
import { 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") ?? undefined;
|
||||
const userId = params.get("userId") ?? undefined;
|
||||
const collectionId = params.get("collectionId") ?? "";
|
||||
const userId = params.get("userId") ?? "";
|
||||
const documentId = params.get("documentId") ?? undefined;
|
||||
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
|
||||
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
|
||||
const statusFilter = params.getAll("statusFilter")?.length
|
||||
? (params.getAll("statusFilter") as TStatusFilter[])
|
||||
: [TStatusFilter.Published, TStatusFilter.Draft];
|
||||
@@ -375,27 +375,24 @@ 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: 2px;
|
||||
margin-top: 4px;
|
||||
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={18} />,
|
||||
icon: <CollectionIcon collection={collection} size={24} />,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any collection"),
|
||||
icon: <SVGCollectionIcon size={18} />,
|
||||
icon: <SVGCollectionIcon size={24} />,
|
||||
},
|
||||
...collectionOptions,
|
||||
];
|
||||
@@ -39,7 +39,6 @@ function CollectionFilter(props: Props) {
|
||||
selectedKeys={[collectionId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any collection")}
|
||||
selectedPrefix={`${t("Collection")}:`}
|
||||
showFilter
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any time"),
|
||||
label: t("All 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.title}
|
||||
{props.document.titleWithDefault}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,9 @@ const RemoveButton = styled(NudeButton)`
|
||||
opacity: 0;
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
&:hover {
|
||||
&:focus,
|
||||
&:${hover} {
|
||||
opacity: 1;
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
@@ -61,17 +63,11 @@ const RecentSearch = styled(Link)`
|
||||
justify-content: space-between;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: var(--pointer);
|
||||
padding: 1px 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
&:before {
|
||||
content: "·";
|
||||
color: ${s("textTertiary")};
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
}
|
||||
margin: 0 -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 Fade from "~/components/Fade";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RecentSearchListItem from "./RecentSearchListItem";
|
||||
|
||||
@@ -19,7 +19,6 @@ function RecentSearches(
|
||||
) {
|
||||
const { searches } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [isPreloaded] = React.useState(searches.recent.length > 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
void searches.fetchPage({
|
||||
@@ -48,7 +47,11 @@ function RecentSearches(
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return isPreloaded ? content : <Fade>{content}</Fade>;
|
||||
return (
|
||||
<ConditionalFade animate={!searches.recent.length}>
|
||||
{content}
|
||||
</ConditionalFade>
|
||||
);
|
||||
}
|
||||
|
||||
const Heading = styled.h2`
|
||||
@@ -56,7 +59,7 @@ const Heading = styled.h2`
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${s("textSecondary")};
|
||||
margin-bottom: 0;
|
||||
margin: 12px 0 0;
|
||||
`;
|
||||
|
||||
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
||||
|
||||
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
|
||||
const userOptions = users.all.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
icon: <Avatar model={user} size={AvatarSize.Small} />,
|
||||
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
key: "",
|
||||
label: t("Any author"),
|
||||
icon: <NoAuthor size={20} />,
|
||||
icon: <UserIcon size={20} />,
|
||||
},
|
||||
...userOptions,
|
||||
];
|
||||
@@ -43,7 +43,6 @@ function UserFilter(props: Props) {
|
||||
selectedKeys={[userId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any author")}
|
||||
selectedPrefix={`${t("Author")}:`}
|
||||
fetchQuery={users.fetchPage}
|
||||
fetchQueryOptions={fetchQueryOptions}
|
||||
showFilter
|
||||
@@ -51,8 +50,8 @@ function UserFilter(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const NoAuthor = styled(UserIcon)`
|
||||
margin-left: -2px;
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin: 2px;
|
||||
`;
|
||||
|
||||
export default observer(UserFilter);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -149,15 +150,17 @@ function Groups() {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
|
||||
@@ -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 Fade from "~/components/Fade";
|
||||
import { ConditionalFade } 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 { PeopleTable } from "./components/PeopleTable";
|
||||
import { MembersTable } from "./components/MembersTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import UserRoleFilter from "./components/UserRoleFilter";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
@@ -163,8 +163,8 @@ function Members() {
|
||||
onSelect={handleRoleFilter}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<Fade>
|
||||
<PeopleTable
|
||||
<ConditionalFade animate={!data}>
|
||||
<MembersTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
@@ -174,7 +174,7 @@ function Members() {
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
|
||||
import { GlobeIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ConditionalFade } 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";
|
||||
@@ -16,17 +17,22 @@ 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"
|
||||
@@ -44,18 +50,44 @@ function Shares() {
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: shares.orderedData,
|
||||
data: shares.findByQuery(reqParams.query ?? ""),
|
||||
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>
|
||||
@@ -83,20 +115,26 @@ function Shares() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{data?.length ? (
|
||||
<Fade>
|
||||
<SharesTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
canManage={can.update}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
) : null}
|
||||
<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>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
/**
|
||||
@@ -8,8 +9,9 @@ import { s } from "@shared/styles";
|
||||
export const ActionRow = styled.div`
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 16px 50vw;
|
||||
margin: 0 -50vw;
|
||||
width: 100vw;
|
||||
padding: 16px 12px;
|
||||
margin-left: -12px;
|
||||
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -17,4 +19,8 @@ export const ActionRow = styled.div`
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export function PeopleTable({ canManage, ...rest }: Props) {
|
||||
export function MembersTable({ 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 } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize } 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={4}>
|
||||
<Flex align="center" gap={8}>
|
||||
{share.createdBy && (
|
||||
<>
|
||||
<Avatar model={share.createdBy} />
|
||||
<Avatar model={share.createdBy} size={AvatarSize.Small} />
|
||||
{share.createdBy.name}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -69,7 +69,9 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
*/
|
||||
@computed
|
||||
get nonPrivate(): Collection[] {
|
||||
return this.all.filter((collection) => !collection.isPrivate);
|
||||
return this.all.filter(
|
||||
(collection) => collection.isActive && !collection.isPrivate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,17 +14,16 @@ 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;
|
||||
}
|
||||
|
||||
// called when a user leaves the document
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@action
|
||||
public leave(documentId: string, userId: string) {
|
||||
const existing = this.data.get(documentId);
|
||||
@@ -34,8 +33,16 @@ 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);
|
||||
@@ -45,7 +52,13 @@ export default class PresenceStore {
|
||||
|
||||
event.states.forEach((state) => {
|
||||
const { user, cursor } = state;
|
||||
if (user && this.rootStore.auth.currentUserId !== user.id) {
|
||||
|
||||
// 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)) {
|
||||
this.update(documentId, user.id, !!cursor);
|
||||
existingUserIds = existingUserIds.filter((id) => id !== user.id);
|
||||
}
|
||||
@@ -56,6 +69,14 @@ 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);
|
||||
@@ -73,6 +94,13 @@ 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();
|
||||
@@ -95,4 +123,10 @@ export default class PresenceStore {
|
||||
public clear() {
|
||||
this.data.clear();
|
||||
}
|
||||
|
||||
private timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
private offlineTimeout = 30000;
|
||||
|
||||
private rootStore: RootStore;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
export default class RevisionsStore extends Store<Revision> {
|
||||
actions = [RPCAction.List, RPCAction.Info];
|
||||
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Revision);
|
||||
|
||||
+24
-1
@@ -206,8 +206,31 @@ 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: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
|
||||
states: {
|
||||
clientId: number;
|
||||
user?: { id: string };
|
||||
cursor: Cursor;
|
||||
scrollY: number | undefined;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const EmptySelectValue = "__empty__";
|
||||
|
||||
+22
-21
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@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": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.7.10",
|
||||
"@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.0",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@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": "^3.58.0",
|
||||
"dd-trace": "^5.41.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"email-providers": "^1.14.0",
|
||||
@@ -186,9 +186,9 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.37.1",
|
||||
"prosemirror-view": "^1.38.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"randomstring": "1.3.1",
|
||||
"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.6.2",
|
||||
"semver": "^7.7.1",
|
||||
"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.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"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.3",
|
||||
"@types/react-color": "^3.0.12",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@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.102",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"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": "^7.9.0",
|
||||
"i18next-parser": "^8.13.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.37.0",
|
||||
"typescript": "^5.7.2",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -368,7 +368,8 @@
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,56 @@ 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 });
|
||||
|
||||
@@ -102,7 +102,7 @@ async function accountProvisioner({
|
||||
if (err.id === "invalid_authentication") {
|
||||
const authenticationProvider = await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: authenticationProviderParams.name, // example: "google"
|
||||
name: authenticationProviderParams.name,
|
||||
teamId: teamParams.teamId,
|
||||
},
|
||||
include: [
|
||||
@@ -112,6 +112,7 @@ async function accountProvisioner({
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
order: [["enabled", "DESC"]],
|
||||
});
|
||||
|
||||
if (authenticationProvider) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
@@ -45,8 +43,6 @@ 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] ??
|
||||
@@ -72,7 +68,6 @@ export default async function documentCollaborativeUpdater({
|
||||
|
||||
await document.update(
|
||||
{
|
||||
text,
|
||||
content,
|
||||
state: Buffer.from(state),
|
||||
lastModifiedById,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Optional } from "utility-types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper as SharedProsemirrorHelper } 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<
|
||||
@@ -81,53 +82,58 @@ export default async function documentCreator({
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.create",
|
||||
|
||||
@@ -52,7 +52,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(document),
|
||||
["comment"]
|
||||
),
|
||||
text: document.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
@@ -86,7 +85,6 @@ export default async function documentDuplicator({
|
||||
DocumentHelper.toProsemirror(childDocument),
|
||||
["comment"]
|
||||
),
|
||||
text: childDocument.text,
|
||||
...sharedProperties,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ async function teamProvisioner({
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
order: [["enabled", "DESC"]],
|
||||
});
|
||||
|
||||
// This authentication provider already exists which means we have a team and
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function userProvisioner({
|
||||
const auth = authentication
|
||||
? await UserAuthentication.findOne({
|
||||
where: {
|
||||
providerId: "" + authentication.providerId,
|
||||
providerId: String(authentication.providerId),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import { Document, UserMembership } from "@server/models";
|
||||
import { Document, GroupMembership, UserMembership } from "@server/models";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
@@ -11,13 +11,14 @@ import Heading from "./components/Heading";
|
||||
type InputProps = EmailProps & {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
membershipId?: string;
|
||||
actorName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
membership: UserMembership;
|
||||
membership: UserMembership | GroupMembership;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
@@ -33,18 +34,20 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend({ documentId, userId }: InputProps) {
|
||||
protected async beforeSend({ documentId, membershipId }: InputProps) {
|
||||
if (!membershipId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const membership = await UserMembership.findOne({
|
||||
where: {
|
||||
documentId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
const membership =
|
||||
(await UserMembership.findByPk(membershipId)) ??
|
||||
(await GroupMembership.findByPk(membershipId));
|
||||
|
||||
if (!membership) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"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");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"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");
|
||||
},
|
||||
};
|
||||
@@ -830,7 +830,9 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
this.content = revision.content;
|
||||
this.text = revision.text;
|
||||
this.text = DocumentHelper.toMarkdown(revision, {
|
||||
includeTitle: false,
|
||||
});
|
||||
this.title = revision.title;
|
||||
this.icon = revision.icon;
|
||||
this.color = revision.color;
|
||||
|
||||
@@ -177,6 +177,10 @@ class Notification extends Model<
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.UUID)
|
||||
membershipId: string;
|
||||
|
||||
@AfterCreate
|
||||
static async createEvent(
|
||||
model: Notification,
|
||||
@@ -191,6 +195,7 @@ class Notification extends Model<
|
||||
documentId: model.documentId,
|
||||
collectionId: model.collectionId,
|
||||
actorId: model.actorId,
|
||||
membershipId: model.membershipId,
|
||||
};
|
||||
|
||||
if (options.transaction) {
|
||||
|
||||
@@ -16,6 +16,5 @@ describe("#findLatest", () => {
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.findLatest(document.id);
|
||||
expect(revision?.title).toBe("Changed 2");
|
||||
expect(revision?.text).toBe("Content");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Length as SimpleLength,
|
||||
} from "sequelize-typescript";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { DocumentValidation, RevisionValidation } from "@shared/validations";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
@@ -42,6 +42,7 @@ 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`,
|
||||
@@ -49,6 +50,7 @@ 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`,
|
||||
@@ -56,22 +58,29 @@ 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.
|
||||
* @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.
|
||||
*/
|
||||
@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;
|
||||
|
||||
/** An icon to use as the document icon. */
|
||||
/** The icon at the time of the revision. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -79,7 +88,7 @@ class Revision extends IdModel<
|
||||
@Column
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the icon. */
|
||||
/** The color at the time of the revision. */
|
||||
@IsHexColor
|
||||
@Column
|
||||
color: string | null;
|
||||
@@ -126,7 +135,6 @@ 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,
|
||||
|
||||
@@ -79,6 +79,7 @@ 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({
|
||||
@@ -92,6 +93,7 @@ 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({
|
||||
@@ -104,6 +106,7 @@ 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({
|
||||
|
||||
@@ -614,6 +614,7 @@ class User extends ParanoidModel<
|
||||
where: { email: this.email },
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "ASC"]],
|
||||
});
|
||||
|
||||
// hooks
|
||||
|
||||
@@ -147,10 +147,15 @@ 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
|
||||
document: Document | Revision | Collection | ProsemirrorData,
|
||||
options?: {
|
||||
/** Whether to include the document title (default: true) */
|
||||
includeTitle?: boolean;
|
||||
}
|
||||
) {
|
||||
const text = serializer
|
||||
.serialize(DocumentHelper.toProsemirror(document))
|
||||
@@ -165,7 +170,10 @@ export class DocumentHelper {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (document instanceof Document || document instanceof Revision) {
|
||||
if (
|
||||
(document instanceof Document || document instanceof Revision) &&
|
||||
options?.includeTitle !== false
|
||||
) {
|
||||
const iconType = determineIconType(document.icon);
|
||||
|
||||
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
buildComment,
|
||||
buildDocument,
|
||||
buildSubscription,
|
||||
buildUser,
|
||||
@@ -7,6 +8,112 @@ 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();
|
||||
|
||||
@@ -62,7 +62,7 @@ export default class NotificationHelper {
|
||||
): Promise<User[]> => {
|
||||
let recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
notificationType: NotificationEventType.CreateComment,
|
||||
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 = {
|
||||
|
||||
@@ -118,10 +118,13 @@ export class ProsemirrorHelper {
|
||||
/**
|
||||
* Converts a plain object into a Prosemirror Node.
|
||||
*
|
||||
* @param data The object to parse
|
||||
* @param data The ProsemirrorData object or string to parse.
|
||||
* @returns The content as a Prosemirror Node
|
||||
*/
|
||||
static toProsemirror(data: ProsemirrorData) {
|
||||
static toProsemirror(data: ProsemirrorData | string) {
|
||||
if (typeof data === "string") {
|
||||
return parser.parse(data);
|
||||
}
|
||||
return Node.fromJSON(schema, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -861,6 +861,51 @@ 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:*");
|
||||
|
||||
@@ -203,6 +203,35 @@ 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 = {}
|
||||
@@ -601,6 +630,8 @@ 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",
|
||||
@@ -665,7 +696,6 @@ export default class SearchHelper {
|
||||
"because",
|
||||
"as",
|
||||
"until",
|
||||
"while",
|
||||
"of",
|
||||
"at",
|
||||
"by",
|
||||
@@ -673,7 +703,6 @@ export default class SearchHelper {
|
||||
"with",
|
||||
"about",
|
||||
"against",
|
||||
"between",
|
||||
"into",
|
||||
"through",
|
||||
"during",
|
||||
@@ -681,18 +710,12 @@ export default class SearchHelper {
|
||||
"after",
|
||||
"above",
|
||||
"below",
|
||||
"to",
|
||||
"from",
|
||||
"up",
|
||||
"down",
|
||||
"in",
|
||||
"out",
|
||||
"on",
|
||||
"off",
|
||||
"over",
|
||||
"under",
|
||||
"again",
|
||||
"further",
|
||||
"then",
|
||||
"once",
|
||||
"here",
|
||||
@@ -700,22 +723,15 @@ 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",
|
||||
@@ -723,12 +739,8 @@ export default class SearchHelper {
|
||||
"very",
|
||||
"s",
|
||||
"t",
|
||||
"can",
|
||||
"will",
|
||||
"just",
|
||||
"don",
|
||||
"should",
|
||||
"now",
|
||||
];
|
||||
return query
|
||||
.split(" ")
|
||||
|
||||
+1
-36
@@ -6,18 +6,8 @@ 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 {
|
||||
AuthorizationError,
|
||||
ClientClosedRequestError,
|
||||
InternalError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "@server/errors";
|
||||
import { ClientClosedRequestError, InternalError } from "@server/errors";
|
||||
import { requestErrorHandler } from "@server/logging/sentry";
|
||||
|
||||
let errorHtmlCache: Buffer | undefined;
|
||||
@@ -32,16 +22,6 @@ 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) {
|
||||
@@ -49,21 +29,6 @@ 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" ||
|
||||
|
||||
@@ -12,6 +12,7 @@ import "./fileOperation";
|
||||
import "./integration";
|
||||
import "./pins";
|
||||
import "./reaction";
|
||||
import "./revision";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./star";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
)
|
||||
);
|
||||
@@ -42,7 +42,7 @@ async function presentDocument(
|
||||
|
||||
const text =
|
||||
!asData || options?.includeText
|
||||
? document.text || DocumentHelper.toMarkdown(data)
|
||||
? DocumentHelper.toMarkdown(data, { includeTitle: false })
|
||||
: undefined;
|
||||
|
||||
const res: Record<string, any> = {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,21 +6,17 @@ 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);
|
||||
|
||||
@@ -29,11 +25,6 @@ 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: {
|
||||
@@ -49,7 +40,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionTask.schedule({
|
||||
...event,
|
||||
name: userEventName,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ 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,6 +2,7 @@ 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 {
|
||||
@@ -36,6 +37,8 @@ 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/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
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/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Op } from "sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { GroupUser, UserMembership } from "@server/models";
|
||||
import { GroupUser } from "@server/models";
|
||||
import { DocumentGroupEvent } from "@server/types";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
|
||||
@@ -20,26 +19,9 @@ 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,27 +1,65 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { DocumentPermission, NotificationEventType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
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 recipient = await User.findByPk(event.userId);
|
||||
if (!recipient) {
|
||||
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.isSuspended &&
|
||||
recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
|
||||
!recipient ||
|
||||
recipient.isSuspended ||
|
||||
!recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.AddUserToDocument,
|
||||
userId: event.userId,
|
||||
actorId: event.actorId,
|
||||
teamId: event.teamId,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, User } from "@server/models";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Subscription, User } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { DocumentUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
@@ -10,44 +11,29 @@ import BaseTask from "./BaseTask";
|
||||
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
|
||||
public async perform(event: DocumentUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
if (!user) {
|
||||
|
||||
if (!user || event.name !== "documents.remove_user") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "documents.add_user":
|
||||
return this.addUser(event, user);
|
||||
|
||||
case "documents.remove_user":
|
||||
return this.removeUser(event, user);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private async addUser(event: DocumentUserEvent, user: User) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await subscriptionCreator({
|
||||
ctx: createContext({
|
||||
user,
|
||||
authType: event.authType,
|
||||
ip: event.ip,
|
||||
transaction,
|
||||
}),
|
||||
documentId: event.documentId,
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async removeUser(event: DocumentUserEvent, user: User) {
|
||||
if (can(user, "read", document)) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Skip unsubscribing user ${user.id} as they have permission to the document ${event.documentId} through other means`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const subscription = await Subscription.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user