Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 757890e1c1 chore: Compressed inefficient images automatically 2025-10-18 03:25:51 +00:00
173 changed files with 2192 additions and 6189 deletions
+4 -2
View File
@@ -20,7 +20,8 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -47,7 +48,8 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
+8 -8
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
FROM node:22-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
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 AS deps
FROM node:22 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.0.1
Licensed Work: Outline 0.87.4
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-10-29
Change Date: 2029-09-18
Change License: Apache License, Version 2.0
-7
View File
@@ -5,13 +5,6 @@
{
"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",
{
-16
View File
@@ -176,21 +176,6 @@ 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 />,
@@ -224,7 +209,6 @@ export const developer = createAction({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
+6 -7
View File
@@ -1,5 +1,4 @@
import { LocationDescriptor } from "history";
import { v4 as uuidv4 } from "uuid";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
@@ -46,7 +45,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
return definition.perform?.(context);
}
: undefined,
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -202,7 +201,7 @@ export function createActionV2(
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -213,7 +212,7 @@ export function createInternalLinkActionV2(
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -224,7 +223,7 @@ export function createExternalLinkActionV2(
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -235,7 +234,7 @@ export function createActionV2WithChildren(
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
@@ -252,7 +251,7 @@ export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
id: crypto.randomUUID(),
type: "action",
variant: "action_with_children",
name: "root_action",
-1
View File
@@ -26,7 +26,6 @@ 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}
/>
+2 -4
View File
@@ -83,15 +83,13 @@ function EditableTitle(
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(value);
setIsEditing(true);
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
+3 -11
View File
@@ -13,7 +13,6 @@ 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";
@@ -133,13 +132,6 @@ 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}
@@ -303,10 +295,10 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
&:before {
border: 8px solid transparent;
${({ direction }) =>
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: rgba(0, 0, 0, 0.1)`
: `border-top-color: rgba(0, 0, 0, 0.1)`};
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
}
@@ -1,59 +0,0 @@
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 }: Props,
{ avatarUrl, name, lastActive, color, email }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -25,6 +25,7 @@ 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 -7
View File
@@ -11,12 +11,9 @@ 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;
@@ -91,10 +88,7 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent ref={parentRef}>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
<SubMenuContent>{submenuItems}</SubMenuContent>
</SubMenu>
);
}
-92
View File
@@ -1,92 +0,0 @@
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%)`;
+4 -2
View File
@@ -47,7 +47,7 @@ function SharedSidebar({ share }: Props) {
}
return (
<StyledSidebar $hoverTransition={!teamAvailable} canResize={false}>
<StyledSidebar $hoverTransition={!teamAvailable}>
{teamAvailable && (
<SidebarButton
title={team.name}
@@ -57,7 +57,9 @@ function SharedSidebar({ share }: Props) {
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
}
/>
>
<ToggleSidebar />
</SidebarButton>
)}
<ScrollContainer topShadow flex>
<TopSection>
+8 -12
View File
@@ -25,15 +25,13 @@ import { useTranslation } from "react-i18next";
const ANIMATION_MS = 250;
type Props = {
hidden?: boolean;
/** Whether the sidebar can be resized and collapsed, defaults to true. */
canResize?: boolean;
className?: string;
children: React.ReactNode;
hidden?: boolean;
className?: string;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, canResize = true, className }: Props,
{ children, hidden = false, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -45,7 +43,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 && canResize;
const collapsed = ui.sidebarIsClosed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
@@ -256,12 +254,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
</SidebarButton>
</AccountMenu>
)}
{canResize && (
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</Container>
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
</TooltipProvider>
@@ -86,33 +86,27 @@ 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) => {
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);
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 },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
);
@@ -198,7 +192,6 @@ 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,36 +281,30 @@ function InnerDocumentLink(
[setExpanded, setCollapsed, hasChildren, expanded]
);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
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);
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 },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[
documents,
@@ -326,62 +320,6 @@ 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={{
@@ -407,7 +345,17 @@ function InnerDocumentLink(
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={labelElement}
label={
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
@@ -416,7 +364,35 @@ function InnerDocumentLink(
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={menuElement}
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
}
/>
</DropToImport>
</div>
@@ -438,7 +414,6 @@ function InnerDocumentLink(
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
@@ -8,32 +8,19 @@ 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, response }) => {
const GroupLink: React.FC<Props> = ({ group }) => {
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);
@@ -63,14 +50,6 @@ const GroupLink: React.FC<Props> = ({ group, response }) => {
depth={1}
/>
))}
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
disabled={loading}
depth={0}
/>
)}
</Folder>
</SidebarContext.Provider>
</Relative>
+3 -3
View File
@@ -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;
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
opacity: 0;
`;
@@ -30,9 +30,7 @@ function SharedWithMe() {
const history = useHistory();
const locationSidebarContext = useLocationSidebarContext();
const gmResponse = usePaginatedRequest<GroupMembership>(
groupMemberships.fetchAll
);
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
const { loading, next, end, error, page } =
usePaginatedRequest<UserMembership>(userMemberships.fetchPage, {
@@ -110,7 +108,7 @@ function SharedWithMe() {
<Flex column>
<Header id="shared" title={t("Shared with me")}>
{user.groupsWithDocumentMemberships.map((group) => (
<GroupLink key={group.id} group={group} response={gmResponse} />
<GroupLink key={group.id} group={group} />
))}
<Relative>
{reorderProps.isDragging && (
@@ -85,8 +85,11 @@ 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
@@ -94,11 +97,7 @@ function StarredDocumentLink({
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId });
if (!document) {
return null;
}
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
@@ -7,28 +7,18 @@ 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;
}
return insertDraftDocument && activeDocument
const insertDraftDocument =
activeDocument?.isActive &&
activeDocument?.isDraft &&
activeDocument?.collectionId === collection.id &&
!activeDocument?.parentDocumentId;
return insertDraftDocument
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
@@ -36,9 +26,14 @@ export default function useCollectionDocuments(
)
: collection.sortedDocuments;
}, [
insertDraftDocument,
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
collection?.sortedDocuments,
collection?.id,
collection?.sort,
]);
}
+16 -12
View File
@@ -57,7 +57,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
box-shadow: none;
cursor: var(--pointer);
svg:not([data-fixed-color]) {
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
@@ -66,18 +66,22 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
${(props) =>
!props.disabled &&
`
&: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:not([data-fixed-color]) {
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
fill: ${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};
}
}
}
`}
+9 -15
View File
@@ -1,4 +1,3 @@
import { useCallback } from "react";
import useDictionary from "~/hooks/useDictionary";
import getMenuItems from "../menus/block";
import { useEditor } from "./EditorContext";
@@ -14,25 +13,20 @@ 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={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
/>
)}
items={getMenuItems(dictionary, elementRef)}
/>
);
@@ -3,57 +3,71 @@ 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 MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
export function EmbedLinkEditor({ node, view, dictionary }: Props) {
const { t } = useTranslation();
const embeds = useEmbeds();
const url = node.attrs.href 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);
dispatch(state.tr.setSelection(selection));
view.focus();
}, [view]);
const openLink = useCallback(() => {
const openEmbed = useCallback(() => {
window.open(url, "_blank");
}, [url]);
const remove = useCallback(() => {
const removeEmbed = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
}, [view]);
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,
});
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,
})
);
view.dispatch(tr);
moveSelectionToEnd();
}, [localUrl, node, view, moveSelectionToEnd]);
}, [t, localUrl, embeds, node, view, moveSelectionToEnd]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -64,7 +78,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
switch (event.key) {
case "Enter": {
event.preventDefault();
update();
updateEmbed();
return;
}
@@ -75,13 +89,12 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
}
}
},
[update, moveSelectionToEnd]
[updateEmbed, moveSelectionToEnd]
);
return (
<Wrapper>
<Input
autoFocus={autoFocus}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
@@ -89,19 +102,13 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
readOnly={!view.editable}
/>
<Tooltip content={dictionary.openLink}>
<ToolbarButton onClick={openLink} disabled={!localUrl}>
<ToolbarButton onClick={openEmbed} disabled={!localUrl}>
<OpenIcon />
</ToolbarButton>
</Tooltip>
{view.editable && (
<Tooltip
content={
node.type.name === "embed"
? dictionary.deleteEmbed
: dictionary.deleteImage
}
>
<ToolbarButton onClick={remove}>
<Tooltip content={dictionary.deleteEmbed}>
<ToolbarButton onClick={removeEmbed}>
<TrashIcon />
</ToolbarButton>
</Tooltip>
@@ -114,5 +121,4 @@ const Wrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
min-width: 350px;
`;
+9 -14
View File
@@ -1,5 +1,5 @@
import capitalize from "lodash/capitalize";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
@@ -45,23 +45,18 @@ 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={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.description}
emoji={item.emoji}
/>
)}
items={items}
/>
);
+5 -18
View File
@@ -47,19 +47,9 @@ function usePosition({
}) {
const { view } = useEditor();
const { selection } = view.state;
const [menuWidth, setMenuWidth] = React.useState(0);
const menuWidth = menuRef.current?.offsetWidth ?? 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;
@@ -298,7 +288,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
ref={menuRef}
$offset={position.offset}
style={{
minWidth: props.width,
width: props.width,
maxWidth: `${position.maxWidth}px`,
top: `${position.top}px`,
left: `${position.left}px`,
@@ -319,7 +309,7 @@ type WrapperProps = {
const arrow = (props: WrapperProps) =>
props.arrow
? css`
&::after {
&::before {
content: "";
display: block;
width: 24px;
@@ -327,14 +317,11 @@ const arrow = (props: WrapperProps) =>
transform: translateX(-50%) rotate(45deg);
background: ${s("menuBackground")};
border-radius: 3px;
z-index: 0;
z-index: -1;
position: absolute;
bottom: -2px;
bottom: -3px;
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%);
}
`
: "";
+33 -98
View File
@@ -1,6 +1,5 @@
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";
@@ -10,14 +9,13 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import { Avatar, AvatarSize } 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";
@@ -26,7 +24,6 @@ import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { runInAction } from "mobx";
interface MentionItem extends MenuItem {
attrs: {
@@ -47,7 +44,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, groups } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -55,17 +52,11 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", {
query: search,
limit: maxResultsInSection,
});
const res = await client.post("/suggestions.mention", { query: search });
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);
});
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users, collections])
);
@@ -100,7 +91,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: UserSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: crypto.randomUUID(),
type: MentionType.User,
modelId: user.id,
actorId,
@@ -108,32 +99,6 @@ 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 })
@@ -158,7 +123,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: DocumentsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: crypto.randomUUID(),
type: MentionType.Document,
modelId: doc.id,
actorId,
@@ -186,7 +151,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
section: CollectionsSection,
appendSpace: true,
attrs: {
id: uuidv4(),
id: crypto.randomUUID(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
@@ -206,9 +171,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
priority: -1,
appendSpace: true,
attrs: {
id: uuidv4(),
id: crypto.randomUUID(),
type: MentionType.Document,
modelId: uuidv4(),
modelId: crypto.randomUUID(),
actorId,
label: search,
},
@@ -218,17 +183,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
const handleSelect = useCallback(
async (item: MentionItem) => {
@@ -241,57 +196,29 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (!documentId) {
return;
}
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);
// 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(
`Members of "{{ groupName }}" that have access to this document will be notified`,
"{{ userName }} won't be notified, as they do not have access to this document",
{
groupName: item.attrs.label,
userName: item.attrs.label,
}
),
{
icon: group ? <GroupAvatar group={group} /> : undefined,
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
},
[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}
/>
),
[]
[t, users, documentId]
);
// Prevent showing the menu until we have data otherwise it will be positioned
@@ -307,7 +234,15 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
filterable={false}
search={search}
onSelect={handleSelect}
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
subtitle={item.subtitle}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
+11 -17
View File
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
@@ -27,18 +26,6 @@ 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;
@@ -49,7 +36,14 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
{...props}
trigger=""
filterable={false}
renderMenuItem={renderMenuItem}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
@@ -102,11 +96,11 @@ function useItems({
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: uuidv4(),
id: crypto.randomUUID(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: uuidv4(),
modelId: crypto.randomUUID(),
actorId: user?.id,
},
appendSpace: true,
+82 -50
View File
@@ -1,9 +1,11 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import some from "lodash/some";
import { EditorState, 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,
@@ -15,6 +17,7 @@ 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";
@@ -26,33 +29,71 @@ import getTableMenuItems from "../menus/table";
import getTableColMenuItems from "../menus/tableCol";
import getTableRowMenuItems from "../menus/tableRow";
import { useEditor } from "./EditorContext";
import { MediaLinkEditor } from "./MediaLinkEditor";
import { EmbedLinkEditor } from "./EmbedLinkEditor";
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;
/** Callback function when a link is clicked */
onOpen: () => void;
onClose: () => void;
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);
@@ -61,19 +102,25 @@ function useIsDragging() {
return isDragging;
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
export default function SelectionToolbar(props: Props) {
const { onClose, readOnly, onOpen } = props;
const { view, commands } = useEditor();
const dictionary = useDictionary();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isActive = useIsActive(view.state) || isMobile;
const isDragging = useIsDragging();
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
const previousIsActive = usePrevious(isActive);
React.useEffect(() => {
setIsEditingImgUrl(false);
}, [isActive]);
// Trigger callbacks when the toolbar is opened or closed
if (previousIsActive && !isActive) {
onClose();
}
if (!previousIsActive && isActive) {
onOpen();
}
}, [isActive, onClose, onOpen, previousIsActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -96,8 +143,6 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -109,7 +154,7 @@ export function SelectionToolbar(props: Props) {
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, readOnly, view]);
}, [isActive, previousIsActive, readOnly, view]);
const handleOnSelectLink = ({
href,
@@ -131,14 +176,14 @@ export function SelectionToolbar(props: Props) {
);
};
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
if (isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
@@ -160,22 +205,19 @@ export function SelectionToolbar(props: Props) {
items = getCodeMenuItems(state, readOnly, dictionary);
align = "end";
} else if (isTableSelected(state)) {
items = getTableMenuItems(state, readOnly, dictionary);
items = readOnly ? [] : getTableMenuItems(state, dictionary);
} else if (colIndex !== undefined) {
items = getTableColMenuItems(state, readOnly, dictionary, {
index: colIndex,
rtl,
});
items = readOnly
? []
: getTableColMenuItems(state, colIndex, rtl, dictionary);
} else if (rowIndex !== undefined) {
items = getTableRowMenuItems(state, readOnly, dictionary, {
index: rowIndex,
});
items = readOnly ? [] : getTableRowMenuItems(state, rowIndex, dictionary);
} else if (isImageSelection) {
items = getImageMenuItems(state, readOnly, dictionary);
items = readOnly ? [] : getImageMenuItems(state, dictionary);
} else if (isAttachmentSelection) {
items = getAttachmentMenuItems(state, readOnly, dictionary);
items = readOnly ? [] : getAttachmentMenuItems(state, dictionary);
} else if (isDividerSelection) {
items = getDividerMenuItems(state, readOnly, dictionary);
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else if (isNoticeSelection && selection.empty) {
@@ -210,9 +252,6 @@ export function SelectionToolbar(props: Props) {
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
return (
<FloatingToolbar
align={align}
@@ -231,22 +270,15 @@ export function SelectionToolbar(props: Props) {
onClickLink={props.onClickLink}
onSelectLink={handleOnSelectLink}
/>
) : isEditingMedia ? (
<MediaLinkEditor
) : isEmbedSelection ? (
<EmbedLinkEditor
key={`embed-${selection.from}`}
node={selection.node}
node={(selection as NodeSelection).node}
view={view}
dictionary={dictionary}
autoFocus={isEditingImgUrl}
/>
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
<ToolbarMenu items={items} {...rest} />
)}
</FloatingToolbar>
);
+1 -5
View File
@@ -641,10 +641,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
const handleOnClick = () => {
handleClickItem(item);
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
@@ -661,7 +657,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
onClick: handleOnClick,
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
@@ -92,4 +92,4 @@ const Shortcut = styled.span<{ $active?: boolean }>`
text-align: right;
`;
export default React.memo(SuggestionsMenuItem);
export default SuggestionsMenuItem;
+7 -21
View File
@@ -20,20 +20,15 @@ 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;
handlers?: Record<string, Function>;
}) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, handlers } = props;
const { item } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -42,19 +37,11 @@ function ToolbarDropdown(props: {
return;
}
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
);
}
commands[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
};
return item.children
@@ -141,7 +128,6 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
@@ -1,5 +1,6 @@
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";
/**
@@ -18,32 +19,33 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
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");
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// 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");
// 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;
// Use plain text serializer only for code-only content
const usePlainText = isSingleCodeBlock || hasOnlyCodeMark;
return usePlainText
? slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("")
: mdSerializer.serialize(slice.content, {
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
softBreak: true,
});
})
: slice.content.content
.map((node) => ProsemirrorHelper.toPlainText(node))
.join("");
},
},
}),
+2 -3
View File
@@ -1,5 +1,4 @@
import { action, observable } from "mobx";
import { v4 as uuidv4 } from "uuid";
import { toggleMark } from "prosemirror-commands";
import { Node, Slice } from "prosemirror-model";
import {
@@ -144,7 +143,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Document,
modelId: document.id,
label: document.titleWithDefault,
id: uuidv4(),
id: crypto.randomUUID(),
})
)
);
@@ -189,7 +188,7 @@ export default class PasteHandler extends Extension {
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: uuidv4(),
id: crypto.randomUUID(),
})
)
);
-116
View File
@@ -1,116 +0,0 @@
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}
/>
);
};
}
+1 -1
View File
@@ -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(/(?:^|[^\|])(--\s)$/, "— ");
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
-2
View File
@@ -10,7 +10,6 @@ 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)[];
@@ -25,7 +24,6 @@ export const withUIExtensions = (nodes: Nodes) => [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
// Order these default key handlers last
PreventTab,
Keys,
+51 -56
View File
@@ -52,16 +52,15 @@ 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 { isArray, map } from "lodash";
import { 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 */
@@ -151,6 +150,8 @@ 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;
};
@@ -181,6 +182,7 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
selectionToolbarOpen: false,
activeLightboxImage: null,
};
@@ -268,12 +270,19 @@ export class Editor extends React.PureComponent<
this.calculateDir();
}
if (!this.isBlurred && !this.state.isEditorFocused) {
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
!this.state.selectionToolbarOpen
) {
this.isBlurred = true;
this.props.onBlur?.();
}
if (this.isBlurred && this.state.isEditorFocused) {
if (
this.isBlurred &&
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
) {
this.isBlurred = false;
this.props.onFocus?.();
}
@@ -407,7 +416,6 @@ export class Editor extends React.PureComponent<
plugins: [
...this.keymaps,
...this.plugins,
anchorPlugin(),
dropCursor({
color: this.props.theme.cursor,
}),
@@ -670,36 +678,19 @@ 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 (markRemoved) {
return false;
if (!node.isInline) {
return;
}
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);
@@ -708,7 +699,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 update
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (
@@ -717,11 +708,10 @@ export class Editor extends React.PureComponent<
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
let markUpdated = false;
state.doc.descendants((node, pos) => {
if (markUpdated) {
return false;
if (!node.isInline) {
return;
}
const mark = node.marks.find(
@@ -735,27 +725,9 @@ 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);
@@ -819,6 +791,23 @@ 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;
@@ -848,7 +837,18 @@ 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,15 +880,10 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.focusedCommentId &&
css`
span#comment-${props.focusedCommentId} {
#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) =>
-4
View File
@@ -5,12 +5,8 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function attachmentMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
return [
{
name: "replaceAttachment",
-4
View File
@@ -6,12 +6,8 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function dividerMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { schema } = state;
return [
+1 -25
View File
@@ -6,22 +6,16 @@ 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",
@@ -81,32 +75,14 @@ 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 />,
},
];
}
-4
View File
@@ -6,12 +6,8 @@ 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,
+3 -11
View File
@@ -25,18 +25,10 @@ import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
export default function tableColMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
rtl: boolean;
}
index: number,
rtl: boolean,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { index, rtl } = options;
const { schema, selection } = state;
if (!(selection instanceof CellSelection)) {
+2 -10
View File
@@ -19,17 +19,9 @@ import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
export default function tableRowMenuItems(
state: EditorState,
readOnly: boolean,
dictionary: Dictionary,
options: {
index: number;
}
index: number,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const { index } = options;
const { selection } = state;
if (!(selection instanceof CellSelection)) {
-2
View File
@@ -32,7 +32,6 @@ 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"),
@@ -109,7 +108,6 @@ export default function useDictionary() {
untitled: t("Untitled"),
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
}),
[t]
);
-29
View File
@@ -1,29 +0,0 @@
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;
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react";
import { PaginationParams } from "~/types";
import useRequest from "./useRequest";
export type RequestResponse<T> = {
type RequestResponse<T> = {
/** The return value of the paginated request function. */
data: T[] | undefined;
/** The request error, if any. */
+2 -2
View File
@@ -1,5 +1,5 @@
import invariant from "invariant";
import { action, comparer, computed, observable, runInAction } from "mobx";
import { action, computed, observable, runInAction } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
@@ -156,7 +156,7 @@ export default class Collection extends ParanoidModel {
return this.sort.field === "index";
}
@computed({ equals: comparer.structural })
@computed
get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {
return undefined;
+2 -7
View File
@@ -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, comparer, computed, observable, set } from "mobx";
import { action, autorun, computed, observable, set } from "mobx";
import type {
JSONObject,
NavigationNode,
@@ -89,11 +89,6 @@ 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.
*/
@@ -652,7 +647,7 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
@computed({ equals: comparer.structural })
@computed
get asNavigationNode(): NavigationNode {
return {
type: NavigationNodeType.Document,
+1 -16
View File
@@ -3,9 +3,8 @@ 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 implements Searchable {
class Group extends Model {
static modelName = "Group";
@Field
@@ -18,10 +17,6 @@ class Group extends Model implements Searchable {
@observable
memberCount: number;
@Field
@observable
disableMentions: boolean;
/**
* Returns the users that are members of this group.
*/
@@ -31,16 +26,6 @@ class Group extends Model implements Searchable {
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;
-5
View File
@@ -122,9 +122,6 @@ 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:
@@ -180,11 +177,9 @@ 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:
-5
View File
@@ -105,11 +105,6 @@ class Share extends Model implements Searchable {
return [this.title];
}
@computed
get searchSuppressed(): boolean {
return false;
}
@computed
get sharedCache() {
return (
-5
View File
@@ -68,11 +68,6 @@ 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 -6
View File
@@ -1,12 +1,11 @@
import pick from "lodash/pick";
import { observable, action, toJS } from "mobx";
import { observable, action } 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;
@@ -148,10 +147,6 @@ 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);
-2
View File
@@ -4,6 +4,4 @@
export interface Searchable {
/** The content to be used for search */
get searchContent(): string | string[];
get searchSuppressed(): boolean;
}
+14 -17
View File
@@ -13,7 +13,6 @@ 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";
@@ -82,22 +81,20 @@ function Overview({ collection, shareId }: Props) {
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<Suspense fallback={<Placeholder>Loading</Placeholder>}>
<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>
<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} />
</Suspense>
)}
</>
@@ -1,5 +1,4 @@
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";
@@ -161,7 +160,7 @@ function CommentForm({
comments
);
comment.id = uuidv4();
comment.id = crypto.randomUUID();
comments.add(comment);
comment
+2 -14
View File
@@ -41,7 +41,6 @@ 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";
@@ -55,6 +54,7 @@ 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,18 +417,6 @@ 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({
@@ -545,7 +533,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.handleSelectTemplate}
onSelectTemplate={this.replaceSelection}
onSave={this.onSave}
/>
<Main
@@ -2,7 +2,7 @@ import * as React from "react";
import useMeasure from "react-use-measure";
export const MeasuredContainer = <T extends React.ElementType>({
as: As = "div",
as: As,
name,
children,
...rest
+1 -4
View File
@@ -56,7 +56,6 @@ 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 });
@@ -207,7 +206,7 @@ function Login({ children, onBack }: Props) {
(provider) => provider.id === auth.lastSignedIn && !isCreate
);
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = isPWA || !!forceOTP;
const preferOTP = isPWA;
if (firstRun) {
return (
@@ -325,7 +324,6 @@ function Login({ children, onBack }: Props) {
<AuthenticationProvider
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
preferOTP={preferOTP}
{...defaultProvider}
/>
{hasMultipleProviders && (
@@ -350,7 +348,6 @@ function Login({ children, onBack }: Props) {
key={provider.id}
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
preferOTP={preferOTP}
neutral={defaultProvider && hasMultipleProviders}
{...provider}
/>
@@ -3,6 +3,7 @@ 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";
@@ -16,7 +17,6 @@ type Props = React.ComponentProps<typeof ButtonLarge> & {
authUrl: string;
isCreate: boolean;
onEmailSuccess: (email: string) => void;
preferOTP: boolean;
};
type AuthState = "initial" | "email" | "code";
@@ -28,6 +28,7 @@ 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);
@@ -45,7 +46,7 @@ function AuthenticationProvider(props: Props) {
const response = await client.post(event.currentTarget.action, {
email,
client: clientType,
preferOTP: props.preferOTP,
preferOTP,
});
if (response.redirect) {
+1 -2
View File
@@ -1,5 +1,4 @@
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";
@@ -105,7 +104,7 @@ function Search() {
// without a flash of loading.
if (query) {
searches.add({
id: uuidv4(),
id: crypto.randomUUID(),
query,
createdAt: new Date().toISOString(),
});
-9
View File
@@ -13,7 +13,6 @@ import {
SmileyIcon,
StarredIcon,
UserIcon,
GroupIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
@@ -71,14 +70,6 @@ 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,7 +29,6 @@ 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;
@@ -104,9 +103,6 @@ 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) => {
@@ -116,7 +112,6 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
try {
await group.save({
name,
disableMentions,
});
onSubmit();
} catch (err) {
@@ -125,7 +120,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
setIsSaving(false);
}
},
[group, onSubmit, name, disableMentions]
[group, onSubmit, name]
);
const handleNameChange = React.useCallback(
@@ -143,7 +138,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
often might confuse your team mates.
</Trans>
</Text>
<Flex column>
<Flex>
<Input
type="text"
label={t("Name")}
@@ -153,15 +148,6 @@ 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 -2
View File
@@ -1,5 +1,4 @@
import { observable, action } from "mobx";
import { v4 as uuidv4 } from "uuid";
import * as React from "react";
type DialogDefinition = {
@@ -66,7 +65,7 @@ export default class DialogsStore {
this.modalStack.clear();
}
this.modalStack.set(id ?? replaceId ?? uuidv4(), {
this.modalStack.set(id ?? replaceId ?? crypto.randomUUID(), {
title,
content,
style,
-3
View File
@@ -37,9 +37,6 @@ 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) {
-3
View File
@@ -34,9 +34,6 @@ 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) {
-8
View File
@@ -86,9 +86,6 @@ class UiStore {
@observable
multiplayerErrorCode?: number;
@observable
debugSafeArea = false;
rootStore: RootStore;
constructor(rootStore: RootStore) {
@@ -251,11 +248,6 @@ class UiStore {
this.mobileSidebarVisible = false;
};
@action
toggleDebugSafeArea = () => {
this.debugSafeArea = !this.debugSafeArea;
};
@computed
get readyToShow() {
return (
+13 -2
View File
@@ -101,13 +101,24 @@ export default abstract class Store<T extends Model> {
if (!normalized) {
return this.orderedData
.filter((item: T & Searchable) => !item.searchSuppressed)
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
}
return this.orderedData
.filter((item: T & Searchable) => {
if (item.searchSuppressed) {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
if ("searchContent" in item) {
+1
View File
@@ -150,6 +150,7 @@ declare module "styled-components" {
menuItemSelected: string;
menuBackground: string;
menuShadow: string;
menuBorder?: string;
divider: string;
titleBarDivider: string;
inputBorder: string;
+20 -21
View File
@@ -41,7 +41,7 @@
"url": "https://github.com/sponsors/outline"
},
"engines": {
"node": ">=20.12 <21 || 22"
"node": "20 || 22"
},
"repository": {
"type": "git",
@@ -51,18 +51,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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",
"@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",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@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.8.2",
"ioredis": "^5.7.0",
"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.3",
"koa": "^3.0.1",
"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.1",
"outline-icons": "^3.13.0",
"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.1",
"patch-package": "^8.0.0",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
@@ -262,8 +262,7 @@
"ukkonen": "^2.2.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^11.1.0",
"validator": "13.15.20",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "1.0.3",
@@ -278,7 +277,7 @@
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/preset-typescript": "^7.28.5",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@types/addressparser": "^1.0.3",
@@ -330,7 +329,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.22",
"@types/readable-stream": "^4.0.21",
"@types/redis-info": "^3.0.3",
"@types/refractor": "^3.4.1",
"@types/resolve-path": "^1.4.3",
@@ -351,7 +350,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.30",
"discord-api-types": "^0.38.20",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
@@ -364,7 +363,7 @@
"oxlint-tsgolint": "^0.1.6",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.6.2",
"react-refresh": "^0.18.0",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "2.1.6",
"terser": "^5.43.1",
@@ -382,6 +381,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "1.0.1",
"version": "0.87.4",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+7 -9
View File
@@ -72,7 +72,7 @@ router.post(
}
// Generate both a link token and a 6-digit verification code
const token = preferOTP ? undefined : user.getEmailSigninToken(ctx);
const token = preferOTP ? undefined : user.getEmailSigninToken();
const verificationCode = preferOTP
? await user.getEmailVerificationCode()
: undefined;
@@ -131,7 +131,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
try {
if (token) {
user = await getUserForEmailSigninToken(ctx, token as string);
user = await getUserForEmailSigninToken(token as string);
} else if (code && email) {
user = await User.scope("withTeam").findOne({
rejectOnEmpty: true,
@@ -150,18 +150,16 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
// Delete the code after successful verification
await VerificationCode.delete(email);
} else {
ctx.redirect("/?notice=auth-error&description=Missing%20token");
ctx.redirect("/?notice=auth-error");
return;
}
} catch (err) {
Logger.debug("authentication", err);
return ctx.redirect(`/?notice=auth-error&description=${err.message}`);
return ctx.redirect("/?notice=auth-error");
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect(
"/?notice=auth-error&description=Disabled%20signin%20method"
);
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
@@ -197,13 +195,13 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
};
router.get(
"email.callback",
rateLimiter(RateLimiterStrategy.FivePerMinute),
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.EmailCallbackSchema),
emailCallback
);
router.post(
"email.callback",
rateLimiter(RateLimiterStrategy.FivePerMinute),
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.EmailCallbackSchema),
emailCallback
);
@@ -1,76 +0,0 @@
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 }}"`} />
</>
)
+11 -24
View File
@@ -13,8 +13,6 @@ 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(),
@@ -113,25 +111,18 @@ export class Linear {
return;
}
const integrations = (await Integration.scope("withAuthentication").findAll(
{
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
},
}
)) as Integration<IntegrationType.Embed>[];
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.Linear,
teamId: actor.teamId,
"settings.linear.workspace.key": resource.workspaceKey,
},
})) as Integration<IntegrationType.Embed>;
if (integrations.length === 0) {
if (!integration) {
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),
@@ -151,7 +142,7 @@ export class Linear {
issue.paginate(issue.labels, {}),
]);
if (!state || !labels) {
if (!author || !state || !labels) {
return { error: "Failed to fetch auxiliary data from Linear" };
}
@@ -168,12 +159,8 @@ export class Linear {
title: issue.title,
description: issue.description ?? null,
author: {
name:
author?.name ??
issue.botActor?.userDisplayName ??
issue.botActor?.name ??
t("Unknown", opts(actor)),
avatarUrl: author?.avatarUrl ?? "",
name: author.name,
avatarUrl: author.avatarUrl ?? "",
},
labels: labels.map((label) => ({
name: label.name,
+1 -8
View File
@@ -1,6 +1,5 @@
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 = {
@@ -86,13 +85,7 @@ export default async function documentUpdater(
document.insightsEnabled = insightsEnabled;
}
if (text !== undefined) {
document = DocumentHelper.applyMarkdownToDocument(
document,
await TextHelper.replaceImagesWithAttachments(ctx, text, user, {
base64Only: true,
}),
append
);
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
}
const changed = document.changed();
+1 -1
View File
@@ -108,7 +108,7 @@ export default async function userInviter(
"email",
`Sign in immediately: ${
env.URL
}/auth/email.callback?token=${newUser.getEmailSigninToken(ctx)}`
}/auth/email.callback?token=${newUser.getEmailSigninToken()}`
);
}
}
+1 -11
View File
@@ -1,7 +1,6 @@
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";
@@ -144,21 +143,12 @@ 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,
subject: this.subject(data),
messageId,
references,
previewText: this.preview(data),
@@ -1,176 +0,0 @@
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>
);
}
}
@@ -1,167 +0,0 @@
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>
);
}
}
+2 -10
View File
@@ -6,7 +6,7 @@ import "./logging/tracer"; // must come before importing any instrumented module
import http from "http";
import https from "https";
import Koa, { Context } from "koa";
import Koa from "koa";
import helmet from "koa-helmet";
import logger from "koa-logger";
import Router from "koa-router";
@@ -90,7 +90,6 @@ 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,
/**
@@ -114,13 +113,6 @@ 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>
@@ -131,7 +123,7 @@ async function start(_id: number, disconnect: () => void) {
${formFields}
</form>
<script nonce="${this.state.cspNonce}">
${!this.userAgent.isBot} && document.getElementById('redirect-form').submit();
document.getElementById('redirect-form').submit();
</script>
</body>
</html>`;
@@ -1,19 +0,0 @@
"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");
},
};
@@ -1,16 +0,0 @@
"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");
},
};
-3
View File
@@ -68,9 +68,6 @@ class Group extends ParanoidModel<
@Column
externalId: string;
@Column(DataType.BOOLEAN)
disableMentions: boolean;
static filterByMember(userId: string | undefined) {
return userId
? this.scope({ method: ["withMembership", userId] })
-30
View File
@@ -81,36 +81,6 @@ 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", () => {
-13
View File
@@ -30,7 +30,6 @@ 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;
@@ -129,13 +128,6 @@ 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;
@@ -210,7 +202,6 @@ class Notification extends Model<
collectionId: model.collectionId,
actorId: model.actorId,
membershipId: model.membershipId,
groupId: model.groupId,
};
if (options.transaction) {
@@ -269,10 +260,6 @@ 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`;
+1 -2
View File
@@ -588,11 +588,10 @@ class User extends ParanoidModel<
*
* @returns The email signin token
*/
getEmailSigninToken = (ctx: Context) =>
getEmailSigninToken = () =>
JWT.sign(
{
id: this.id,
ip: ctx.request.ip,
createdAt: new Date().toISOString(),
type: "email-signin",
},
+1 -13
View File
@@ -9,7 +9,6 @@ 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 {
@@ -66,11 +65,7 @@ export class TextHelper {
static async replaceImagesWithAttachments(
ctx: APIContext,
markdown: string,
user: User,
options: {
/** If true, only process base64 encoded images */
base64Only?: boolean;
} = {}
user: User
) {
let output = markdown;
const images = parseImages(markdown);
@@ -89,13 +84,6 @@ 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,
-1
View File
@@ -6,7 +6,6 @@ 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,
};
+1 -22
View File
@@ -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, Group } from "@server/models";
import { Document, User, View } from "@server/models";
import { opts } from "@server/utils/i18n";
async function presentUnfurl(
@@ -12,8 +12,6 @@ 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:
@@ -56,25 +54,6 @@ 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,8 +11,6 @@ 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"];
@@ -85,21 +83,6 @@ 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(
@@ -116,24 +99,6 @@ 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,14 +5,7 @@ import {
} from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import {
Comment,
Document,
Group,
GroupUser,
Notification,
User,
} from "@server/models";
import { Comment, Document, 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";
@@ -84,65 +77,6 @@ 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,14 +1,7 @@
import invariant from "invariant";
import { Op } from "sequelize";
import { MentionType, NotificationEventType } from "@shared/types";
import {
Comment,
Document,
Group,
GroupUser,
Notification,
User,
} from "@server/models";
import { Comment, Document, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
@@ -76,62 +69,6 @@ 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, Group, Notification, User, GroupUser } from "@server/models";
import { Document, Notification, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
@@ -50,60 +50,6 @@ 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,
+29 -22
View File
@@ -36,36 +36,43 @@ 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,14 +5,7 @@ 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,
GroupUser,
} from "@server/models";
import { Document, Revision, Notification, User, View } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { RevisionEvent } from "@server/types";
@@ -45,23 +38,20 @@ 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 &&
@@ -78,68 +68,10 @@ 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,
+1 -14
View File
@@ -250,20 +250,7 @@ router.post(
{ type: MentionType.User }
).map((mention) => mention.id);
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),
];
newMentionIds = difference(updatedMentionIds, existingMentionIds);
comment.data = data;
}
+1 -2
View File
@@ -168,14 +168,13 @@ router.post(
validate(T.GroupsCreateSchema),
transaction(),
async (ctx: APIContext<T.GroupsCreateReq>) => {
const { name, externalId, disableMentions } = ctx.input.body;
const { name, externalId } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createGroup", user.team);
const group = await Group.createWithCtx(ctx, {
name,
externalId,
disableMentions,
teamId: user.teamId,
createdById: user.id,
});

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