mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98445e9996 | |||
| da6a449cf3 | |||
| 4631b5ccaa | |||
| 4d5895d2a8 | |||
| 3543fafee3 | |||
| e77cdc2903 | |||
| ecba11b786 | |||
| 6d13347806 | |||
| 36773febd2 | |||
| fa8d82d82a | |||
| cc6d2dc471 | |||
| 5035ad2027 | |||
| 06ec6fdfbb | |||
| acc8d99ca0 | |||
| 7da3108412 | |||
| 7e56d04285 | |||
| 3987b7de3d | |||
| 6daed33b4a | |||
| 3551d16bd8 | |||
| 641c0da603 | |||
| 7768273255 | |||
| 9cadcc668c | |||
| adc11aee9f | |||
| 7ab247f367 | |||
| 9ec5c473f1 | |||
| 02bdb2e464 | |||
| 77d50f8323 | |||
| 76691e8aaa | |||
| 633d41e67f | |||
| 3db845b395 | |||
| 3269eacf68 | |||
| eef2ea4347 | |||
| a2ce13a7dd | |||
| ff13f1a452 | |||
| a5d065e5ec | |||
| fc6152bd55 | |||
| 06d4d7e893 | |||
| a85f36d896 | |||
| 5231318e55 | |||
| 916032508c | |||
| 1a3478a228 | |||
| 1028edaa03 | |||
| 6a736072f0 | |||
| 94f302f712 | |||
| d2ef7e770d | |||
| 323094ce57 | |||
| 0e596f61c8 | |||
| a23888f5d6 | |||
| 515e160bdb | |||
| c853063d1f | |||
| e86593f234 | |||
| 285b770b3d | |||
| 2c27ef9c2c | |||
| 3704dc2a4d | |||
| d37422ab8a | |||
| a75af8759b |
+2
-4
@@ -20,8 +20,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@@ -48,8 +47,7 @@
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:22-slim AS runner
|
||||
FROM node:22.21.0-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
@@ -16,9 +16,9 @@ ENV NODE_ENV=production
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
|
||||
COPY --from=base $APP_PATH/server ./server
|
||||
@@ -29,13 +29,13 @@ COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
|
||||
|
||||
VOLUME /var/lib/outline/data
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:22 AS deps
|
||||
FROM node:22.21.0 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.87.4
|
||||
Licensed Work: Outline 1.0.1
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-09-18
|
||||
Change Date: 2029-10-29
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "crypto",
|
||||
"message": "Do not use, does not work in environments without SSL."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -176,6 +176,21 @@ export const toggleDebugLogging = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleDebugSafeArea = createAction({
|
||||
name: () => "Toggle menu safe area debugging",
|
||||
icon: <ToolsIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: ({ stores }) => {
|
||||
stores.ui.toggleDebugSafeArea();
|
||||
toast.message(
|
||||
stores.ui.debugSafeArea
|
||||
? "Menu safe area debugging enabled"
|
||||
: "Menu safe area debugging disabled"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleFeatureFlag = createAction({
|
||||
name: "Toggle feature flag",
|
||||
icon: <BeakerIcon />,
|
||||
@@ -209,6 +224,7 @@ export const developer = createAction({
|
||||
children: [
|
||||
copyId,
|
||||
toggleDebugLogging,
|
||||
toggleDebugSafeArea,
|
||||
toggleFeatureFlag,
|
||||
createToast,
|
||||
createTestUsers,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
@@ -45,7 +46,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ export function createActionV2(
|
||||
return definition.perform(context);
|
||||
}
|
||||
: () => {},
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +213,7 @@ export function createInternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ export function createExternalLinkActionV2(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,7 +235,7 @@ export function createActionV2WithChildren(
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? crypto.randomUUID(),
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,7 +252,7 @@ export function createRootMenuAction(
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GroupAvatar({
|
||||
return (
|
||||
<Squircle color={color ?? theme.text} size={size} className={className}>
|
||||
<GroupIcon
|
||||
data-fixed-color
|
||||
color={backgroundColor ?? theme.background}
|
||||
size={size * 0.75}
|
||||
/>
|
||||
|
||||
@@ -83,13 +83,15 @@ function EditableTitle(
|
||||
try {
|
||||
await onSubmit(trimmedValue);
|
||||
setOriginalValue(trimmedValue);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
setValue(value);
|
||||
setIsEditing(true);
|
||||
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[originalValue, value, onCancel, onSubmit, isSubmitting]
|
||||
|
||||
@@ -13,6 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
@@ -132,6 +133,13 @@ const HoverPreviewDesktop = observer(
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Group ? (
|
||||
<HoverPreviewGroup
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
memberCount={data.memberCount}
|
||||
users={data.users}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
@@ -295,10 +303,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
${({ direction, theme }) =>
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
|
||||
: `border-top-color: rgba(0, 0, 0, 0.1)`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import User from "~/models/User";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Info,
|
||||
Card,
|
||||
CardContent,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
|
||||
|
||||
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
|
||||
{ name, memberCount, users }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<Preview as="div">
|
||||
<Card fadeOut={false} ref={ref}>
|
||||
<CardContent>
|
||||
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
|
||||
<Flex column gap={2} align="start">
|
||||
<Title>{name}</Title>
|
||||
<Info>
|
||||
{memberCount === 1 ? "1 member" : `${memberCount} members`}
|
||||
</Info>
|
||||
{users.length > 0 && (
|
||||
<Description>
|
||||
<Facepile
|
||||
users={users.map(
|
||||
(member) =>
|
||||
({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
color: member.color,
|
||||
initial: member.name ? member.name[0] : "?",
|
||||
}) as User
|
||||
)}
|
||||
overflow={Math.max(0, memberCount - users.length)}
|
||||
limit={MAX_AVATAR_DISPLAY}
|
||||
/>
|
||||
</Description>
|
||||
)}
|
||||
</Flex>
|
||||
</ErrorBoundary>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
export default HoverPreviewGroup;
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,7 +25,6 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
export const MeasuredContainer = <T extends React.ElementType>({
|
||||
as: As,
|
||||
as: As = "div",
|
||||
name,
|
||||
children,
|
||||
...rest
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import { createRef } from "react";
|
||||
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
const parentRef = createRef<HTMLDivElement>();
|
||||
|
||||
if (!filteredItems.length) {
|
||||
return null;
|
||||
@@ -88,7 +91,10 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
<SubMenuContent ref={parentRef}>
|
||||
<MouseSafeArea parentRef={parentRef} />
|
||||
{submenuItems}
|
||||
</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMousePosition } from "~/hooks/useMousePosition";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Positions = {
|
||||
/** Sub-menu x */
|
||||
x: number;
|
||||
/** Sub-menu y */
|
||||
y: number;
|
||||
/** Sub-menu height */
|
||||
h: number;
|
||||
/** Sub-menu width */
|
||||
w: number;
|
||||
/** Mouse x */
|
||||
mouseX: number;
|
||||
/** Mouse y */
|
||||
mouseY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to cover the area between the mouse cursor and the sub-menu, to
|
||||
* allow moving cursor to lower parts of sub-menu without the sub-menu
|
||||
* disappearing.
|
||||
*/
|
||||
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
height: h = 0,
|
||||
width: w = 0,
|
||||
} = props.parentRef.current?.getBoundingClientRect() || {};
|
||||
const { ui } = useStores();
|
||||
const [mouseX, mouseY] = useMousePosition();
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const positions = { x, y, h, w, mouseX, mouseY };
|
||||
const distance = Math.abs(mouseX - x);
|
||||
const prevDistance = usePrevious(distance) ?? distance;
|
||||
|
||||
// Hide the safe area if the mouse is moving _away_ from the menu
|
||||
React.useEffect(() => {
|
||||
if (distance > prevDistance) {
|
||||
setIsVisible(false);
|
||||
} else if (distance < prevDistance) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [distance, prevDistance]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
|
||||
right: getRight(positions),
|
||||
left: getLeft(positions),
|
||||
height: h,
|
||||
width: getWidth(positions),
|
||||
clipPath: getClipPath(positions),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const buffer = 10;
|
||||
|
||||
const getLeft = ({ x, mouseX }: Positions) =>
|
||||
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getRight = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
|
||||
|
||||
const getWidth = ({ x, w, mouseX }: Positions) =>
|
||||
mouseX > x
|
||||
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
|
||||
: Math.max(x - mouseX + buffer, buffer) + "px";
|
||||
|
||||
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
|
||||
mouseX > x
|
||||
? `polygon(0% 0%, 0% 100%, 100% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
|
||||
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
|
||||
(100 * (mouseY - y)) / h + 5
|
||||
}%, 100% 100%)`;
|
||||
@@ -47,7 +47,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable}>
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -57,9 +57,7 @@ function SharedSidebar({ share }: Props) {
|
||||
onClick={() =>
|
||||
history.push(user ? homePath() : sharedModelPath(shareId))
|
||||
}
|
||||
>
|
||||
<ToggleSidebar />
|
||||
</SidebarButton>
|
||||
/>
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
|
||||
@@ -25,13 +25,15 @@ import { useTranslation } from "react-i18next";
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
/** Whether the sidebar can be resized and collapsed, defaults to true. */
|
||||
canResize?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children, hidden = false, className }: Props,
|
||||
{ children, hidden = false, canResize = true, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
@@ -43,7 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed;
|
||||
const collapsed = ui.sidebarIsClosed && canResize;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
@@ -254,10 +256,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{canResize && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -86,27 +86,33 @@ const CollectionLink: React.FC<Props> = ({
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
title: input,
|
||||
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
@@ -192,6 +198,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ function Disclosure({ onClick, root, expanded, ...rest }: Props) {
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
{...rest}
|
||||
>
|
||||
<StyledCollapsedIcon expanded={expanded} size={20} />
|
||||
<StyledCollapsedIcon $expanded={expanded} size={20} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -52,13 +52,13 @@ const Button = styled(NudeButton)<{ $root?: boolean }>`
|
||||
`;
|
||||
|
||||
const StyledCollapsedIcon = styled(CollapsedIcon)<{
|
||||
expanded?: boolean;
|
||||
$expanded?: boolean;
|
||||
}>`
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease,
|
||||
fill 50ms !important;
|
||||
${(props) => !props.expanded && "transform: rotate(-90deg);"};
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
// Enables identifying this component within styled components
|
||||
|
||||
@@ -281,30 +281,36 @@ function InnerDocumentLink(
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
const newChildTitleRef = React.useRef<RefHandle>(null);
|
||||
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
|
||||
useBoolean();
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
async (input) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
try {
|
||||
newChildTitleRef.current?.setIsEditing(false);
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{ publish: true }
|
||||
);
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
closeAddingNewChild();
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
} catch (_err) {
|
||||
newChildTitleRef.current?.setIsEditing(true);
|
||||
}
|
||||
},
|
||||
[
|
||||
documents,
|
||||
@@ -320,6 +326,62 @@ function InnerDocumentLink(
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
),
|
||||
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
|
||||
);
|
||||
|
||||
const menuElement = React.useMemo(
|
||||
() =>
|
||||
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined,
|
||||
[
|
||||
document,
|
||||
isMoving,
|
||||
isEditing,
|
||||
isDraggingAnyDocument,
|
||||
can.createChildDocument,
|
||||
t,
|
||||
setIsAddingNewChild,
|
||||
setExpanded,
|
||||
handleRename,
|
||||
handleMenuOpen,
|
||||
handleMenuClose,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
@@ -345,17 +407,7 @@ function InnerDocumentLink(
|
||||
contextAction={contextMenuAction}
|
||||
to={toPath}
|
||||
icon={iconElement}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={editableTitleRef}
|
||||
/>
|
||||
}
|
||||
label={labelElement}
|
||||
isActive={isActiveCheck}
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
@@ -364,35 +416,7 @@ function InnerDocumentLink(
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={
|
||||
document &&
|
||||
!isMoving &&
|
||||
!isEditing &&
|
||||
!isDraggingAnyDocument ? (
|
||||
<Fade>
|
||||
{can.createChildDocument && (
|
||||
<Tooltip content={t("New doc")}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
setExpanded();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Fade>
|
||||
) : undefined
|
||||
}
|
||||
menu={menuElement}
|
||||
/>
|
||||
</DropToImport>
|
||||
</div>
|
||||
@@ -414,6 +438,7 @@ function InnerDocumentLink(
|
||||
onCancel={closeAddingNewChild}
|
||||
onSubmit={handleNewDoc}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
ref={newChildTitleRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,19 +8,32 @@ import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { RequestResponse } from "~/hooks/usePaginatedRequest";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import { t } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Props = {
|
||||
/** The group to render */
|
||||
group: Group;
|
||||
/** The response from the group memberships request */
|
||||
response: RequestResponse<GroupMembership>;
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const GroupLink: React.FC<Props> = ({ group, response }) => {
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const { loading, next, end, error } = response;
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load shared documents"));
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
@@ -50,6 +63,14 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={loading}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
</Folder>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
|
||||
<H3>
|
||||
<Button onClick={handleClick} disabled={!id}>
|
||||
{title}
|
||||
{id && <Disclosure expanded={expanded} size={20} />}
|
||||
{id && <Disclosure $expanded={expanded} size={20} />}
|
||||
</Button>
|
||||
</H3>
|
||||
{expanded && (firstRender ? children : <Fade>{children}</Fade>)}
|
||||
@@ -91,12 +91,12 @@ const Button = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)<{ expanded?: boolean }>`
|
||||
const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease,
|
||||
fill 50ms !important;
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ function SharedWithMe() {
|
||||
const history = useHistory();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
|
||||
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
|
||||
const gmResponse = usePaginatedRequest<GroupMembership>(
|
||||
groupMemberships.fetchAll
|
||||
);
|
||||
|
||||
const { loading, next, end, error, page } =
|
||||
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
|
||||
@@ -108,7 +110,7 @@ function SharedWithMe() {
|
||||
<Flex column>
|
||||
<Header id="shared" title={t("Shared with me")}>
|
||||
{user.groupsWithDocumentMemberships.map((group) => (
|
||||
<GroupLink key={group.id} group={group} />
|
||||
<GroupLink key={group.id} group={group} response={gmResponse} />
|
||||
))}
|
||||
<Relative>
|
||||
{reorderProps.isDragging && (
|
||||
|
||||
@@ -85,11 +85,8 @@ function StarredDocumentLink({
|
||||
const { collections, documents } = useStores();
|
||||
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentCollection = document.collectionId
|
||||
const documentCollection = document?.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = documentCollection
|
||||
@@ -97,7 +94,11 @@ function StarredDocumentLink({
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId });
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
|
||||
@@ -7,18 +7,28 @@ export default function useCollectionDocuments(
|
||||
collection: Collection | undefined,
|
||||
activeDocument: Document | undefined
|
||||
) {
|
||||
const insertDraftDocument = useMemo(
|
||||
() =>
|
||||
activeDocument &&
|
||||
activeDocument.isActive &&
|
||||
activeDocument.isDraft &&
|
||||
activeDocument.collectionId === collection?.id &&
|
||||
!activeDocument.parentDocumentId,
|
||||
[
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
activeDocument?.collectionId,
|
||||
activeDocument?.parentDocumentId,
|
||||
collection?.id,
|
||||
]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!collection?.sortedDocuments) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.collectionId === collection.id &&
|
||||
!activeDocument?.parentDocumentId;
|
||||
|
||||
return insertDraftDocument
|
||||
return insertDraftDocument && activeDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
|
||||
collection.sort,
|
||||
@@ -26,14 +36,9 @@ export default function useCollectionDocuments(
|
||||
)
|
||||
: collection.sortedDocuments;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
activeDocument?.collectionId,
|
||||
activeDocument?.parentDocumentId,
|
||||
insertDraftDocument,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
collection?.sortedDocuments,
|
||||
collection?.id,
|
||||
collection?.sort,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
@@ -66,22 +66,18 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&: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);
|
||||
&: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);
|
||||
|
||||
svg {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -13,20 +14,25 @@ function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
filterable
|
||||
trigger="/"
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
@@ -45,18 +45,23 @@ const EmojiMenu = (props: Props) => {
|
||||
[search]
|
||||
);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.description}
|
||||
emoji={item.emoji}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.description}
|
||||
emoji={item.emoji}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -47,9 +47,19 @@ function usePosition({
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const menuWidth = menuRef.current?.offsetWidth ?? 0;
|
||||
const [menuWidth, setMenuWidth] = React.useState(0);
|
||||
const menuHeight = 36;
|
||||
|
||||
// Measure the menu width after DOM updates to ensure accurate positioning
|
||||
React.useLayoutEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const width = menuRef.current.offsetWidth;
|
||||
if (width !== menuWidth) {
|
||||
setMenuWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// based on the start and end of the selection calculate the position at
|
||||
// the center top
|
||||
let fromPos;
|
||||
@@ -288,7 +298,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
ref={menuRef}
|
||||
$offset={position.offset}
|
||||
style={{
|
||||
width: props.width,
|
||||
minWidth: props.width,
|
||||
maxWidth: `${position.maxWidth}px`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
@@ -309,7 +319,7 @@ type WrapperProps = {
|
||||
const arrow = (props: WrapperProps) =>
|
||||
props.arrow
|
||||
? css`
|
||||
&::before {
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 24px;
|
||||
@@ -317,11 +327,14 @@ const arrow = (props: WrapperProps) =>
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
bottom: -2px;
|
||||
left: calc(50% - ${props.$offset || 0}px);
|
||||
pointer-events: none;
|
||||
|
||||
// clip to show only the bottom right corner
|
||||
clip-path: polygon(100% 50%, 100% 100%, 50% 100%);
|
||||
}
|
||||
`
|
||||
: "";
|
||||
|
||||
+28
-34
@@ -3,71 +3,57 @@ import { Node } from "prosemirror-model";
|
||||
import { Selection, TextSelection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
|
||||
import Flex from "~/components/Flex";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Input from "~/editor/components/Input";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
|
||||
type Props = {
|
||||
node: Node;
|
||||
view: EditorView;
|
||||
dictionary: Dictionary;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const embeds = useEmbeds();
|
||||
|
||||
const url = node.attrs.href as string;
|
||||
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
|
||||
const url = (node.attrs.href ?? node.attrs.src) as string;
|
||||
const [localUrl, setLocalUrl] = useState(url);
|
||||
|
||||
const moveSelectionToEnd = useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
|
||||
const nextSelection = Selection.findFrom(
|
||||
state.tr.doc.resolve(state.selection.from),
|
||||
1,
|
||||
true
|
||||
);
|
||||
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
|
||||
|
||||
const selection = nextSelection ?? TextSelection.create(state.tr.doc, 0);
|
||||
dispatch(state.tr.setSelection(selection));
|
||||
view.focus();
|
||||
}, [view]);
|
||||
|
||||
const openEmbed = useCallback(() => {
|
||||
const openLink = useCallback(() => {
|
||||
window.open(url, "_blank");
|
||||
}, [url]);
|
||||
|
||||
const removeEmbed = useCallback(() => {
|
||||
const remove = useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
dispatch(state.tr.deleteSelection());
|
||||
}, [view]);
|
||||
|
||||
const updateEmbed = useCallback(() => {
|
||||
const matchingEmbed = getMatchingEmbed(embeds, localUrl);
|
||||
|
||||
if (!matchingEmbed) {
|
||||
toast.error(t("Sorry, invalid embed link"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, dispatch } = view;
|
||||
dispatch(
|
||||
state.tr.setNodeMarkup(state.selection.from, undefined, {
|
||||
...node.attrs,
|
||||
href: localUrl,
|
||||
})
|
||||
);
|
||||
const update = useCallback(() => {
|
||||
const { state } = view;
|
||||
const hrefType = node.type.name === "image" ? "src" : "href";
|
||||
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
|
||||
...node.attrs,
|
||||
[hrefType]: localUrl,
|
||||
});
|
||||
|
||||
view.dispatch(tr);
|
||||
moveSelectionToEnd();
|
||||
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
|
||||
}, [localUrl, node, view, moveSelectionToEnd]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -78,7 +64,7 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
updateEmbed();
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,12 +75,13 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[updateEmbed, moveSelectionToEnd]
|
||||
[update, moveSelectionToEnd]
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
value={localUrl}
|
||||
placeholder={dictionary.pasteLink}
|
||||
onChange={(e) => setLocalUrl(e.target.value)}
|
||||
@@ -102,13 +89,19 @@ export function EmbedLinkEditor({ node, view, dictionary }: Props) {
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
<Tooltip content={dictionary.openLink}>
|
||||
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
|
||||
<ToolbarButton onClick={openLink} disabled={!localUrl}>
|
||||
<OpenIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{view.editable && (
|
||||
<Tooltip content={dictionary.deleteEmbed}>
|
||||
<ToolbarButton onClick={removeEmbed}>
|
||||
<Tooltip
|
||||
content={
|
||||
node.type.name === "embed"
|
||||
? dictionary.deleteEmbed
|
||||
: dictionary.deleteImage
|
||||
}
|
||||
>
|
||||
<ToolbarButton onClick={remove}>
|
||||
<TrashIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
@@ -121,4 +114,5 @@ const Wrapper = styled(Flex)`
|
||||
pointer-events: all;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
min-width: 350px;
|
||||
`;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,13 +10,14 @@ import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
GroupSection,
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -24,6 +26,7 @@ import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
@@ -44,7 +47,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections } = useStores();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
@@ -52,11 +55,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
query: search,
|
||||
limit: maxResultsInSection,
|
||||
});
|
||||
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
runInAction(() => {
|
||||
res.data.documents.map(documents.add);
|
||||
res.data.users.map(users.add);
|
||||
res.data.collections.map(collections.add);
|
||||
res.data.groups.map(groups.add);
|
||||
});
|
||||
}, [search, documents, users, collections])
|
||||
);
|
||||
|
||||
@@ -91,7 +100,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
@@ -99,6 +108,32 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
@@ -123,7 +158,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
@@ -151,7 +186,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
@@ -171,9 +206,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: crypto.randomUUID(),
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
@@ -183,7 +218,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
groups,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
@@ -196,29 +241,57 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
if (item.attrs.type === MentionType.User) {
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't be notified, as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (item.attrs.type === MentionType.Group) {
|
||||
const group = groups.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't be notified, as they do not have access to this document",
|
||||
`Members of "{{ groupName }}" that have access to this document will be notified`,
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
groupName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
icon: group ? <GroupAvatar group={group} /> : undefined,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, users, documentId]
|
||||
[t, users, documentId, groups]
|
||||
);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Prevent showing the menu until we have data otherwise it will be positioned
|
||||
@@ -234,15 +307,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
filterable={false}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
@@ -26,6 +27,18 @@ type Props = Omit<
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const items = useItems({ pastedText, embeds });
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!items) {
|
||||
props.onClose();
|
||||
return null;
|
||||
@@ -36,14 +49,7 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
@@ -96,11 +102,11 @@ function useItems({
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: crypto.randomUUID(),
|
||||
modelId: uuidv4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
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,
|
||||
@@ -17,7 +15,6 @@ import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
@@ -29,71 +26,33 @@ import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { EmbedLinkEditor } from "./EmbedLinkEditor";
|
||||
import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
|
||||
type Props = {
|
||||
/** Whether the text direction is right-to-left */
|
||||
rtl: boolean;
|
||||
/** Whether the current document is a template */
|
||||
isTemplate: boolean;
|
||||
/** Whether the toolbar is currently active/visible */
|
||||
isActive: boolean;
|
||||
/** The current selection */
|
||||
selection?: Selection;
|
||||
/** Whether the editor is in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** Whether the user has permission to add comments */
|
||||
canComment?: boolean;
|
||||
/** Whether the user has permission to update the document */
|
||||
canUpdate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
/** Callback function when a link is clicked */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
};
|
||||
|
||||
function useIsActive(state: EditorState) {
|
||||
const { selection, doc } = state;
|
||||
|
||||
if (isMarkActive(state.schema.marks.link)(state)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(isNodeActive(state.schema.nodes.code_block)(state) ||
|
||||
isNodeActive(state.schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === "hr") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
["image", "attachment", "embed"].includes(selection.node.type.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (selection instanceof NodeSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
|
||||
return some(nodes, (n) => n.content.size);
|
||||
}
|
||||
|
||||
function useIsDragging() {
|
||||
const [isDragging, setDragging, setNotDragging] = useBoolean();
|
||||
useEventListener("dragstart", setDragging);
|
||||
@@ -102,25 +61,19 @@ function useIsDragging() {
|
||||
return isDragging;
|
||||
}
|
||||
|
||||
export default function SelectionToolbar(props: Props) {
|
||||
const { onClose, readOnly, onOpen } = props;
|
||||
export function SelectionToolbar(props: Props) {
|
||||
const { readOnly = false } = props;
|
||||
const { view, commands } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useMobile();
|
||||
const isActive = useIsActive(view.state) || isMobile;
|
||||
const isActive = props.isActive || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
const previousIsActive = usePrevious(isActive);
|
||||
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Trigger callbacks when the toolbar is opened or closed
|
||||
if (previousIsActive && !isActive) {
|
||||
onClose();
|
||||
}
|
||||
if (!previousIsActive && isActive) {
|
||||
onOpen();
|
||||
}
|
||||
}, [isActive, onClose, onOpen, previousIsActive]);
|
||||
setIsEditingImgUrl(false);
|
||||
}, [isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
@@ -143,6 +96,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditingImgUrl(false);
|
||||
|
||||
const { dispatch } = view;
|
||||
dispatch(
|
||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||
@@ -154,7 +109,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
return () => {
|
||||
window.removeEventListener("mouseup", handleClickOutside);
|
||||
};
|
||||
}, [isActive, previousIsActive, readOnly, view]);
|
||||
}, [isActive, readOnly, view]);
|
||||
|
||||
const handleOnSelectLink = ({
|
||||
href,
|
||||
@@ -176,14 +131,14 @@ export default function SelectionToolbar(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
if (isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
@@ -205,19 +160,22 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
items = readOnly ? [] : getTableMenuItems(state, dictionary);
|
||||
items = getTableMenuItems(state, readOnly, dictionary);
|
||||
} else if (colIndex !== undefined) {
|
||||
items = readOnly
|
||||
? []
|
||||
: getTableColMenuItems(state, colIndex, rtl, dictionary);
|
||||
items = getTableColMenuItems(state, readOnly, dictionary, {
|
||||
index: colIndex,
|
||||
rtl,
|
||||
});
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
|
||||
items = getTableRowMenuItems(state, readOnly, dictionary, {
|
||||
index: rowIndex,
|
||||
});
|
||||
} else if (isImageSelection) {
|
||||
items = readOnly ? [] : getImageMenuItems(state, dictionary);
|
||||
items = getImageMenuItems(state, readOnly, dictionary);
|
||||
} else if (isAttachmentSelection) {
|
||||
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
|
||||
items = getAttachmentMenuItems(state, readOnly, dictionary);
|
||||
} else if (isDividerSelection) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
items = getDividerMenuItems(state, readOnly, dictionary);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
@@ -252,6 +210,9 @@ export default function SelectionToolbar(props: Props) {
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
|
||||
const isEditingMedia =
|
||||
isEmbedSelection || (isImageSelection && isEditingImgUrl);
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
@@ -270,15 +231,22 @@ export default function SelectionToolbar(props: Props) {
|
||||
onClickLink={props.onClickLink}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
/>
|
||||
) : isEmbedSelection ? (
|
||||
<EmbedLinkEditor
|
||||
) : isEditingMedia ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
node={(selection as NodeSelection).node}
|
||||
node={selection.node}
|
||||
view={view}
|
||||
dictionary={dictionary}
|
||||
autoFocus={isEditingImgUrl}
|
||||
/>
|
||||
) : (
|
||||
<ToolbarMenu items={items} {...rest} />
|
||||
<ToolbarMenu
|
||||
items={items}
|
||||
{...rest}
|
||||
handlers={{
|
||||
editImageUrl: () => setIsEditingImgUrl(true),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
|
||||
@@ -641,6 +641,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClick = () => {
|
||||
handleClickItem(item);
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
@@ -657,7 +661,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -92,4 +92,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export default SuggestionsMenuItem;
|
||||
export default React.memo(SuggestionsMenuItem);
|
||||
|
||||
@@ -20,15 +20,20 @@ import EventBoundary from "@shared/components/EventBoundary";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
handlers?: Record<string, (...args: any[]) => void>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Renders a dropdown menu in the floating toolbar.
|
||||
*/
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
function ToolbarDropdown(props: {
|
||||
active: boolean;
|
||||
item: MenuItem;
|
||||
handlers?: Record<string, Function>;
|
||||
}) {
|
||||
const { commands, view } = useEditor();
|
||||
const { t } = useTranslation();
|
||||
const { item } = props;
|
||||
const { item, handlers } = props;
|
||||
const { state } = view;
|
||||
|
||||
const items: TMenuItem[] = useMemo(() => {
|
||||
@@ -37,11 +42,19 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
return;
|
||||
}
|
||||
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (handlers && handlers[menuItem.name]) {
|
||||
handlers[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return item.children
|
||||
@@ -128,6 +141,7 @@ function ToolbarMenu(props: Props) {
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown
|
||||
handlers={props.handlers}
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -19,33 +18,32 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
clipboardTextSerializer: (slice) => {
|
||||
// Check if the only node is a code block
|
||||
const isSingleCodeBlock =
|
||||
slice.content.childCount === 1 &&
|
||||
(slice.content.firstChild?.type.name === "code_block" ||
|
||||
slice.content.firstChild?.type.name === "code_fence");
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
// Check if the only mark is a code mark
|
||||
const marks = new Set<string>();
|
||||
slice.content.descendants((node) => {
|
||||
node.marks.forEach((mark) => marks.add(mark.type.name));
|
||||
});
|
||||
const hasOnlyCodeMark =
|
||||
marks.size === 1 && marks.has("code_inline");
|
||||
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
// Use plain text serializer only for code-only content
|
||||
const usePlainText = isSingleCodeBlock || hasOnlyCodeMark;
|
||||
|
||||
return usePlainText
|
||||
? slice.content.content
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("");
|
||||
.join("")
|
||||
: mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Node, Slice } from "prosemirror-model";
|
||||
import {
|
||||
@@ -143,7 +144,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -188,7 +189,7 @@ export default class PasteHandler extends Extension {
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
label: collection.name,
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import some from "lodash/some";
|
||||
import { action, observable } from "mobx";
|
||||
import {
|
||||
EditorState,
|
||||
NodeSelection,
|
||||
Selection,
|
||||
Plugin,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { SelectionToolbar } from "../components/SelectionToolbar";
|
||||
|
||||
export default class SelectionToolbarExtension extends Extension {
|
||||
get name() {
|
||||
return "selection-toolbar";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [
|
||||
new Plugin({
|
||||
view: () => ({
|
||||
update: this.handleUpdate,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@observable
|
||||
state: Selection | boolean = false;
|
||||
|
||||
private handleUpdate = action((view: EditorView) => {
|
||||
const { state } = view;
|
||||
this.state = this.calculateState(state);
|
||||
});
|
||||
|
||||
private calculateState(state: EditorState): Selection | boolean {
|
||||
const { selection, doc, schema } = state;
|
||||
|
||||
if (isMarkActive(schema.marks.link)(state)) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "hr"
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
["image", "attachment", "embed"].includes(selection.node.type.name)
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
if (selection instanceof NodeSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
|
||||
if (some(nodes, (n) => n.content.size)) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
widget = (props: WidgetProps) => {
|
||||
const editorProps = this.editor.props;
|
||||
|
||||
return (
|
||||
<SelectionToolbar
|
||||
{...props}
|
||||
isActive={!!this.state}
|
||||
selection={this.state ? (this.state as Selection) : undefined}
|
||||
canUpdate={editorProps.canUpdate}
|
||||
canComment={editorProps.canComment}
|
||||
isTemplate={editorProps.template === true}
|
||||
onClickLink={editorProps.onClickLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
|
||||
const emdash = new InputRule(/(?:^|[^\|])(--\s)$/, "— ");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
|
||||
@@ -10,6 +10,7 @@ import Keys from "~/editor/extensions/Keys";
|
||||
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
|
||||
import PasteHandler from "~/editor/extensions/PasteHandler";
|
||||
import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
SelectionToolbarExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
|
||||
+56
-51
@@ -52,15 +52,16 @@ import Logger from "~/utils/Logger";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
|
||||
import WithTheme from "./components/WithTheme";
|
||||
import isNull from "lodash/isNull";
|
||||
import { map } from "lodash";
|
||||
import { isArray, map } from "lodash";
|
||||
import {
|
||||
LightboxImage,
|
||||
LightboxImageFactory,
|
||||
} from "@shared/editor/lib/Lightbox";
|
||||
import Lightbox from "~/components/Lightbox";
|
||||
import { anchorPlugin } from "@shared/editor/plugins/anchorPlugin";
|
||||
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
@@ -150,8 +151,6 @@ type State = {
|
||||
isRTL: boolean;
|
||||
/** If the editor is currently focused */
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** Image that's being currently viewed in Lightbox */
|
||||
activeLightboxImage: LightboxImage | null;
|
||||
};
|
||||
@@ -182,7 +181,6 @@ export class Editor extends React.PureComponent<
|
||||
state: State = {
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
activeLightboxImage: null,
|
||||
};
|
||||
|
||||
@@ -270,19 +268,12 @@ export class Editor extends React.PureComponent<
|
||||
this.calculateDir();
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
if (!this.isBlurred && !this.state.isEditorFocused) {
|
||||
this.isBlurred = true;
|
||||
this.props.onBlur?.();
|
||||
}
|
||||
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
|
||||
) {
|
||||
if (this.isBlurred && this.state.isEditorFocused) {
|
||||
this.isBlurred = false;
|
||||
this.props.onFocus?.();
|
||||
}
|
||||
@@ -416,6 +407,7 @@ export class Editor extends React.PureComponent<
|
||||
plugins: [
|
||||
...this.keymaps,
|
||||
...this.plugins,
|
||||
anchorPlugin(),
|
||||
dropCursor({
|
||||
color: this.props.theme.cursor,
|
||||
}),
|
||||
@@ -678,19 +670,36 @@ export class Editor extends React.PureComponent<
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markRemoved = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
if (markRemoved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
markRemoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(node.attrs?.marks)) {
|
||||
const existingMarks = node.attrs.marks;
|
||||
const updatedMarks = existingMarks.filter(
|
||||
(mark: any) => mark.attrs.id !== commentId
|
||||
);
|
||||
const attrs = {
|
||||
...node.attrs,
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -699,7 +708,7 @@ export class Editor extends React.PureComponent<
|
||||
/**
|
||||
* Update all marks related to a specific comment in the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param commentId The id of the comment to update
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (
|
||||
@@ -708,10 +717,11 @@ export class Editor extends React.PureComponent<
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markUpdated = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline) {
|
||||
return;
|
||||
if (markUpdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
@@ -725,9 +735,27 @@ export class Editor extends React.PureComponent<
|
||||
...mark.attrs,
|
||||
...attrs,
|
||||
});
|
||||
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(node.attrs?.marks)) {
|
||||
const existingMarks = node.attrs.marks;
|
||||
const updatedMarks = existingMarks.map((mark: any) =>
|
||||
mark.type === "comment" && mark.attrs.id === commentId
|
||||
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
|
||||
: mark
|
||||
);
|
||||
const newAttrs = {
|
||||
...node.attrs,
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -791,23 +819,6 @@ export class Editor extends React.PureComponent<
|
||||
return false;
|
||||
};
|
||||
|
||||
private handleOpenSelectionToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectionToolbarOpen: true,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseSelectionToolbar = () => {
|
||||
if (!this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
selectionToolbarOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
@@ -837,18 +848,7 @@ export class Editor extends React.PureComponent<
|
||||
ref={this.elementRef}
|
||||
lang=""
|
||||
/>
|
||||
{this.view && (
|
||||
<SelectionToolbar
|
||||
rtl={isRTL}
|
||||
readOnly={readOnly}
|
||||
canUpdate={this.props.canUpdate}
|
||||
canComment={this.props.canComment}
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onClickLink={this.props.onClickLink}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.widgets &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
@@ -880,10 +880,15 @@ const EditorContainer = styled(Styles)<{
|
||||
${(props) =>
|
||||
props.focusedCommentId &&
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
div.image-wrapper {
|
||||
outline: ${props.theme.commentMarkBackground} solid 2px;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function attachmentMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: "replaceAttachment",
|
||||
|
||||
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function dividerMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
|
||||
return [
|
||||
|
||||
@@ -6,16 +6,22 @@ import {
|
||||
AlignImageRightIcon,
|
||||
AlignImageCenterIcon,
|
||||
AlignFullWidthIcon,
|
||||
CommentIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
|
||||
export default function imageMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const isLeftAligned = isNodeActive(schema.nodes.image, {
|
||||
layoutClass: "left-50",
|
||||
@@ -75,14 +81,32 @@ export default function imageMenuItems(
|
||||
visible: !!fetch,
|
||||
},
|
||||
{
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "replaceImage",
|
||||
label: dictionary.uploadImage,
|
||||
},
|
||||
{
|
||||
name: "editImageUrl",
|
||||
label: dictionary.editImageUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "commentOnImage",
|
||||
tooltip: dictionary.comment,
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
const isFullWidth = isNodeActive(schema.nodes.table, {
|
||||
layout: TableLayout.fullWidth,
|
||||
|
||||
@@ -25,10 +25,18 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableColMenuItems(
|
||||
state: EditorState,
|
||||
index: number,
|
||||
rtl: boolean,
|
||||
dictionary: Dictionary
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary,
|
||||
options: {
|
||||
index: number;
|
||||
rtl: boolean;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index, rtl } = options;
|
||||
const { schema, selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
|
||||
@@ -19,9 +19,17 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
|
||||
export default function tableRowMenuItems(
|
||||
state: EditorState,
|
||||
index: number,
|
||||
dictionary: Dictionary
|
||||
readOnly: boolean,
|
||||
dictionary: Dictionary,
|
||||
options: {
|
||||
index: number;
|
||||
}
|
||||
): MenuItem[] {
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { index } = options;
|
||||
const { selection } = state;
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function useDictionary() {
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
editImageUrl: t("Edit image URL"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
createNewChildDoc: t("Create a new child doc"),
|
||||
@@ -108,6 +109,7 @@ export default function useDictionary() {
|
||||
untitled: t("Untitled"),
|
||||
none: t("None"),
|
||||
deleteEmbed: t("Delete embed"),
|
||||
uploadImage: t("Upload an image"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* Mouse position as a tuple of [x, y]
|
||||
*/
|
||||
type MousePosition = [number, number];
|
||||
|
||||
/**
|
||||
* Hook to get the current mouse position
|
||||
*
|
||||
* @returns Mouse position as a tuple of [x, y]
|
||||
*/
|
||||
export const useMousePosition = () => {
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>([0, 0]);
|
||||
|
||||
const updateMousePosition = useMemo(
|
||||
() => (ev: MouseEvent) => {
|
||||
setMousePosition([ev.clientX, ev.clientY]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEventListener("mousemove", updateMousePosition, undefined, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return mousePosition;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { PaginationParams } from "~/types";
|
||||
import useRequest from "./useRequest";
|
||||
|
||||
type RequestResponse<T> = {
|
||||
export type RequestResponse<T> = {
|
||||
/** The return value of the paginated request function. */
|
||||
data: T[] | undefined;
|
||||
/** The request error, if any. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, runInAction } from "mobx";
|
||||
import { action, comparer, computed, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
@@ -156,7 +156,7 @@ export default class Collection extends ParanoidModel {
|
||||
return this.sort.field === "index";
|
||||
}
|
||||
|
||||
@computed
|
||||
@computed({ equals: comparer.structural })
|
||||
get sortedDocuments(): NavigationNode[] | undefined {
|
||||
if (!this.documents) {
|
||||
return undefined;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { addDays, differenceInDays } from "date-fns";
|
||||
import i18n, { t } from "i18next";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import floor from "lodash/floor";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { action, autorun, comparer, computed, observable, set } from "mobx";
|
||||
import type {
|
||||
JSONObject,
|
||||
NavigationNode,
|
||||
@@ -89,6 +89,11 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return this.isDeleted || this.isArchived;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the original data source, if imported.
|
||||
*/
|
||||
@@ -647,7 +652,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
@computed({ equals: comparer.structural })
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
type: NavigationNodeType.Document,
|
||||
|
||||
+16
-1
@@ -3,8 +3,9 @@ import GroupMembership from "./GroupMembership";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
class Group extends Model {
|
||||
class Group extends Model implements Searchable {
|
||||
static modelName = "Group";
|
||||
|
||||
@Field
|
||||
@@ -17,6 +18,10 @@ class Group extends Model {
|
||||
@observable
|
||||
memberCount: number;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
disableMentions: boolean;
|
||||
|
||||
/**
|
||||
* Returns the users that are members of this group.
|
||||
*/
|
||||
@@ -26,6 +31,16 @@ class Group extends Model {
|
||||
return users.inGroup(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return this.disableMentions;
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins() {
|
||||
const { groupUsers } = this.store.rootStore;
|
||||
|
||||
@@ -122,6 +122,9 @@ class Notification extends Model {
|
||||
case NotificationEventType.MentionedInDocument:
|
||||
case NotificationEventType.MentionedInComment:
|
||||
return t("mentioned you in");
|
||||
case NotificationEventType.GroupMentionedInComment:
|
||||
case NotificationEventType.GroupMentionedInDocument:
|
||||
return t("mentioned your group in");
|
||||
case NotificationEventType.CreateComment:
|
||||
return t("left a comment on");
|
||||
case NotificationEventType.ResolveComment:
|
||||
@@ -177,9 +180,11 @@ class Notification extends Model {
|
||||
return collection ? collectionPath(collection.path) : "";
|
||||
}
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
case NotificationEventType.GroupMentionedInDocument:
|
||||
case NotificationEventType.MentionedInDocument: {
|
||||
return this.document?.path;
|
||||
}
|
||||
case NotificationEventType.GroupMentionedInComment:
|
||||
case NotificationEventType.MentionedInComment:
|
||||
case NotificationEventType.ResolveComment:
|
||||
case NotificationEventType.CreateComment:
|
||||
|
||||
@@ -105,6 +105,11 @@ class Share extends Model implements Searchable {
|
||||
return [this.title];
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@computed
|
||||
get sharedCache() {
|
||||
return (
|
||||
|
||||
@@ -68,6 +68,11 @@ class User extends ParanoidModel implements Searchable {
|
||||
return [this.name, this.email].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return this.isDeleted;
|
||||
}
|
||||
|
||||
@computed
|
||||
get initial(): string {
|
||||
return (this.name ? this.name[0] : "?").toUpperCase();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import pick from "lodash/pick";
|
||||
import { observable, action } from "mobx";
|
||||
import { observable, action, toJS } from "mobx";
|
||||
import { JSONObject } from "@shared/types";
|
||||
import type Store from "~/stores/base/Store";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { getFieldsForModel } from "../decorators/Field";
|
||||
import { LifecycleManager } from "../decorators/Lifecycle";
|
||||
import { getRelationsForModelClass } from "../decorators/Relation";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
export default abstract class Model {
|
||||
static modelName: string;
|
||||
@@ -147,6 +148,10 @@ export default abstract class Model {
|
||||
continue;
|
||||
}
|
||||
// @ts-expect-error TODO
|
||||
if (isEqual(toJS(this[key]), data[key])) {
|
||||
continue;
|
||||
}
|
||||
// @ts-expect-error TODO
|
||||
this[key] = data[key];
|
||||
} catch (error) {
|
||||
Logger.warn(`Error setting ${key} on model`, error);
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
export interface Searchable {
|
||||
/** The content to be used for search */
|
||||
get searchContent(): string | string[];
|
||||
|
||||
get searchSuppressed(): boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import Document from "~/models/Document";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Text from "~/components/Text";
|
||||
import { MeasuredContainer } from "~/components/MeasuredContainer";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -81,20 +82,22 @@ function Overview({ collection, shareId }: Props) {
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
<Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
onCreateLink={onCreateLink}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update || !!shareId}
|
||||
userId={user?.id}
|
||||
editorStyle={editorStyle}
|
||||
shareId={shareId}
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
<MeasuredContainer name="document">
|
||||
<Editor
|
||||
defaultValue={collection.data}
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
onCreateLink={onCreateLink}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update || !!shareId}
|
||||
userId={user?.id}
|
||||
editorStyle={editorStyle}
|
||||
shareId={shareId}
|
||||
/>
|
||||
<div ref={childRef} />
|
||||
</MeasuredContainer>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { m } from "framer-motion";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -160,7 +161,7 @@ function CommentForm({
|
||||
comments
|
||||
);
|
||||
|
||||
comment.id = crypto.randomUUID();
|
||||
comment.id = uuidv4();
|
||||
comments.add(comment);
|
||||
|
||||
comment
|
||||
|
||||
@@ -41,6 +41,7 @@ import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
import withStores from "~/components/withStores";
|
||||
import { MeasuredContainer } from "~/components/MeasuredContainer";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -54,7 +55,6 @@ import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import { MeasuredContainer } from "./MeasuredContainer";
|
||||
import Notices from "./Notices";
|
||||
import PublicReferences from "./PublicReferences";
|
||||
import References from "./References";
|
||||
@@ -417,6 +417,18 @@ class DocumentScene extends React.Component<Props> {
|
||||
void this.onSave();
|
||||
});
|
||||
|
||||
handleSelectTemplate = async (template: Document | Revision) => {
|
||||
const doc = this.editor.current?.view.state.doc;
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.replaceSelection(
|
||||
template,
|
||||
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
|
||||
);
|
||||
};
|
||||
|
||||
goBack = () => {
|
||||
if (!this.props.readOnly) {
|
||||
this.props.history.push({
|
||||
@@ -533,7 +545,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceSelection}
|
||||
onSelectTemplate={this.handleSelectTemplate}
|
||||
onSave={this.onSave}
|
||||
/>
|
||||
<Main
|
||||
|
||||
@@ -56,6 +56,7 @@ function Login({ children, onBack }: Props) {
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
const forceOTP = query.get("forceOTP");
|
||||
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
@@ -206,7 +207,7 @@ function Login({ children, onBack }: Props) {
|
||||
(provider) => provider.id === auth.lastSignedIn && !isCreate
|
||||
);
|
||||
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
|
||||
const preferOTP = isPWA;
|
||||
const preferOTP = isPWA || !!forceOTP;
|
||||
|
||||
if (firstRun) {
|
||||
return (
|
||||
@@ -324,6 +325,7 @@ function Login({ children, onBack }: Props) {
|
||||
<AuthenticationProvider
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
preferOTP={preferOTP}
|
||||
{...defaultProvider}
|
||||
/>
|
||||
{hasMultipleProviders && (
|
||||
@@ -348,6 +350,7 @@ function Login({ children, onBack }: Props) {
|
||||
key={provider.id}
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
preferOTP={preferOTP}
|
||||
neutral={defaultProvider && hasMultipleProviders}
|
||||
{...provider}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Client } from "@shared/types";
|
||||
import { isPWA } from "@shared/utils/browser";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import InputLarge from "~/components/InputLarge";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
@@ -17,6 +16,7 @@ type Props = React.ComponentProps<typeof ButtonLarge> & {
|
||||
authUrl: string;
|
||||
isCreate: boolean;
|
||||
onEmailSuccess: (email: string) => void;
|
||||
preferOTP: boolean;
|
||||
};
|
||||
|
||||
type AuthState = "initial" | "email" | "code";
|
||||
@@ -28,7 +28,6 @@ function AuthenticationProvider(props: Props) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
|
||||
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
|
||||
const preferOTP = isPWA;
|
||||
|
||||
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value);
|
||||
@@ -46,7 +45,7 @@ function AuthenticationProvider(props: Props) {
|
||||
const response = await client.post(event.currentTarget.action, {
|
||||
email,
|
||||
client: clientType,
|
||||
preferOTP,
|
||||
preferOTP: props.preferOTP,
|
||||
});
|
||||
|
||||
if (response.redirect) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -104,7 +105,7 @@ function Search() {
|
||||
// without a flash of loading.
|
||||
if (query) {
|
||||
searches.add({
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
query,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SmileyIcon,
|
||||
StarredIcon,
|
||||
UserIcon,
|
||||
GroupIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
@@ -70,6 +71,14 @@ function Notifications() {
|
||||
"Receive a notification when someone mentions you in a document or comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.GroupMentionedInDocument,
|
||||
icon: <GroupIcon />,
|
||||
title: t("Group mentions"),
|
||||
description: t(
|
||||
"Receive a notification when someone mentions a group you are a member of in a document or comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.ResolveComment,
|
||||
icon: <DoneIcon />,
|
||||
|
||||
@@ -29,6 +29,7 @@ import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelec
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -103,6 +104,9 @@ export function CreateGroupDialog() {
|
||||
export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [disableMentions, setDisableMentions] = React.useState(
|
||||
group.disableMentions || false
|
||||
);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
@@ -112,6 +116,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
disableMentions,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
@@ -120,7 +125,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name]
|
||||
[group, onSubmit, name, disableMentions]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
@@ -138,7 +143,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
@@ -148,6 +153,15 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Switch
|
||||
id="mentions"
|
||||
label={t("Disable mentions")}
|
||||
note={t(
|
||||
"Prevent this group from being mentionable in documents or comments"
|
||||
)}
|
||||
checked={disableMentions}
|
||||
onChange={setDisableMentions}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observable, action } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as React from "react";
|
||||
|
||||
type DialogDefinition = {
|
||||
@@ -65,7 +66,7 @@ export default class DialogsStore {
|
||||
this.modalStack.clear();
|
||||
}
|
||||
|
||||
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
|
||||
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
|
||||
title,
|
||||
content,
|
||||
style,
|
||||
|
||||
@@ -37,6 +37,9 @@ export default class PinsStore extends Store<Pin> {
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -34,6 +34,9 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, options);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
|
||||
@@ -86,6 +86,9 @@ class UiStore {
|
||||
@observable
|
||||
multiplayerErrorCode?: number;
|
||||
|
||||
@observable
|
||||
debugSafeArea = false;
|
||||
|
||||
rootStore: RootStore;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
@@ -248,6 +251,11 @@ class UiStore {
|
||||
this.mobileSidebarVisible = false;
|
||||
};
|
||||
|
||||
@action
|
||||
toggleDebugSafeArea = () => {
|
||||
this.debugSafeArea = !this.debugSafeArea;
|
||||
};
|
||||
|
||||
@computed
|
||||
get readyToShow() {
|
||||
return (
|
||||
|
||||
@@ -101,24 +101,13 @@ export default abstract class Store<T extends Model> {
|
||||
|
||||
if (!normalized) {
|
||||
return this.orderedData
|
||||
.filter((item) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("archivedAt" in item && item.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter((item: T & Searchable) => !item.searchSuppressed)
|
||||
.slice(0, options?.maxResults);
|
||||
}
|
||||
|
||||
return this.orderedData
|
||||
.filter((item: T & Searchable) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("archivedAt" in item && item.archivedAt) {
|
||||
if (item.searchSuppressed) {
|
||||
return false;
|
||||
}
|
||||
if ("searchContent" in item) {
|
||||
|
||||
Vendored
-1
@@ -150,7 +150,6 @@ declare module "styled-components" {
|
||||
menuItemSelected: string;
|
||||
menuBackground: string;
|
||||
menuShadow: string;
|
||||
menuBorder?: string;
|
||||
divider: string;
|
||||
titleBarDivider: string;
|
||||
inputBorder: string;
|
||||
|
||||
+21
-20
@@ -41,7 +41,7 @@
|
||||
"url": "https://github.com/sponsors/outline"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || 22"
|
||||
"node": ">=20.12 <21 || 22"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -51,18 +51,18 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.908.0",
|
||||
"@aws-sdk/lib-storage": "3.908.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.908.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.908.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.908.0",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@aws-sdk/client-s3": "3.917.0",
|
||||
"@aws-sdk/lib-storage": "3.917.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.917.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.917.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.916.0",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.0",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.5",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.4",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.13.0",
|
||||
@@ -149,14 +149,14 @@
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.7.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"kbar": "0.1.0-beta.48",
|
||||
"koa": "^3.0.1",
|
||||
"koa": "^3.0.3",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-compress": "^5.1.1",
|
||||
"koa-helmet": "^6.1.0",
|
||||
@@ -181,13 +181,13 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.7",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.13.0",
|
||||
"outline-icons": "^3.13.1",
|
||||
"oy-vey": "^0.12.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -262,7 +262,8 @@
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"validator": "13.15.15",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "13.15.20",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
@@ -277,7 +278,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
@@ -329,7 +330,7 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.8",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/readable-stream": "^4.0.22",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
"@types/refractor": "^3.4.1",
|
||||
"@types/resolve-path": "^1.4.3",
|
||||
@@ -350,7 +351,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.38.20",
|
||||
"discord-api-types": "^0.38.30",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
@@ -363,7 +364,7 @@
|
||||
"oxlint-tsgolint": "^0.1.6",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "2.1.6",
|
||||
"terser": "^5.43.1",
|
||||
@@ -381,6 +382,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.87.4",
|
||||
"version": "1.0.1",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ router.post(
|
||||
}
|
||||
|
||||
// Generate both a link token and a 6-digit verification code
|
||||
const token = preferOTP ? undefined : user.getEmailSigninToken();
|
||||
const token = preferOTP ? undefined : user.getEmailSigninToken(ctx);
|
||||
const verificationCode = preferOTP
|
||||
? await user.getEmailVerificationCode()
|
||||
: undefined;
|
||||
@@ -131,7 +131,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
user = await getUserForEmailSigninToken(token as string);
|
||||
user = await getUserForEmailSigninToken(ctx, token as string);
|
||||
} else if (code && email) {
|
||||
user = await User.scope("withTeam").findOne({
|
||||
rejectOnEmpty: true,
|
||||
@@ -150,16 +150,18 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
// Delete the code after successful verification
|
||||
await VerificationCode.delete(email);
|
||||
} else {
|
||||
ctx.redirect("/?notice=auth-error");
|
||||
ctx.redirect("/?notice=auth-error&description=Missing%20token");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.debug("authentication", err);
|
||||
return ctx.redirect("/?notice=auth-error");
|
||||
return ctx.redirect(`/?notice=auth-error&description=${err.message}`);
|
||||
}
|
||||
|
||||
if (!user.team.emailSigninEnabled) {
|
||||
return ctx.redirect("/?notice=auth-error");
|
||||
return ctx.redirect(
|
||||
"/?notice=auth-error&description=Disabled%20signin%20method"
|
||||
);
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
@@ -195,13 +197,13 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
};
|
||||
router.get(
|
||||
"email.callback",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
rateLimiter(RateLimiterStrategy.FivePerMinute),
|
||||
validate(T.EmailCallbackSchema),
|
||||
emailCallback
|
||||
);
|
||||
router.post(
|
||||
"email.callback",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
rateLimiter(RateLimiterStrategy.FivePerMinute),
|
||||
validate(T.EmailCallbackSchema),
|
||||
emailCallback
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
export const Translations = () => (
|
||||
<>
|
||||
<Trans defaults={`New attribute`} />
|
||||
<Trans defaults={`Paper size`} />
|
||||
<Trans defaults={`Ask AI "{{question}}"`} />
|
||||
<Trans defaults={`Are you sure you want to delete?`} />
|
||||
<Trans defaults={`Deleting this version of the document will permanently and irrevocably remove it from the history.`} />
|
||||
<Trans defaults={`Format`} />
|
||||
<Trans defaults={`Add option`} />
|
||||
<Trans defaults={`Optional`} />
|
||||
<Trans defaults={`Choose a size for your exported document`} />
|
||||
<Trans defaults={`Revision renamed`} />
|
||||
<Trans defaults={`Failed to save revision`} />
|
||||
<Trans defaults={`Invite to document`} />
|
||||
<Trans defaults={`Sorry, invalid embed link`} />
|
||||
<Trans defaults={`Data Attributes`} />
|
||||
<Trans defaults={`Edit attribute`} />
|
||||
<Trans defaults={`Property`} />
|
||||
<Trans defaults={`Yes`} />
|
||||
<Trans defaults={`No`} />
|
||||
<Trans defaults={`Search or ask a question`} />
|
||||
<Trans defaults={`Invited {{roleName}} will not receive access to any collections or documents unless explicitly shared.`} />
|
||||
<Trans defaults={`Can view only what is explicitly shared`} />
|
||||
<Trans defaults={`SAML assertion was invalid or missing fields, please check your configuration`} />
|
||||
<Trans defaults={`AI generated answer based on related documents in your workspace`} />
|
||||
<Trans defaults={`References`} />
|
||||
<Trans defaults={`Enable AI answers to get direct answers to searched questions.`} />
|
||||
<Trans defaults={`Go to settings`} />
|
||||
<Trans defaults={`Where do I find the file?`} />
|
||||
<Trans defaults={`In a Confluence space, navigate to <em>Space Settings -> Manage space -> Export space</em> and choose to export as HTML with the "Normal Export" option.`} />
|
||||
<Trans defaults={`Drag and drop the zip file from Confluence's HTML export option, or click to upload`} />
|
||||
<Trans defaults={`Guests`} />
|
||||
<Trans defaults={`New Attribute`} />
|
||||
<Trans defaults={`Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.`} />
|
||||
<Trans defaults={`Custom domain`} />
|
||||
<Trans defaults={`AI answers`} />
|
||||
<Trans defaults={`Use AI to directly answer searched questions using content in your workspace.`} />
|
||||
<Trans defaults={`API access`} />
|
||||
<Trans defaults={`Allow members to create API keys for programmatic access`} />
|
||||
<Trans defaults={`Public document embedding`} />
|
||||
<Trans defaults={`When enabled, publicly shared documents can be embedded in third-party websites`} />
|
||||
<Trans defaults={`Include previews in emails`} />
|
||||
<Trans defaults={`When enabled, email notifications will include content previews`} />
|
||||
<Trans defaults={`Boolean`} />
|
||||
<Trans defaults={`Number`} />
|
||||
<Trans defaults={`Text`} />
|
||||
<Trans defaults={`List`} />
|
||||
<Trans defaults={`Could not load events`} />
|
||||
<Trans defaults={`Audit Log`} />
|
||||
<Trans defaults={`The audit log details the history of security related and other events across your knowledge base.`} />
|
||||
<Trans defaults={`IP address`} />
|
||||
<Trans defaults={`Actor`} />
|
||||
<Trans defaults={`Event`} />
|
||||
<Trans defaults={`Timestamp`} />
|
||||
<Trans defaults={`IP`} />
|
||||
<Trans defaults={`a group`} />
|
||||
<Trans defaults={`All users`} />
|
||||
<Trans defaults={`Private`} />
|
||||
<Trans defaults={`View and edit`} />
|
||||
<Trans defaults={`Sharing enabled`} />
|
||||
<Trans defaults={`Date archived`} />
|
||||
<Trans defaults={`Could not load collections`} />
|
||||
<Trans defaults={`Manage the permissions and settings of all collections in the knowledge base. As a workspace admin you can also administer private collections.`} />
|
||||
<Trans defaults={`Automatically index and search document content from {{appName}} inside <4>Glean</4> in realtime.`} />
|
||||
<Trans defaults={`API Endpoint`} />
|
||||
<Trans defaults={`API Secret`} />
|
||||
<Trans defaults={`Datasource`} />
|
||||
<Trans defaults={`Details of the current {{appName}} license. To arrange contract renewal as expiry or seat limits approach or increase licensed seats please contact your account manager or email <4>priority@getoutline.com</4>.`} />
|
||||
<Trans defaults={`Sorry, an answer could not be found in the collection, try widening your search.`} />
|
||||
<Trans defaults={`Sorry, an answer could not be found in the workspace, try widening your search.`} />
|
||||
<Trans defaults={`Looking for answers`} />
|
||||
<Trans defaults={`Answer to "{{ query }}"`} />
|
||||
</>
|
||||
)
|
||||
@@ -13,6 +13,8 @@ import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { LinearUtils } from "../shared/LinearUtils";
|
||||
import env from "./env";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { opts } from "@server/utils/i18n";
|
||||
import { t } from "i18next";
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
@@ -111,18 +113,25 @@ export class Linear {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = (await Integration.scope("withAuthentication").findOne({
|
||||
where: {
|
||||
service: IntegrationService.Linear,
|
||||
teamId: actor.teamId,
|
||||
"settings.linear.workspace.key": resource.workspaceKey,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed>;
|
||||
const integrations = (await Integration.scope("withAuthentication").findAll(
|
||||
{
|
||||
where: {
|
||||
service: IntegrationService.Linear,
|
||||
teamId: actor.teamId,
|
||||
},
|
||||
}
|
||||
)) as Integration<IntegrationType.Embed>[];
|
||||
|
||||
if (!integration) {
|
||||
if (integrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer integration with matching workspaceKey, otherwise pick the first one
|
||||
const integration =
|
||||
integrations.find(
|
||||
(int) => int.settings.linear?.workspace.key === resource.workspaceKey
|
||||
) ?? integrations[0];
|
||||
|
||||
try {
|
||||
const accessToken = await integration.authentication.refreshTokenIfNeeded(
|
||||
async (refreshToken: string) => Linear.refreshToken(refreshToken),
|
||||
@@ -142,7 +151,7 @@ export class Linear {
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!author || !state || !labels) {
|
||||
if (!state || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
@@ -159,8 +168,12 @@ export class Linear {
|
||||
title: issue.title,
|
||||
description: issue.description ?? null,
|
||||
author: {
|
||||
name: author.name,
|
||||
avatarUrl: author.avatarUrl ?? "",
|
||||
name:
|
||||
author?.name ??
|
||||
issue.botActor?.userDisplayName ??
|
||||
issue.botActor?.name ??
|
||||
t("Unknown", opts(actor)),
|
||||
avatarUrl: author?.avatarUrl ?? "",
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +1,6 @@
|
||||
import { Event, Document, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
@@ -85,7 +86,13 @@ export default async function documentUpdater(
|
||||
document.insightsEnabled = insightsEnabled;
|
||||
}
|
||||
if (text !== undefined) {
|
||||
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
|
||||
document = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
await TextHelper.replaceImagesWithAttachments(ctx, text, user, {
|
||||
base64Only: true,
|
||||
}),
|
||||
append
|
||||
);
|
||||
}
|
||||
|
||||
const changed = document.changed();
|
||||
|
||||
@@ -108,7 +108,7 @@ export default async function userInviter(
|
||||
"email",
|
||||
`Sign in immediately: ${
|
||||
env.URL
|
||||
}/auth/email.callback?token=${newUser.getEmailSigninToken()}`
|
||||
}/auth/email.callback?token=${newUser.getEmailSigninToken(ctx)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import addressparser, { EmailAddress } from "addressparser";
|
||||
import Bull from "bull";
|
||||
import invariant from "invariant";
|
||||
import { subMinutes } from "date-fns";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { randomString } from "@shared/random";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
@@ -143,12 +144,21 @@ export default abstract class BaseEmail<
|
||||
? await Notification.emailReferences(notification)
|
||||
: undefined;
|
||||
|
||||
// Check if notification is considerably delayed and annotate
|
||||
// the subject. This is incase of extended downtime or queue backlogs
|
||||
let subject = this.subject(data);
|
||||
if (notification) {
|
||||
if (notification.createdAt < subMinutes(new Date(), 30)) {
|
||||
subject = `Delayed notification: ${subject}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mailer.sendMail({
|
||||
to: this.props.to,
|
||||
replyTo: this.replyTo?.(data),
|
||||
from: this.from(data),
|
||||
subject: this.subject(data),
|
||||
subject,
|
||||
messageId,
|
||||
references,
|
||||
previewText: this.preview(data),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as React from "react";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Collection, Comment, Document, Group } from "@server/models";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { can } from "@server/policies";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type InputProps = EmailProps & {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
documentId: string;
|
||||
actorName: string;
|
||||
commentId: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
collection: Collection;
|
||||
body: string | undefined;
|
||||
unsubscribeUrl: string;
|
||||
groupName: string;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a user when they are a member of a group mentioned in a comment.
|
||||
*/
|
||||
export default class GroupCommentMentionedEmail extends BaseEmail<
|
||||
InputProps,
|
||||
BeforeSend
|
||||
> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend(props: InputProps) {
|
||||
const { documentId, commentId, groupId } = props;
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = await document.$get("collection");
|
||||
if (!collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [comment, team] = await Promise.all([
|
||||
Comment.findByPk(commentId),
|
||||
document.$get("team"),
|
||||
]);
|
||||
if (!comment || !team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await this.htmlForData(
|
||||
team,
|
||||
ProsemirrorHelper.toProsemirror(comment.data)
|
||||
);
|
||||
|
||||
return {
|
||||
document,
|
||||
collection,
|
||||
body,
|
||||
groupName: group.name,
|
||||
unsubscribeUrl: this.unsubscribeUrl(props),
|
||||
};
|
||||
}
|
||||
|
||||
protected unsubscribeUrl({ userId }: InputProps) {
|
||||
return NotificationSettingsHelper.unsubscribeUrl(
|
||||
userId,
|
||||
NotificationEventType.GroupMentionedInComment
|
||||
);
|
||||
}
|
||||
|
||||
protected replyTo({ notification }: Props) {
|
||||
if (notification?.user && notification.actor?.email) {
|
||||
if (can(notification.user, "readEmail", notification.actor)) {
|
||||
return notification.actor.email;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
protected subject({ document, groupName }: Props) {
|
||||
return `The ${groupName} group was mentioned in “${document.titleWithDefault}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName, groupName }: Props): string {
|
||||
return `${actorName} mentioned the "${groupName}" group in a thread`;
|
||||
}
|
||||
|
||||
protected fromName({ actorName }: Props): string {
|
||||
return actorName;
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
actorName,
|
||||
teamUrl,
|
||||
document,
|
||||
commentId,
|
||||
collection,
|
||||
groupName,
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} mentioned the "${groupName}" group in a comment on "${document.titleWithDefault}"${
|
||||
collection.name ? ` in the ${collection.name} collection` : ""
|
||||
}.
|
||||
|
||||
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(props: Props) {
|
||||
const {
|
||||
document,
|
||||
collection,
|
||||
actorName,
|
||||
teamUrl,
|
||||
commentId,
|
||||
unsubscribeUrl,
|
||||
body,
|
||||
groupName,
|
||||
} = props;
|
||||
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate
|
||||
previewText={this.preview(props)}
|
||||
goToAction={{ url: threadLink, name: "View Thread" }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} mentioned the "{groupName}" group in a comment on{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
{collection.name ? ` in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={threadLink}>Open Thread</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import * as React from "react";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { Document, Revision, Group } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { can } from "@server/policies";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type InputProps = EmailProps & {
|
||||
documentId: string;
|
||||
revisionId: string | undefined;
|
||||
actorName: string;
|
||||
teamUrl: string;
|
||||
groupId: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
groupName: string;
|
||||
body: string | undefined;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a user when they are a member of a group mentioned in a document.
|
||||
*/
|
||||
export default class GroupDocumentMentionedEmail extends BaseEmail<
|
||||
InputProps,
|
||||
BeforeSend
|
||||
> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend({ documentId, revisionId, groupId }: InputProps) {
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const team = await document.$get("team");
|
||||
if (!team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let currDoc: Document | Revision = document;
|
||||
let prevDoc: Revision | undefined;
|
||||
|
||||
if (revisionId) {
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
if (!revision) {
|
||||
return false;
|
||||
}
|
||||
currDoc = revision;
|
||||
prevDoc = (await revision.before()) ?? undefined;
|
||||
}
|
||||
|
||||
const currMentions = DocumentHelper.parseMentions(currDoc, {
|
||||
type: MentionType.Group,
|
||||
modelId: groupId,
|
||||
});
|
||||
const prevMentions = prevDoc
|
||||
? DocumentHelper.parseMentions(prevDoc, {
|
||||
type: MentionType.Group,
|
||||
modelId: groupId,
|
||||
})
|
||||
: [];
|
||||
|
||||
const firstNewMention = differenceBy(currMentions, prevMentions, "id")[0];
|
||||
|
||||
let body: string | undefined;
|
||||
|
||||
if (firstNewMention) {
|
||||
const node = ProsemirrorHelper.getNodeForMentionEmail(
|
||||
DocumentHelper.toProsemirror(currDoc),
|
||||
firstNewMention
|
||||
);
|
||||
if (node) {
|
||||
body = await this.htmlForData(team, node);
|
||||
}
|
||||
}
|
||||
|
||||
return { document, body, groupName: group.name };
|
||||
}
|
||||
|
||||
protected subject({ document, groupName }: Props) {
|
||||
return `The ${groupName} group was mentioned in “${document.titleWithDefault}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName, groupName }: Props): string {
|
||||
return `${actorName} mentioned the "${groupName}" group`;
|
||||
}
|
||||
|
||||
protected fromName({ actorName }: Props) {
|
||||
return actorName;
|
||||
}
|
||||
|
||||
protected replyTo({ notification }: Props) {
|
||||
if (notification?.user && notification.actor?.email) {
|
||||
if (can(notification.user, "readEmail", notification.actor)) {
|
||||
return notification.actor.email;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
actorName,
|
||||
teamUrl,
|
||||
document,
|
||||
groupName,
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} mentioned the “${groupName}” group in the document “${document.titleWithDefault}”.
|
||||
|
||||
Open Document: ${teamUrl}${document.url}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(props: Props) {
|
||||
const { document, actorName, teamUrl, body, groupName } = props;
|
||||
const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate
|
||||
previewText={this.preview(props)}
|
||||
goToAction={{ url: documentLink, name: "View Document" }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>Your group was mentioned</Heading>
|
||||
<p>
|
||||
{actorName} mentioned the "{groupName}" group in the document{" "}
|
||||
<a href={documentLink}>{document.titleWithDefault}</a>.
|
||||
</p>
|
||||
{body && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={documentLink}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -6,7 +6,7 @@ import "./logging/tracer"; // must come before importing any instrumented module
|
||||
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import Koa from "koa";
|
||||
import Koa, { Context } from "koa";
|
||||
import helmet from "koa-helmet";
|
||||
import logger from "koa-logger";
|
||||
import Router from "koa-router";
|
||||
@@ -90,6 +90,7 @@ async function start(_id: number, disconnect: () => void) {
|
||||
|
||||
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
|
||||
app.context.redirectOnClient = function (
|
||||
this: Context,
|
||||
/** The URL to redirect to */
|
||||
url: string,
|
||||
/**
|
||||
@@ -113,6 +114,13 @@ async function start(_id: number, disconnect: () => void) {
|
||||
)}" value="${escape(value)}" />`;
|
||||
});
|
||||
|
||||
if (this.userAgent.isBot) {
|
||||
formFields += `
|
||||
<p>If you are not redirected automatically, please click the button below.</p>
|
||||
<input type="submit" value="Continue" />
|
||||
`;
|
||||
}
|
||||
|
||||
this.body = `
|
||||
<html>
|
||||
<head>
|
||||
@@ -123,7 +131,7 @@ async function start(_id: number, disconnect: () => void) {
|
||||
${formFields}
|
||||
</form>
|
||||
<script nonce="${this.state.cspNonce}">
|
||||
document.getElementById('redirect-form').submit();
|
||||
${!this.userAgent.isBot} && document.getElementById('redirect-form').submit();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("notifications", "groupId", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "groups",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("notifications", "groupId");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("groups", "disableMentions", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("groups", "disableMentions");
|
||||
},
|
||||
};
|
||||
@@ -68,6 +68,9 @@ class Group extends ParanoidModel<
|
||||
@Column
|
||||
externalId: string;
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
disableMentions: boolean;
|
||||
|
||||
static filterByMember(userId: string | undefined) {
|
||||
return userId
|
||||
? this.scope({ method: ["withMembership", userId] })
|
||||
|
||||
@@ -81,6 +81,36 @@ describe("Notification", () => {
|
||||
expect(references?.length).toBe(1);
|
||||
expect(references![0]).toBe(expectedReference);
|
||||
});
|
||||
|
||||
it("group mentioned in document", async () => {
|
||||
const document = await buildDocument();
|
||||
const notification = await buildNotification({
|
||||
event: NotificationEventType.GroupMentionedInDocument,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const references = await Notification.emailReferences(notification);
|
||||
const expectedReference = Notification.emailMessageId(
|
||||
`${document.id}-group-mentions`
|
||||
);
|
||||
expect(references?.length).toBe(1);
|
||||
expect(references![0]).toBe(expectedReference);
|
||||
});
|
||||
|
||||
it("group mentioned in comment", async () => {
|
||||
const document = await buildDocument();
|
||||
const notification = await buildNotification({
|
||||
event: NotificationEventType.GroupMentionedInComment,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const references = await Notification.emailReferences(notification);
|
||||
const expectedReference = Notification.emailMessageId(
|
||||
`${document.id}-group-mentions`
|
||||
);
|
||||
expect(references?.length).toBe(1);
|
||||
expect(references![0]).toBe(expectedReference);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should return comment reference", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import Event from "./Event";
|
||||
import Revision from "./Revision";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import Group from "./Group";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
let baseDomain;
|
||||
@@ -128,6 +129,13 @@ class Notification extends Model<
|
||||
event: NotificationEventType;
|
||||
|
||||
// associations
|
||||
@BelongsTo(() => Group, "groupId")
|
||||
group: Group;
|
||||
|
||||
@AllowNull
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
groupId: string;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
@@ -202,6 +210,7 @@ class Notification extends Model<
|
||||
collectionId: model.collectionId,
|
||||
actorId: model.actorId,
|
||||
membershipId: model.membershipId,
|
||||
groupId: model.groupId,
|
||||
};
|
||||
|
||||
if (options.transaction) {
|
||||
@@ -260,6 +269,10 @@ class Notification extends Model<
|
||||
case NotificationEventType.UpdateDocument:
|
||||
name = `${notification.documentId}-updates`;
|
||||
break;
|
||||
case NotificationEventType.GroupMentionedInComment:
|
||||
case NotificationEventType.GroupMentionedInDocument:
|
||||
name = `${notification.documentId}-group-mentions`;
|
||||
break;
|
||||
case NotificationEventType.MentionedInDocument:
|
||||
case NotificationEventType.MentionedInComment:
|
||||
name = `${notification.documentId}-mentions`;
|
||||
|
||||
@@ -588,10 +588,11 @@ class User extends ParanoidModel<
|
||||
*
|
||||
* @returns The email signin token
|
||||
*/
|
||||
getEmailSigninToken = () =>
|
||||
getEmailSigninToken = (ctx: Context) =>
|
||||
JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
ip: ctx.request.ip,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "email-signin",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import parseImages from "@server/utils/parseImages";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
|
||||
@trace()
|
||||
export class TextHelper {
|
||||
@@ -65,7 +66,11 @@ export class TextHelper {
|
||||
static async replaceImagesWithAttachments(
|
||||
ctx: APIContext,
|
||||
markdown: string,
|
||||
user: User
|
||||
user: User,
|
||||
options: {
|
||||
/** If true, only process base64 encoded images */
|
||||
base64Only?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
let output = markdown;
|
||||
const images = parseImages(markdown);
|
||||
@@ -84,6 +89,13 @@ export class TextHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(image.src)) {
|
||||
return;
|
||||
}
|
||||
if (options.base64Only && !image.src.startsWith("data:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = await attachmentCreator({
|
||||
name: image.alt ?? "image",
|
||||
url: image.src,
|
||||
|
||||
@@ -6,6 +6,7 @@ export default async function presentGroup(group: Group) {
|
||||
name: group.name,
|
||||
externalId: group.externalId,
|
||||
memberCount: await group.memberCount,
|
||||
disableMentions: group.disableMentions,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { dateLocale } from "@shared/utils/date";
|
||||
import { Document, User, View } from "@server/models";
|
||||
import { Document, User, View, Group } from "@server/models";
|
||||
import { opts } from "@server/utils/i18n";
|
||||
|
||||
async function presentUnfurl(
|
||||
@@ -12,6 +12,8 @@ async function presentUnfurl(
|
||||
switch (data.type) {
|
||||
case UnfurlResourceType.Mention:
|
||||
return presentMention(data, options);
|
||||
case UnfurlResourceType.Group:
|
||||
return presentGroup(data);
|
||||
case UnfurlResourceType.Document:
|
||||
return presentDocument(data);
|
||||
case UnfurlResourceType.PR:
|
||||
@@ -54,6 +56,25 @@ const presentMention = async (
|
||||
};
|
||||
};
|
||||
|
||||
const presentGroup = async (
|
||||
data: Record<string, any>
|
||||
): Promise<UnfurlResponse[UnfurlResourceType.Group]> => {
|
||||
const group: Group = data.group;
|
||||
const memberCount = await group.memberCount;
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Group,
|
||||
name: group.name,
|
||||
memberCount,
|
||||
users: (data.users as User[]).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
color: user.color,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const presentDocument = (
|
||||
data: Record<string, any>
|
||||
): UnfurlResponse[UnfurlResourceType.Document] => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
|
||||
import { Notification } from "@server/models";
|
||||
import { Event, NotificationEvent } from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
import GroupDocumentMentionedEmail from "@server/emails/templates/GroupDocumentMentionedEmail";
|
||||
import GroupCommentMentionedEmail from "@server/emails/templates/GroupCommentMentionedEmail";
|
||||
|
||||
export default class EmailsProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["notifications.create"];
|
||||
@@ -83,6 +85,21 @@ export default class EmailsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
case NotificationEventType.GroupMentionedInDocument: {
|
||||
await new GroupDocumentMentionedEmail(
|
||||
{
|
||||
to: notification.user.email,
|
||||
documentId: notification.documentId,
|
||||
revisionId: notification.revisionId,
|
||||
groupId: notification.groupId,
|
||||
teamUrl: notification.team.url,
|
||||
actorName: notification.actor.name,
|
||||
},
|
||||
{ notificationId }
|
||||
).schedule();
|
||||
return;
|
||||
}
|
||||
|
||||
case NotificationEventType.MentionedInDocument: {
|
||||
// No need to delay email here as the notification itself is already delayed
|
||||
await new DocumentMentionedEmail(
|
||||
@@ -99,6 +116,24 @@ export default class EmailsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
case NotificationEventType.GroupMentionedInComment: {
|
||||
await new GroupCommentMentionedEmail(
|
||||
{
|
||||
to: notification.user.email,
|
||||
userId: notification.userId,
|
||||
documentId: notification.documentId,
|
||||
teamUrl: notification.team.url,
|
||||
actorName: notification.actor.name,
|
||||
commentId: notification.commentId,
|
||||
groupId: notification.groupId,
|
||||
},
|
||||
{ notificationId }
|
||||
).schedule({
|
||||
delay: Minute.ms,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case NotificationEventType.MentionedInComment: {
|
||||
await new CommentMentionedEmail(
|
||||
{
|
||||
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
} from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import {
|
||||
Comment,
|
||||
Document,
|
||||
Group,
|
||||
GroupUser,
|
||||
Notification,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -77,6 +84,65 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications to users in mentioned groups
|
||||
const groupMentions = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{
|
||||
type: MentionType.Group,
|
||||
}
|
||||
);
|
||||
|
||||
const mentionedGroup: string[] = [];
|
||||
for (const group of groupMentions) {
|
||||
if (mentionedGroup.includes(group.modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the group has mentions disabled
|
||||
const groupModel = await Group.findByPk(group.modelId);
|
||||
if (groupModel?.disableMentions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usersFromMentionedGroup = await GroupUser.findAll({
|
||||
where: {
|
||||
groupId: group.modelId,
|
||||
},
|
||||
order: [["permission", "ASC"]],
|
||||
});
|
||||
|
||||
const mentionedUser: string[] = [];
|
||||
for (const user of usersFromMentionedGroup) {
|
||||
if (mentionedUser.includes(user.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(user.userId);
|
||||
if (
|
||||
recipient &&
|
||||
recipient.id !== group.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.GroupMentionedInComment
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.GroupMentionedInComment,
|
||||
groupId: group.modelId,
|
||||
userId: recipient.id,
|
||||
actorId: group.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
mentionedUser.push(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
mentionedGroup.push(group.modelId);
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import invariant from "invariant";
|
||||
import { Op } from "sequelize";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import {
|
||||
Comment,
|
||||
Document,
|
||||
Group,
|
||||
GroupUser,
|
||||
Notification,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { CommentEvent, CommentUpdateEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
@@ -69,6 +76,62 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
|
||||
userIdsMentioned.push(mention.modelId);
|
||||
}
|
||||
}
|
||||
|
||||
const groupMentions = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{ type: MentionType.Group }
|
||||
).filter((mention) => newMentionIds.includes(mention.id));
|
||||
|
||||
const mentionedGroup: string[] = [];
|
||||
for (const group of groupMentions) {
|
||||
if (mentionedGroup.includes(group.modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the group has mentions disabled
|
||||
const groupModel = await Group.findByPk(group.modelId);
|
||||
if (groupModel?.disableMentions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usersFromMentionedGroup = await GroupUser.findAll({
|
||||
where: {
|
||||
groupId: group.modelId,
|
||||
},
|
||||
order: [["permission", "ASC"]],
|
||||
});
|
||||
|
||||
const mentionedUser: string[] = [];
|
||||
for (const user of usersFromMentionedGroup) {
|
||||
if (mentionedUser.includes(user.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(user.userId);
|
||||
if (
|
||||
recipient &&
|
||||
recipient.id !== group.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.GroupMentionedInComment
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.GroupMentionedInComment,
|
||||
groupId: group.modelId,
|
||||
userId: recipient.id,
|
||||
actorId: group.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
|
||||
mentionedUser.push(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
mentionedGroup.push(group.modelId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResolvedComment(event: CommentUpdateEvent) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
|
||||
import { Document, Notification, User } from "@server/models";
|
||||
import { Document, Group, Notification, User, GroupUser } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { DocumentEvent } from "@server/types";
|
||||
@@ -50,6 +50,60 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications to users in mentioned groups
|
||||
const groupMentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.Group,
|
||||
});
|
||||
const mentionedGroup: string[] = [];
|
||||
for (const group of groupMentions) {
|
||||
if (mentionedGroup.includes(group.modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the group has mentions disabled
|
||||
const groupModel = await Group.findByPk(group.modelId);
|
||||
if (groupModel?.disableMentions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usersFromMentionedGroup = await GroupUser.findAll({
|
||||
where: {
|
||||
groupId: group.modelId,
|
||||
},
|
||||
order: [["permission", "ASC"]],
|
||||
});
|
||||
|
||||
const mentionedUser: string[] = [];
|
||||
for (const user of usersFromMentionedGroup) {
|
||||
if (mentionedUser.includes(user.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(user.userId);
|
||||
if (
|
||||
recipient &&
|
||||
recipient.id !== group.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.GroupMentionedInDocument
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.GroupMentionedInDocument,
|
||||
groupId: group.modelId,
|
||||
userId: recipient.id,
|
||||
actorId: group.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
mentionedUser.push(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
mentionedGroup.push(group.modelId);
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
|
||||
@@ -36,43 +36,36 @@ export default abstract class ExportTask extends BaseTask<Props> {
|
||||
const fileOperation = await FileOperation.findByPk(fileOperationId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const [team, user] = await Promise.all([
|
||||
Team.findByPk(fileOperation.teamId, { rejectOnEmpty: true }),
|
||||
User.findByPk(fileOperation.userId, { rejectOnEmpty: true }),
|
||||
]);
|
||||
|
||||
const where: WhereOptions<Collection> = fileOperation.collectionId
|
||||
? {
|
||||
teamId: user.teamId,
|
||||
id: fileOperation.collectionId,
|
||||
permission: fileOperation.options?.includePrivate
|
||||
? undefined
|
||||
: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
}
|
||||
: {
|
||||
teamId: user.teamId,
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
permission: fileOperation.options?.includePrivate
|
||||
? undefined
|
||||
: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
};
|
||||
|
||||
const collections = await Collection.scope("withDocumentStructure").findAll(
|
||||
{
|
||||
where,
|
||||
}
|
||||
);
|
||||
|
||||
let filePath: string | undefined;
|
||||
|
||||
try {
|
||||
const where: WhereOptions<Collection> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (!fileOperation.options?.includePrivate) {
|
||||
where.permission = {
|
||||
[Op.ne]: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fileOperation.collectionId) {
|
||||
where.id = fileOperation.collectionId;
|
||||
} else {
|
||||
where.archivedAt = {
|
||||
[Op.eq]: null,
|
||||
};
|
||||
}
|
||||
|
||||
const collections = await Collection.scope(
|
||||
"withDocumentStructure"
|
||||
).findAll({ where });
|
||||
|
||||
if (!fileOperation.collectionId) {
|
||||
const totalAttachmentsSize = await Attachment.getTotalSizeForTeam(
|
||||
user.teamId
|
||||
|
||||
@@ -5,7 +5,14 @@ import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Revision, Notification, User, View } from "@server/models";
|
||||
import {
|
||||
Document,
|
||||
Revision,
|
||||
Notification,
|
||||
User,
|
||||
View,
|
||||
GroupUser,
|
||||
} from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { RevisionEvent } from "@server/types";
|
||||
@@ -38,20 +45,23 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
|
||||
// Send notifications to mentioned users first
|
||||
const oldMentions = before
|
||||
? DocumentHelper.parseMentions(before, { type: MentionType.User })
|
||||
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
|
||||
: [];
|
||||
const newMentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
});
|
||||
const newMentions = [
|
||||
...DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.User,
|
||||
}),
|
||||
];
|
||||
|
||||
const mentions = differenceBy(newMentions, oldMentions, "id");
|
||||
const userIdsMentioned: string[] = [];
|
||||
|
||||
for (const mention of mentions) {
|
||||
if (userIdsMentioned.includes(mention.modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(mention.modelId);
|
||||
|
||||
if (
|
||||
recipient &&
|
||||
recipient.id !== mention.actorId &&
|
||||
@@ -68,10 +78,68 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
userIdsMentioned.push(recipient.id);
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications to users in mentioned groups
|
||||
const oldGroupMentions = before
|
||||
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
|
||||
: [];
|
||||
const newGroupMentions = DocumentHelper.parseMentions(document, {
|
||||
type: MentionType.Group,
|
||||
});
|
||||
|
||||
const groupMentions = differenceBy(
|
||||
newGroupMentions,
|
||||
oldGroupMentions,
|
||||
"id"
|
||||
);
|
||||
const mentionedGroup: string[] = [];
|
||||
for (const group of groupMentions) {
|
||||
if (mentionedGroup.includes(group.modelId)) {
|
||||
continue;
|
||||
}
|
||||
const usersFromMentionedGroup = await GroupUser.findAll({
|
||||
where: {
|
||||
groupId: group.modelId,
|
||||
},
|
||||
order: [["permission", "ASC"]],
|
||||
});
|
||||
|
||||
const mentionedUser: string[] = [];
|
||||
for (const user of usersFromMentionedGroup) {
|
||||
if (mentionedUser.includes(user.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipient = await User.findByPk(user.userId);
|
||||
if (
|
||||
recipient &&
|
||||
recipient.id !== group.actorId &&
|
||||
recipient.subscribedToEventType(
|
||||
NotificationEventType.GroupMentionedInDocument
|
||||
) &&
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.GroupMentionedInDocument,
|
||||
groupId: group.modelId,
|
||||
userId: recipient.id,
|
||||
revisionId: event.modelId,
|
||||
actorId: group.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
mentionedUser.push(user.userId);
|
||||
}
|
||||
}
|
||||
|
||||
mentionedGroup.push(group.modelId);
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
|
||||
@@ -250,7 +250,20 @@ router.post(
|
||||
{ type: MentionType.User }
|
||||
).map((mention) => mention.id);
|
||||
|
||||
newMentionIds = difference(updatedMentionIds, existingMentionIds);
|
||||
const existingGroupMentionIds = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(comment.data),
|
||||
{ type: MentionType.Group }
|
||||
).map((mention) => mention.id);
|
||||
const updatedGroupMentionIds = ProsemirrorHelper.parseMentions(
|
||||
ProsemirrorHelper.toProsemirror(data),
|
||||
{ type: MentionType.Group }
|
||||
).map((mention) => mention.id);
|
||||
|
||||
newMentionIds = [
|
||||
...difference(updatedMentionIds, existingMentionIds),
|
||||
...difference(updatedGroupMentionIds, existingGroupMentionIds),
|
||||
];
|
||||
|
||||
comment.data = data;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user