diff --git a/.circleci/config.yml b/.circleci/config.yml
index c7dc50b9d478b..99d3cf94518d6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3,7 +3,7 @@ version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- - image: cimg/node:18.12
+ - image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
diff --git a/.env.sample b/.env.sample
index 3e8cadbe4735c..8ebec56325999 100644
--- a/.env.sample
+++ b/.env.sample
@@ -195,3 +195,8 @@ RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
# IFRAMELY_URL=
# IFRAMELY_API_KEY=
+
+# Enable unsafe-inline in script-src CSP directive
+# Setting it to true allows React dev tools add-on in
+# Firefox to successfully detect the project
+DEVELOPMENT_UNSAFE_INLINE_CSP=false
diff --git a/Dockerfile b/Dockerfile
index a7b0fb54b4d2e..eaca276e8257a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
-FROM node:18-alpine AS runner
+FROM node:20-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
diff --git a/Dockerfile.base b/Dockerfile.base
index 3c267a00847be..02318b1518c67 100644
--- a/Dockerfile.base
+++ b/Dockerfile.base
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
-FROM node:18-alpine AS deps
+FROM node:20-alpine AS deps
ARG APP_PATH
WORKDIR $APP_PATH
diff --git a/Makefile b/Makefile
index 88666289827d8..1b9fc94e2eb37 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
up:
- docker-compose up -d redis postgres s3
+ docker-compose up -d redis postgres
yarn install-local-ssl
yarn install --pure-lockfile
yarn dev:watch
@@ -8,14 +8,14 @@ build:
docker-compose build --pull outline
test:
- docker-compose up -d redis postgres s3
+ docker-compose up -d redis postgres
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
NODE_ENV=test yarn sequelize db:migrate --env=test
yarn test
watch:
- docker-compose up -d redis postgres s3
+ docker-compose up -d redis postgres
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
NODE_ENV=test yarn sequelize db:migrate --env=test
diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx
index 9831f78ea3876..290202ee92683 100644
--- a/app/actions/definitions/developer.tsx
+++ b/app/actions/definitions/developer.tsx
@@ -1,4 +1,5 @@
-import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
+import copy from "copy-to-clipboard";
+import { CopyIcon, ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction } from "~/actions";
@@ -8,6 +9,71 @@ import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
+export const copyId = createAction({
+ name: ({ t }) => t("Copy ID"),
+ icon: ,
+ keywords: "uuid",
+ section: DeveloperSection,
+ children: ({
+ currentTeamId,
+ currentUserId,
+ activeCollectionId,
+ activeDocumentId,
+ }) => {
+ function copyAndToast(text: string | null | undefined) {
+ if (text) {
+ copy(text);
+ toast.success("Copied to clipboard");
+ }
+ }
+
+ return [
+ createAction({
+ name: "Copy User ID",
+ section: DeveloperSection,
+ icon: ,
+ visible: () => !!currentUserId,
+ perform: () => copyAndToast(currentUserId),
+ }),
+ createAction({
+ name: "Copy Team ID",
+ section: DeveloperSection,
+ icon: ,
+ visible: () => !!currentTeamId,
+ perform: () => copyAndToast(currentTeamId),
+ }),
+ createAction({
+ name: "Copy Collection ID",
+ icon: ,
+ section: DeveloperSection,
+ visible: () => !!activeCollectionId,
+ perform: () => copyAndToast(activeCollectionId),
+ }),
+ createAction({
+ name: "Copy Document ID",
+ icon: ,
+ section: DeveloperSection,
+ visible: () => !!activeDocumentId,
+ perform: () => copyAndToast(activeDocumentId),
+ }),
+ createAction({
+ name: "Copy Team ID",
+ icon: ,
+ section: DeveloperSection,
+ visible: () => !!currentTeamId,
+ perform: () => copyAndToast(currentTeamId),
+ }),
+ createAction({
+ name: "Copy Release ID",
+ icon: ,
+ section: DeveloperSection,
+ visible: () => !!env.RELEASE,
+ perform: () => copyAndToast(env.RELEASE),
+ }),
+ ];
+ },
+});
+
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: ,
@@ -67,7 +133,13 @@ export const developer = createAction({
icon: ,
iconInContextMenu: false,
section: DeveloperSection,
- children: [clearIndexedDB, toggleDebugLogging, createToast, createTestUsers],
+ children: [
+ copyId,
+ clearIndexedDB,
+ toggleDebugLogging,
+ createToast,
+ createTestUsers,
+ ],
});
export const rootDeveloperActions = [developer];
diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx
index 7c2f4fee7ab6c..3fd5ef51128de 100644
--- a/app/actions/definitions/documents.tsx
+++ b/app/actions/definitions/documents.tsx
@@ -1,3 +1,4 @@
+import copy from "copy-to-clipboard";
import invariant from "invariant";
import {
DownloadIcon,
@@ -23,11 +24,15 @@ import {
UnpublishIcon,
PublishIcon,
CommentIcon,
+ GlobeIcon,
+ CopyIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { ExportContentType, TeamPreference } from "@shared/types";
+import MarkdownHelper from "@shared/utils/MarkdownHelper";
import { getEventFiles } from "@shared/utils/files";
+import SharePopover from "~/scenes/Document/components/SharePopover";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
@@ -45,6 +50,7 @@ import {
newDocumentPath,
searchPath,
documentPath,
+ urlify,
} from "~/utils/routeHelpers";
export const openDocument = createAction({
@@ -320,6 +326,40 @@ export const unsubscribeDocument = createAction({
},
});
+export const shareDocument = createAction({
+ name: ({ t }) => t("Share"),
+ analyticsName: "Share document",
+ section: DocumentSection,
+ icon: ,
+ perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
+ if (!activeDocumentId || !currentUserId) {
+ return;
+ }
+
+ const document = stores.documents.get(activeDocumentId);
+ const share = stores.shares.getByDocumentId(activeDocumentId);
+ const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
+ if (!document) {
+ return;
+ }
+
+ stores.dialogs.openModal({
+ title: t("Share this document"),
+ isCentered: true,
+ content: (
+
+ ),
+ });
+ },
+});
+
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
@@ -396,6 +436,47 @@ export const downloadDocument = createAction({
],
});
+export const copyDocumentAsMarkdown = createAction({
+ name: ({ t }) => t("Copy as Markdown"),
+ section: DocumentSection,
+ keywords: "clipboard",
+ visible: ({ activeDocumentId }) => !!activeDocumentId,
+ perform: ({ stores, activeDocumentId, t }) => {
+ const document = activeDocumentId
+ ? stores.documents.get(activeDocumentId)
+ : undefined;
+ if (document) {
+ copy(MarkdownHelper.toMarkdown(document));
+ toast.success(t("Markdown copied to clipboard"));
+ }
+ },
+});
+
+export const copyDocumentLink = createAction({
+ name: ({ t }) => t("Copy link"),
+ section: DocumentSection,
+ keywords: "clipboard",
+ visible: ({ activeDocumentId }) => !!activeDocumentId,
+ perform: ({ stores, activeDocumentId, t }) => {
+ const document = activeDocumentId
+ ? stores.documents.get(activeDocumentId)
+ : undefined;
+ if (document) {
+ copy(urlify(documentPath(document)));
+ toast.success(t("Link copied to clipboard"));
+ }
+ },
+});
+
+export const copyDocument = createAction({
+ name: ({ t }) => t("Copy"),
+ analyticsName: "Copy document",
+ section: DocumentSection,
+ icon: ,
+ keywords: "clipboard",
+ children: [copyDocumentLink, copyDocumentAsMarkdown],
+});
+
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
@@ -703,7 +784,7 @@ export const archiveDocument = createAction({
});
export const deleteDocument = createAction({
- name: ({ t }) => t("Delete"),
+ name: ({ t }) => `${t("Delete")}…`,
analyticsName: "Delete document",
section: DocumentSection,
icon: ,
@@ -781,8 +862,7 @@ export const openDocumentComments = createAction({
const can = stores.policies.abilities(activeDocumentId ?? "");
return (
!!activeDocumentId &&
- can.read &&
- !can.restore &&
+ can.comment &&
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
);
},
@@ -854,6 +934,8 @@ export const rootDocumentActions = [
deleteDocument,
importDocument,
downloadDocument,
+ copyDocumentLink,
+ copyDocumentAsMarkdown,
starDocument,
unstarDocument,
publishDocument,
diff --git a/app/actions/index.ts b/app/actions/index.ts
index dcc4f36e3305a..4083bcc103672 100644
--- a/app/actions/index.ts
+++ b/app/actions/index.ts
@@ -116,6 +116,8 @@ export function actionToKBar(
icon: resolvedIcon,
perform: action.perform ? () => action.perform?.(context) : undefined,
},
+ ].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
- ].concat(children.map((child) => ({ ...child, parent: action.id })));
+ children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
+ );
}
diff --git a/app/components/Authenticated.tsx b/app/components/Authenticated.tsx
index 924e5da8de1f4..02eec34ea9e81 100644
--- a/app/components/Authenticated.tsx
+++ b/app/components/Authenticated.tsx
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
+import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import LoadingIndicator from "./LoadingIndicator";
@@ -13,10 +14,11 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
- const language = auth.user?.language;
+ const user = useCurrentUser({ rejectOnEmpty: false });
+ const language = user?.language;
- // Watching for language changes here as this is the earliest point we have
- // the user available and means we can start loading translations faster
+ // Watching for language changes here as this is the earliest point we might have the user
+ // available and means we can start loading translations faster
React.useEffect(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx
index cad6c54ac5f88..94567c9daefe6 100644
--- a/app/components/AuthenticatedLayout.tsx
+++ b/app/components/AuthenticatedLayout.tsx
@@ -12,6 +12,7 @@ import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import type { Editor as TEditor } from "~/editor";
+import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -25,6 +26,7 @@ import {
matchDocumentInsights,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
+import { PortalContext } from "./Portal";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -44,8 +46,9 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
+ const layoutRef = React.useRef(null);
const can = usePolicy(ui.activeCollectionId);
- const { user, team } = auth;
+ const team = useCurrentTeam();
const documentContext = useLocalStore(() => ({
editor: null,
setEditor: (editor: TEditor) => {
@@ -76,16 +79,14 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return ;
}
- const showSidebar = auth.authenticated && user && team;
-
- const sidebar = showSidebar ? (
+ const sidebar = (
- ) : undefined;
+ );
const showHistory = !!matchPath(location.pathname, {
path: matchDocumentHistory,
@@ -98,7 +99,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!showHistory &&
ui.activeDocumentId &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
- team?.getPreference(TeamPreference.Commenting);
+ team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
{
return (
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
);
};
diff --git a/app/components/Button.tsx b/app/components/Button.tsx
index c072238fd94d0..4c4d9b96c981d 100644
--- a/app/components/Button.tsx
+++ b/app/components/Button.tsx
@@ -171,7 +171,7 @@ const Button = (
danger,
...rest
} = props;
- const hasText = children !== undefined || value !== undefined;
+ const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
diff --git a/app/components/CenteredContent.tsx b/app/components/CenteredContent.tsx
index f3405751a6876..888146b71a5a4 100644
--- a/app/components/CenteredContent.tsx
+++ b/app/components/CenteredContent.tsx
@@ -4,6 +4,7 @@ import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
+ maxWidth?: string;
withStickyHeader?: boolean;
};
@@ -18,18 +19,24 @@ const Container = styled.div`
`};
`;
-const Content = styled.div`
- max-width: 46em;
+type ContentProps = { $maxWidth?: string };
+
+const Content = styled.div`
+ max-width: ${(props) => props.$maxWidth ?? "46em"};
margin: 0 auto;
${breakpoint("desktopLarge")`
- max-width: 52em;
+ max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
`};
`;
-const CenteredContent: React.FC = ({ children, ...rest }: Props) => (
+const CenteredContent: React.FC = ({
+ children,
+ maxWidth,
+ ...rest
+}: Props) => (
- {children}
+ {children}
);
diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx
index d0d0e22605072..58cb4c4c05d31 100644
--- a/app/components/CollectionDescription.tsx
+++ b/app/components/CollectionDescription.tsx
@@ -5,6 +5,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
+import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
@@ -12,9 +13,19 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
+import BlockMenuExtension from "~/editor/extensions/BlockMenu";
+import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
+import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
+const extensions = [
+ ...richExtensions,
+ BlockMenuExtension,
+ EmojiMenuExtension,
+ HoverPreviewsExtension,
+];
+
type Props = {
collection: Collection;
};
@@ -104,6 +115,7 @@ function CollectionDescription({ collection }: Props) {
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
+ extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
@@ -165,7 +177,7 @@ const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
- margin: -12px -8px -8px;
+ margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx
index 70282781c547b..074664fb4e907 100644
--- a/app/components/ConfirmationDialog.tsx
+++ b/app/components/ConfirmationDialog.tsx
@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
+import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -29,6 +30,7 @@ const ConfirmationDialog: React.FC = ({
disabled = false,
}: Props) => {
const [isSaving, setIsSaving] = React.useState(false);
+ const { t } = useTranslation();
const { dialogs } = useStores();
const handleSubmit = React.useCallback(
@@ -48,19 +50,20 @@ const ConfirmationDialog: React.FC = ({
);
return (
-
-
-
+
+
);
};
diff --git a/app/components/ContextMenu/MenuIconWrapper.ts b/app/components/ContextMenu/MenuIconWrapper.ts
index 385e8291dabd4..ab0855b7ab1ab 100644
--- a/app/components/ContextMenu/MenuIconWrapper.ts
+++ b/app/components/ContextMenu/MenuIconWrapper.ts
@@ -7,6 +7,7 @@ const MenuIconWrapper = styled.span`
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
+ flex-shrink: 0;
`;
export default MenuIconWrapper;
diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx
index 42306b55181aa..8c79f5b8dd54a 100644
--- a/app/components/ContextMenu/Template.tsx
+++ b/app/components/ContextMenu/Template.tsx
@@ -201,7 +201,7 @@ function Template({ items, actions, context, ...menu }: Props) {
}
if (item.type === "heading") {
- return ;
+ return ;
}
const _exhaustiveCheck: never = item;
diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx
index 974c7cb428a40..6426f7733e458 100644
--- a/app/components/DocumentBreadcrumb.tsx
+++ b/app/components/DocumentBreadcrumb.tsx
@@ -68,6 +68,10 @@ const DocumentBreadcrumb: React.FC = ({
? collections.get(document.collectionId)
: undefined;
+ React.useEffect(() => {
+ void document.loadRelations();
+ }, [document]);
+
let collectionNode: MenuInternalLink | undefined;
if (collection) {
@@ -86,11 +90,7 @@ const DocumentBreadcrumb: React.FC = ({
};
}
- const path = React.useMemo(
- () => collection?.pathToDocument(document.id).slice(0, -1) || [],
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [collection, document, document.collectionId, document.parentDocumentId]
- );
+ const path = document.pathTo;
const items = React.useMemo(() => {
const output = [];
@@ -103,7 +103,7 @@ const DocumentBreadcrumb: React.FC = ({
output.push(collectionNode);
}
- path.forEach((node: NavigationNode) => {
+ path.slice(0, -1).forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.emoji ? (
@@ -127,7 +127,7 @@ const DocumentBreadcrumb: React.FC = ({
return (
<>
{collection?.name}
- {path.map((node: NavigationNode) => (
+ {path.slice(0, -1).map((node: NavigationNode) => (
{node.title}
diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx
index 9f220f76e6937..1b88629524512 100644
--- a/app/components/DocumentListItem.tsx
+++ b/app/components/DocumentListItem.tsx
@@ -260,8 +260,8 @@ const Title = styled(Highlight)`
const ResultContext = styled(Highlight)`
display: block;
- color: ${s("textTertiary")};
- font-size: 14px;
+ color: ${s("textSecondary")};
+ font-size: 15px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx
index 2304dda10ba5d..234b8592ecc5b 100644
--- a/app/components/Editor.tsx
+++ b/app/components/Editor.tsx
@@ -6,7 +6,6 @@ import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
-import { useHistory } from "react-router-dom";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
@@ -16,21 +15,18 @@ import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
-import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
-import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
+import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
+import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
-import { isModKey } from "~/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry";
-import { sharedDocumentPath } from "~/utils/routeHelpers";
-import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -46,10 +42,9 @@ export type Props = Optional<
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
- previewsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise;
- onPublish?: (event: React.MouseEvent) => any;
+ onPublish?: (event: React.MouseEvent) => void;
editorStyle?: React.CSSProperties;
};
@@ -61,30 +56,17 @@ function Editor(props: Props, ref: React.RefObject | null) {
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
- previewsDisabled,
} = props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
- const { auth, comments, documents } = useStores();
+ const { comments, documents } = useStores();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
- const history = useHistory();
const localRef = React.useRef();
- const preferences = auth.user?.preferences;
+ const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousHeadings = React.useRef(null);
- const [activeLinkElement, setActiveLink] =
- React.useState(null);
const previousCommentIds = React.useRef();
- const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
- setActiveLink(element);
- return false;
- }, []);
-
- const handleLinkInactive = React.useCallback(() => {
- setActiveLink(null);
- }, []);
-
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
@@ -121,7 +103,7 @@ function Editor(props: Props, ref: React.RefObject | null) {
const results = await documents.searchTitles(term);
return sortBy(
- results.map((document: Document) => ({
+ results.map(({ document }) => ({
title: document.title,
subtitle: ,
url: document.url,
@@ -134,7 +116,7 @@ function Editor(props: Props, ref: React.RefObject | null) {
: 1
);
},
- [documents]
+ [locale, documents]
);
const handleUploadFile = React.useCallback(
@@ -148,47 +130,7 @@ function Editor(props: Props, ref: React.RefObject | null) {
[id]
);
- const handleClickLink = React.useCallback(
- (href: string, event: MouseEvent) => {
- // on page hash
- if (isHash(href)) {
- window.location.href = href;
- return;
- }
-
- if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
- // relative
- let navigateTo = href;
-
- // probably absolute
- if (href[0] !== "/") {
- try {
- const url = new URL(href);
- navigateTo = url.pathname + url.hash;
- } catch (err) {
- navigateTo = href;
- }
- }
-
- // Link to our own API should be opened in a new tab, not in the app
- if (navigateTo.startsWith("/api/")) {
- window.open(href, "_blank");
- return;
- }
-
- // If we're navigating to an internal document link then prepend the
- // share route to the URL so that the document is loaded in context
- if (shareId && navigateTo.includes("/doc/")) {
- navigateTo = sharedDocumentPath(shareId, navigateTo);
- }
-
- history.push(navigateTo);
- } else if (href) {
- window.open(href, "_blank");
- }
- },
- [history, shareId]
- );
+ const { handleClickLink } = useEditorClickHandlers({ shareId });
const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd();
@@ -335,7 +277,6 @@ function Editor(props: Props, ref: React.RefObject | null) {
userPreferences={preferences}
dictionary={dictionary}
{...props}
- onHoverLink={previewsDisabled ? undefined : handleLinkActive}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
@@ -350,12 +291,6 @@ function Editor(props: Props, ref: React.RefObject | null) {
minHeight={props.editorStyle.paddingBottom}
/>
)}
- {activeLinkElement && !shareId && (
-
- )}
>
);
diff --git a/app/components/Facepile.tsx b/app/components/Facepile.tsx
index a9de5116e8f0d..bd9097354bf2b 100644
--- a/app/components/Facepile.tsx
+++ b/app/components/Facepile.tsx
@@ -32,9 +32,12 @@ function Facepile({
)}
- {users.slice(0, limit).map((user) => (
- {renderAvatar(user)}
- ))}
+ {users
+ .filter(Boolean)
+ .slice(0, limit)
+ .map((user) => (
+ {renderAvatar(user)}
+ ))}
);
}
diff --git a/app/components/Highlight.tsx b/app/components/Highlight.tsx
index f57e7135f5815..d071dc6d79798 100644
--- a/app/components/Highlight.tsx
+++ b/app/components/Highlight.tsx
@@ -2,7 +2,6 @@ import escapeRegExp from "lodash/escapeRegExp";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
-import { s } from "@shared/styles";
type Props = React.HTMLAttributes & {
highlight: (string | null | undefined) | RegExp;
@@ -44,9 +43,9 @@ function Highlight({
}
export const Mark = styled.mark`
- background: ${s("searchHighlight")};
- border-radius: 2px;
- padding: 0 2px;
+ color: inherit;
+ background: transparent;
+ font-weight: 600;
`;
export default Highlight;
diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx
index ff651bffb723a..1e908daf84d43 100644
--- a/app/components/HoverPreview/HoverPreview.tsx
+++ b/app/components/HoverPreview/HoverPreview.tsx
@@ -9,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
+import usePrevious from "~/hooks/usePrevious";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -17,13 +18,14 @@ import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
-const DELAY_OPEN = 500;
const DELAY_CLOSE = 600;
+const POINTER_HEIGHT = 22;
+const POINTER_WIDTH = 22;
type Props = {
- /** The HTML element that is being hovered over */
- element: HTMLAnchorElement;
- /** A callback on close of the hover preview */
+ /** The HTML element that is being hovered over, or null if none. */
+ element: HTMLElement | null;
+ /** A callback on close of the hover preview. */
onClose: () => void;
};
@@ -32,99 +34,23 @@ enum Direction {
DOWN,
}
-const POINTER_HEIGHT = 22;
-const POINTER_WIDTH = 22;
-
-function HoverPreviewInternal({ element, onClose }: Props) {
- const url = element.href || element.dataset.url;
+function HoverPreviewDesktop({ element, onClose }: Props) {
+ const url = element?.getAttribute("href") || element?.dataset.url;
+ const previousUrl = usePrevious(url, true);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef>();
- const timerOpen = React.useRef>();
const cardRef = React.useRef(null);
- const stores = useStores();
- const [cardLeft, setCardLeft] = React.useState(0);
- const [cardTop, setCardTop] = React.useState(0);
- const [pointerLeft, setPointerLeft] = React.useState(0);
- const [pointerTop, setPointerTop] = React.useState(0);
- const [pointerDir, setPointerDir] = React.useState(Direction.UP);
-
- React.useLayoutEffect(() => {
- if (isVisible && cardRef.current) {
- const elem = element.getBoundingClientRect();
- const card = cardRef.current.getBoundingClientRect();
-
- let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
- let pTop = -POINTER_HEIGHT;
- let pDir = Direction.UP;
- if (cTop + card.height > window.innerHeight + window.scrollY) {
- // shift card upwards if it goes out of screen
- const bottom = elem.top + window.scrollY;
- cTop = bottom - card.height;
- // shift a little further to leave some margin between card and element boundary
- cTop -= CARD_MARGIN;
- // pointer should be shifted downwards to align with card's bottom
- pTop = card.height;
- pDir = Direction.DOWN;
- }
- setCardTop(cTop);
- setPointerTop(pTop);
- setPointerDir(pDir);
-
- let cLeft = elem.left;
- let pLeft = elem.width / 2;
- if (cLeft + card.width > window.innerWidth) {
- // shift card leftwards by the amount it went out of screen
- let shiftBy = cLeft + card.width - window.innerWidth;
- // shift a little further to leave some margin between card and window boundary
- shiftBy += CARD_MARGIN;
- cLeft -= shiftBy;
-
- // shift pointer rightwards by same amount so as to position it back correctly
- pLeft += shiftBy;
- }
- setCardLeft(cLeft);
- setPointerLeft(pLeft);
- }
- }, [isVisible, element]);
-
- const { data, request, loading } = useRequest(
- React.useCallback(
- () =>
- client.post("/urls.unfurl", {
- url,
- documentId: stores.ui.activeDocumentId,
- }),
- [url, stores.ui.activeDocumentId]
- )
- );
-
- React.useEffect(() => {
- if (url) {
- stopOpenTimer();
- setVisible(false);
-
- void request();
- }
- }, [url, request]);
-
- const stopOpenTimer = () => {
- if (timerOpen.current) {
- clearTimeout(timerOpen.current);
- timerOpen.current = undefined;
- }
- };
+ const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
+ useHoverPosition({
+ cardRef,
+ element,
+ isVisible,
+ });
const closePreview = React.useCallback(() => {
- if (isVisible) {
- stopOpenTimer();
- setVisible(false);
- onClose();
- }
- }, [isVisible, onClose]);
-
- useOnClickOutside(cardRef, closePreview);
- useKeyDown("Escape", closePreview);
- useEventListener("scroll", closePreview, window, { capture: true });
+ setVisible(false);
+ onClose();
+ }, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
@@ -133,38 +59,36 @@ function HoverPreviewInternal({ element, onClose }: Props) {
}
}, []);
- const startOpenTimer = React.useCallback(() => {
- if (!timerOpen.current) {
- timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
- }
- }, []);
-
const startCloseTimer = React.useCallback(() => {
- stopOpenTimer();
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
+ // Open and close the preview when the element changes.
React.useEffect(() => {
- const card = cardRef.current;
+ if (element) {
+ setVisible(true);
+ } else {
+ startCloseTimer();
+ }
+ }, [startCloseTimer, element]);
- if (data) {
- startOpenTimer();
+ // Close the preview on Escape, scroll, or click outside.
+ useOnClickOutside(cardRef, closePreview);
+ useKeyDown("Escape", closePreview);
+ useEventListener("scroll", closePreview, window, { capture: true });
+ // Ensure that the preview stays open while the user is hovering over the card.
+ React.useEffect(() => {
+ const card = cardRef.current;
+
+ if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
-
- element.addEventListener("mouseout", startCloseTimer);
- element.addEventListener("mouseover", stopCloseTimer);
- element.addEventListener("mouseover", startOpenTimer);
}
return () => {
- element.removeEventListener("mouseout", startCloseTimer);
- element.removeEventListener("mouseover", stopCloseTimer);
- element.removeEventListener("mouseover", startOpenTimer);
-
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
@@ -172,69 +96,159 @@ function HoverPreviewInternal({ element, onClose }: Props) {
stopCloseTimer();
};
- }, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]);
+ }, [element, startCloseTimer, isVisible, stopCloseTimer]);
- if (loading) {
- return ;
- }
+ const displayUrl = url ?? previousUrl;
- if (!data) {
+ if (!isVisible || !displayUrl) {
return null;
}
return (
-
- {isVisible ? (
-
- {data.type === UnfurlType.Mention ? (
-
- ) : data.type === UnfurlType.Document ? (
-
- ) : (
-
+
+ {(data) => (
+
+ {data.type === UnfurlType.Mention ? (
+
+ ) : data.type === UnfurlType.Document ? (
+
+ ) : (
+
+ )}
+
- )}
-
-
- ) : null}
+
+ )}
+
);
}
+function DataLoader({
+ url,
+ children,
+}: {
+ url: string;
+ children: (data: any) => React.ReactNode;
+}) {
+ const { ui } = useStores();
+ const { data, request, loading } = useRequest(
+ React.useCallback(
+ () =>
+ client.post("/urls.unfurl", {
+ url,
+ documentId: ui.activeDocumentId,
+ }),
+ [url, ui.activeDocumentId]
+ )
+ );
+
+ React.useEffect(() => {
+ if (url) {
+ void request();
+ }
+ }, [url, request]);
+
+ if (loading) {
+ return ;
+ }
+
+ if (!data) {
+ return null;
+ }
+
+ return <>{children(data)}>;
+}
+
function HoverPreview({ element, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
- return ;
+ return ;
+}
+
+function useHoverPosition({
+ cardRef,
+ element,
+ isVisible,
+}: {
+ cardRef: React.RefObject;
+ element: HTMLElement | null;
+ isVisible: boolean;
+}) {
+ const [cardLeft, setCardLeft] = React.useState(0);
+ const [cardTop, setCardTop] = React.useState(0);
+ const [pointerLeft, setPointerLeft] = React.useState(0);
+ const [pointerTop, setPointerTop] = React.useState(0);
+ const [pointerDir, setPointerDir] = React.useState(Direction.UP);
+
+ React.useLayoutEffect(() => {
+ if (isVisible && element && cardRef.current) {
+ const elem = element.getBoundingClientRect();
+ const card = cardRef.current.getBoundingClientRect();
+
+ let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
+ let pTop = -POINTER_HEIGHT;
+ let pDir = Direction.UP;
+ if (cTop + card.height > window.innerHeight + window.scrollY) {
+ // shift card upwards if it goes out of screen
+ const bottom = elem.top + window.scrollY;
+ cTop = bottom - card.height;
+ // shift a little further to leave some margin between card and element boundary
+ cTop -= CARD_MARGIN;
+ // pointer should be shifted downwards to align with card's bottom
+ pTop = card.height;
+ pDir = Direction.DOWN;
+ }
+ setCardTop(cTop);
+ setPointerTop(pTop);
+ setPointerDir(pDir);
+
+ let cLeft = elem.left;
+ let pLeft = elem.width / 2;
+ if (cLeft + card.width > window.innerWidth) {
+ // shift card leftwards by the amount it went out of screen
+ let shiftBy = cLeft + card.width - window.innerWidth;
+ // shift a little further to leave some margin between card and window boundary
+ shiftBy += CARD_MARGIN;
+ cLeft -= shiftBy;
+
+ // shift pointer rightwards by same amount so as to position it back correctly
+ pLeft += shiftBy;
+ }
+ setCardLeft(cLeft);
+ setPointerLeft(pLeft);
+ }
+ }, [isVisible, cardRef, element]);
+
+ return { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir };
}
const Animate = styled(m.div)`
diff --git a/app/components/Input.tsx b/app/components/Input.tsx
index 458a0f68a4021..2cd3264f7e1c2 100644
--- a/app/components/Input.tsx
+++ b/app/components/Input.tsx
@@ -111,9 +111,8 @@ export const LabelText = styled.div`
display: inline-block;
`;
-export type Props = React.InputHTMLAttributes<
- HTMLInputElement | HTMLTextAreaElement
-> & {
+export interface Props
+ extends React.InputHTMLAttributes {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
label?: string;
@@ -130,7 +129,7 @@ export type Props = React.InputHTMLAttributes<
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
-};
+}
function Input(
props: Props,
diff --git a/app/components/InputRich.tsx b/app/components/InputRich.tsx
deleted file mode 100644
index d4ec3f1e3b5bc..0000000000000
--- a/app/components/InputRich.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { observer } from "mobx-react";
-import * as React from "react";
-import { Trans } from "react-i18next";
-import styled from "styled-components";
-import Editor from "~/components/Editor";
-import { LabelText, Outline } from "~/components/Input";
-import Text from "~/components/Text";
-
-type Props = {
- label: string;
- minHeight?: number;
- maxHeight?: number;
- readOnly?: boolean;
-};
-
-function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
- const [focused, setFocused] = React.useState(false);
- const handleBlur = React.useCallback(() => {
- setFocused(false);
- }, []);
- const handleFocus = React.useCallback(() => {
- setFocused(true);
- }, []);
-
- return (
- <>
- {label}
-
-
- Loading editor…
-
- }
- >
-
-
-
- >
- );
-}
-
-const StyledOutline = styled(Outline)<{
- minHeight?: number;
- maxHeight?: number;
- focused?: boolean;
-}>`
- display: block;
- padding: 8px 12px;
- min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
- max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
- overflow-y: auto;
-
- > * {
- display: block;
- }
-`;
-
-export default observer(InputRich);
diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx
index ee345c73c6b87..5e73300c87a51 100644
--- a/app/components/InputSelect.tsx
+++ b/app/components/InputSelect.tsx
@@ -46,6 +46,10 @@ export type Props = {
onChange?: (value: string | null) => void;
};
+interface InnerProps extends React.HTMLAttributes {
+ placement: Placement;
+}
+
const getOptionFromValue = (options: Option[], value: string | null) =>
options.find((option) => option.value === value);
@@ -147,11 +151,7 @@ const InputSelect = (props: Props) => {
)}
- {(
- props: React.HTMLAttributes & {
- placement: Placement;
- }
- ) => {
+ {(props: InnerProps) => {
const topAnchor = props.style?.top === "0";
const rightAnchor = props.placement === "bottom-end";
diff --git a/app/components/InputSelectRole.tsx b/app/components/InputSelectRole.tsx
index 15aa55114acf4..10d526a81d784 100644
--- a/app/components/InputSelectRole.tsx
+++ b/app/components/InputSelectRole.tsx
@@ -18,7 +18,7 @@ const InputSelectRole = (
label={t("Role")}
options={[
{
- label: t("Member"),
+ label: t("Editor"),
value: "member",
},
{
diff --git a/app/components/LanguagePrompt.tsx b/app/components/LanguagePrompt.tsx
index 73425f4ed5ae8..c84c6b6ec8af5 100644
--- a/app/components/LanguagePrompt.tsx
+++ b/app/components/LanguagePrompt.tsx
@@ -42,7 +42,7 @@ function Icon({ className }: { className?: string }) {
}
export default function LanguagePrompt() {
- const { auth, ui } = useStores();
+ const { ui } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const language = detectLanguage();
@@ -75,9 +75,7 @@ export default function LanguagePrompt() {
{
ui.setLanguagePromptDismissed();
- await auth.updateUser({
- language,
- });
+ await user.save({ language });
}}
>
{t("Change Language")}
diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx
index 4d4f8954f4ca0..990727062ad5b 100644
--- a/app/components/Layout.tsx
+++ b/app/components/Layout.tsx
@@ -22,12 +22,10 @@ type Props = {
sidebarRight?: React.ReactNode;
};
-const Layout: React.FC = ({
- title,
- children,
- sidebar,
- sidebarRight,
-}: Props) => {
+const Layout = React.forwardRef(function Layout_(
+ { title, children, sidebar, sidebarRight }: Props,
+ ref: React.RefObject
+) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
@@ -40,7 +38,7 @@ const Layout: React.FC = ({
});
return (
-
+
{title ? title : env.APP_NAME}
@@ -75,7 +73,7 @@ const Layout: React.FC = ({
);
-};
+});
const Container = styled(Flex)`
background: ${s("background")};
diff --git a/app/components/LocaleTime.tsx b/app/components/LocaleTime.tsx
index f32a3af5089a9..4579445b4e85a 100644
--- a/app/components/LocaleTime.tsx
+++ b/app/components/LocaleTime.tsx
@@ -20,7 +20,7 @@ function eachMinute(fn: () => void) {
};
}
-type Props = {
+export type Props = {
children?: React.ReactNode;
dateTime: string;
tooltipDelay?: number;
diff --git a/app/components/MobileScrollWrapper.tsx b/app/components/MobileScrollWrapper.tsx
new file mode 100644
index 0000000000000..14dbed872c3f1
--- /dev/null
+++ b/app/components/MobileScrollWrapper.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+import styled from "styled-components";
+import useMobile from "~/hooks/useMobile";
+
+type Props = {
+ children: React.ReactNode;
+};
+
+const MobileWrapper = styled.div`
+ width: 100vw;
+ height: 100vh;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+`;
+
+const MobileScrollWrapper = ({ children }: Props) => {
+ const isMobile = useMobile();
+ return isMobile ? {children} : <>{children}>;
+};
+
+export default MobileScrollWrapper;
diff --git a/app/components/Notifications/NotificationListItem.tsx b/app/components/Notifications/NotificationListItem.tsx
index 3f7f8e4aab65f..bab0a78ea6bd5 100644
--- a/app/components/Notifications/NotificationListItem.tsx
+++ b/app/components/Notifications/NotificationListItem.tsx
@@ -64,7 +64,6 @@ function NotificationListItem({ notification, onNavigate }: Props) {
{notification.comment && (
)}
diff --git a/app/components/PaginatedList.test.tsx b/app/components/PaginatedList.test.tsx
index 079757954626a..6a7dd8c4ee4c1 100644
--- a/app/components/PaginatedList.test.tsx
+++ b/app/components/PaginatedList.test.tsx
@@ -3,8 +3,7 @@ import { shallow } from "enzyme";
import { TFunction } from "i18next";
import * as React from "react";
import { getI18n } from "react-i18next";
-import RootStore from "~/stores/RootStore";
-import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
+import { Pagination } from "@shared/constants";
import { runAllPromises } from "~/test/support";
import { Component as PaginatedList } from "./PaginatedList";
@@ -12,17 +11,12 @@ describe("PaginatedList", () => {
const render = () => null;
const i18n = getI18n();
- const { logout, ...store } = new RootStore();
const props = {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
- logout: () => {
- //
- },
- ...store,
- };
+ } as any;
it("with no items renders nothing", () => {
const list = shallow(
@@ -59,13 +53,13 @@ describe("PaginatedList", () => {
);
expect(fetch).toHaveBeenCalledWith({
...options,
- limit: DEFAULT_PAGINATION_LIMIT,
+ limit: Pagination.defaultLimit,
offset: 0,
});
});
it("calls fetch when options prop changes", async () => {
- const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill(undefined);
+ const fetchedItems = Array(Pagination.defaultLimit).fill(undefined);
const fetch = jest.fn().mockReturnValue(Promise.resolve(fetchedItems));
const list = shallow(
{
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "one",
- limit: DEFAULT_PAGINATION_LIMIT,
+ limit: Pagination.defaultLimit,
offset: 0,
});
fetch.mockReset();
@@ -95,7 +89,7 @@ describe("PaginatedList", () => {
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "two",
- limit: DEFAULT_PAGINATION_LIMIT,
+ limit: Pagination.defaultLimit,
offset: 0,
});
});
diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx
index 407bbb0383827..3daea4bed08c9 100644
--- a/app/components/PaginatedList.tsx
+++ b/app/components/PaginatedList.tsx
@@ -5,8 +5,8 @@ import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
+import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
-import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
@@ -86,7 +86,7 @@ class PaginatedList extends React.Component> {
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
- this.renderCount = DEFAULT_PAGINATION_LIMIT;
+ this.renderCount = Pagination.defaultLimit;
this.isFetching = false;
this.isFetchingInitial = false;
this.isFetchingMore = false;
@@ -99,7 +99,7 @@ class PaginatedList extends React.Component> {
}
this.isFetching = true;
const counter = ++this.fetchCounter;
- const limit = this.props.options?.limit ?? DEFAULT_PAGINATION_LIMIT;
+ const limit = this.props.options?.limit ?? Pagination.defaultLimit;
this.error = undefined;
try {
@@ -139,12 +139,12 @@ class PaginatedList extends React.Component> {
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 0) {
- this.renderCount += DEFAULT_PAGINATION_LIMIT;
+ this.renderCount += Pagination.defaultLimit;
}
// If there are less than a pages results in the cache go ahead and fetch
// another page from the server
- if (leftToRender <= DEFAULT_PAGINATION_LIMIT) {
+ if (leftToRender <= Pagination.defaultLimit) {
this.isFetchingMore = true;
await this.fetchResults();
}
diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx
index eaa784c3cc01f..268d75fecafff 100644
--- a/app/components/Popover.tsx
+++ b/app/components/Popover.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
-import styled, { css } from "styled-components";
+import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import useKeyDown from "~/hooks/useKeyDown";
@@ -95,10 +95,13 @@ const Contents = styled.div`
width: ${(props) => props.$width}px;
${(props) =>
- props.$scrollable &&
- css`
+ props.$scrollable
+ ? `
overflow-x: hidden;
overflow-y: auto;
+ `
+ : `
+ overflow: hidden;
`}
${breakpoint("mobile", "tablet")`
diff --git a/app/components/Scene.tsx b/app/components/Scene.tsx
index 3e543bb41e18a..d01ce177abca6 100644
--- a/app/components/Scene.tsx
+++ b/app/components/Scene.tsx
@@ -5,12 +5,21 @@ import Header from "~/components/Header";
import PageTitle from "~/components/PageTitle";
type Props = {
+ /** An icon to display in the header when content has scrolled past the title */
icon?: React.ReactNode;
+ /** The title of the scene */
title?: React.ReactNode;
+ /** The title of the scene, as text – only required if the title prop is not plain text */
textTitle?: string;
+ /** A component to display on the left side of the header */
left?: React.ReactNode;
+ /** A component to display on the right side of the header */
actions?: React.ReactNode;
+ /** Whether to center the content horizontally with the standard maximum width (default: true) */
centered?: boolean;
+ /** Whether to use the full width of the screen (default: false) */
+ wide?: boolean;
+ /** The content of the scene */
children?: React.ReactNode;
};
@@ -22,6 +31,7 @@ const Scene: React.FC = ({
left,
children,
centered,
+ wide,
}: Props) => (
@@ -40,7 +50,9 @@ const Scene: React.FC = ({
left={left}
/>
{centered !== false ? (
- {children}
+
+ {children}
+
) : (
children
)}
diff --git a/app/components/SearchActions.ts b/app/components/SearchActions.ts
index 62577b55a1754..81f1f8dd9a62f 100644
--- a/app/components/SearchActions.ts
+++ b/app/components/SearchActions.ts
@@ -11,7 +11,9 @@ export default function SearchActions() {
React.useEffect(() => {
if (!searches.isLoaded) {
- void searches.fetchPage({});
+ void searches.fetchPage({
+ source: "app",
+ });
}
}, [searches]);
diff --git a/app/components/SearchListItem.tsx b/app/components/SearchListItem.tsx
index 23483ffde233e..e9c5a98eb0880 100644
--- a/app/components/SearchListItem.tsx
+++ b/app/components/SearchListItem.tsx
@@ -116,7 +116,7 @@ const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
- height: 18px;
+ height: 22px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
@@ -138,7 +138,7 @@ const ResultContext = styled(Highlight)`
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
- margin-bottom: 0.25em;
+ margin-bottom: 0;
${ellipsis()}
${Mark} {
diff --git a/app/components/SearchPopover.tsx b/app/components/SearchPopover.tsx
index ad696a9a0c863..1ce8387b7d982 100644
--- a/app/components/SearchPopover.tsx
+++ b/app/components/SearchPopover.tsx
@@ -17,7 +17,9 @@ import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
-type Props = React.HTMLAttributes & { shareId: string };
+interface Props extends React.HTMLAttributes {
+ shareId: string;
+}
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
@@ -31,9 +33,11 @@ function SearchPopover({ shareId }: Props) {
});
const [query, setQuery] = React.useState("");
- const searchResults = documents.searchResults(query);
const { show, hide } = popover;
+ const [searchResults, setSearchResults] = React.useState<
+ PaginatedItem[] | undefined
+ >();
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
@@ -50,7 +54,16 @@ function SearchPopover({ shareId }: Props) {
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
- return await documents.search(query, { shareId, ...options });
+ const response: PaginatedItem[] = await documents.search(query, {
+ shareId,
+ ...options,
+ });
+
+ if (response.length) {
+ setSearchResults(response);
+ }
+
+ return response;
}
return undefined;
},
diff --git a/app/components/Sidebar/Right.tsx b/app/components/Sidebar/Right.tsx
index 43bf1a9ee1ef5..2ebf8c28fdad3 100644
--- a/app/components/Sidebar/Right.tsx
+++ b/app/components/Sidebar/Right.tsx
@@ -11,10 +11,10 @@ import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { sidebarAppearDuration } from "~/styles/animations";
-type Props = React.HTMLAttributes & {
+interface Props extends React.HTMLAttributes {
children: React.ReactNode;
border?: boolean;
-};
+}
function Right({ children, border, className }: Props) {
const theme = useTheme();
@@ -134,7 +134,7 @@ const Sidebar = styled(m.div)<{
top: 0;
right: 0;
bottom: 0;
- z-index: ${depths.sidebar};
+ z-index: ${depths.mobileSidebar};
`}
${breakpoint("tablet")`
diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx
index 7efaf46ec5a39..faa63a4af8ab2 100644
--- a/app/components/Sidebar/Shared.tsx
+++ b/app/components/Sidebar/Shared.tsx
@@ -5,6 +5,7 @@ import styled from "styled-components";
import { NavigationNode } from "@shared/types";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
+import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
@@ -22,7 +23,8 @@ type Props = {
function SharedSidebar({ rootNode, shareId }: Props) {
const team = useTeamContext();
- const { ui, documents, auth } = useStores();
+ const user = useCurrentUser({ rejectOnEmpty: false });
+ const { ui, documents } = useStores();
const { t } = useTranslation();
return (
@@ -33,7 +35,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
image={}
onClick={() =>
history.push(
- auth.user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
+ user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
)
}
/>
diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx
index 60227e6945ad6..23e1edc57885a 100644
--- a/app/components/Sidebar/Sidebar.tsx
+++ b/app/components/Sidebar/Sidebar.tsx
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
-import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Flex from "~/components/Flex";
+import useCurrentUser from "~/hooks/useCurrentUser";
import useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
@@ -33,11 +33,11 @@ const Sidebar = React.forwardRef(function _Sidebar(
) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
- const { ui, auth } = useStores();
+ const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
- const { user } = auth;
+ const user = useCurrentUser({ rejectOnEmpty: false });
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
@@ -191,11 +191,6 @@ const Sidebar = React.forwardRef(function _Sidebar(
onPointerLeave={handlePointerLeave}
column
>
- {ui.mobileSidebarVisible && (
-
-
-
- )}
{children}
{user && (
@@ -234,6 +229,7 @@ const Sidebar = React.forwardRef(function _Sidebar(
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
+ {ui.mobileSidebarVisible && }
>
);
});
@@ -246,7 +242,7 @@ const Backdrop = styled.a`
bottom: 0;
right: 0;
cursor: default;
- z-index: ${depths.sidebar - 1};
+ z-index: ${depths.mobileSidebar - 1};
background: ${s("backdrop")};
`;
@@ -287,7 +283,7 @@ const Container = styled(Flex)`
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
- z-index: ${depths.sidebar};
+ z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
${fadeOnDesktopBackgrounded()}
@@ -302,6 +298,7 @@ const Container = styled(Flex)`
}
${breakpoint("tablet")`
+ z-index: ${depths.sidebar};
margin: 0;
min-width: 0;
transform: translateX(${(props: ContainerProps) =>
diff --git a/app/components/Sidebar/components/NavLink.tsx b/app/components/Sidebar/components/NavLink.tsx
index afb438d6e311d..37c1a78607321 100644
--- a/app/components/Sidebar/components/NavLink.tsx
+++ b/app/components/Sidebar/components/NavLink.tsx
@@ -29,7 +29,7 @@ const normalizeToLocation = (
const joinClassnames = (...classnames: (string | undefined)[]) =>
classnames.filter((i) => i).join(" ");
-export type Props = React.AnchorHTMLAttributes & {
+export interface Props extends React.AnchorHTMLAttributes {
activeClassName?: string;
activeStyle?: React.CSSProperties;
scrollIntoViewIfNeeded?: boolean;
@@ -40,7 +40,7 @@ export type Props = React.AnchorHTMLAttributes & {
strict?: boolean;
to: LocationDescriptor;
onBeforeClick?: () => void;
-};
+}
/**
* A wrapper that clicks extra fast and knows if it's "active" or not.
diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx
index f4c230fead447..544897027f1f2 100644
--- a/app/components/Sidebar/components/Starred.tsx
+++ b/app/components/Sidebar/components/Starred.tsx
@@ -1,11 +1,11 @@
-import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
-import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
import Star from "~/models/Star";
import DelayedMount from "~/components/DelayedMount";
import Flex from "~/components/Flex";
+import usePaginatedRequest from "~/hooks/usePaginatedRequest";
import useStores from "~/hooks/useStores";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -14,53 +14,25 @@ import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import StarredContext from "./StarredContext";
import StarredLink from "./StarredLink";
+import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
const STARRED_PAGINATION_LIMIT = 10;
function Starred() {
- const [fetchError, setFetchError] = React.useState();
- const [displayedStarsCount, setDisplayedStarsCount] = React.useState(
- STARRED_PAGINATION_LIMIT
- );
const { stars } = useStores();
const { t } = useTranslation();
- const fetchResults = React.useCallback(
- async (offset = 0) => {
- try {
- await stars.fetchPage({
- limit: STARRED_PAGINATION_LIMIT + 1,
- offset,
- });
- } catch (error) {
- setFetchError(error);
- }
- },
- [stars]
+ const { loading, next, end, error, page } = usePaginatedRequest(
+ stars.fetchPage
);
+ const [reorderStarMonitor, dropToReorder] = useDropToReorderStar();
+ const [createStarMonitor, dropToStarRef] = useDropToCreateStar();
React.useEffect(() => {
- void fetchResults();
- }, []);
-
- const handleShowMore = async () => {
- await fetchResults(displayedStarsCount);
- setDisplayedStarsCount((prev) => prev + STARRED_PAGINATION_LIMIT);
- };
-
- // Drop to reorder document
- const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({
- accept: "star",
- drop: async (item: { star: Star }) => {
- void item.star.save({
- index: fractionalIndex(null, stars.orderedData[0].index),
- });
- },
- collect: (monitor) => ({
- isOverReorder: !!monitor.isOver(),
- isDraggingAnyStar: monitor.getItemType() === "star",
- }),
- });
+ if (error) {
+ toast.error(t("Could not load starred documents"));
+ }
+ }, [t, error]);
if (!stars.orderedData.length) {
return null;
@@ -71,25 +43,34 @@ function Starred() {
- {isDraggingAnyStar && (
+ {reorderStarMonitor.isDragging && (
)}
- {stars.orderedData.slice(0, displayedStarsCount).map((star) => (
-
- ))}
- {stars.orderedData.length > displayedStarsCount && (
+ {createStarMonitor.isDragging && (
+
+ )}
+ {stars.orderedData
+ .slice(0, page * STARRED_PAGINATION_LIMIT)
+ .map((star) => (
+
+ ))}
+ {!end && (
)}
- {(stars.isFetching || fetchError) && !stars.orderedData.length && (
+ {loading && (
diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx
index caa703eefbf29..0378a94a5c073 100644
--- a/app/components/Sidebar/components/StarredLink.tsx
+++ b/app/components/Sidebar/components/StarredLink.tsx
@@ -1,17 +1,12 @@
import fractionalIndex from "fractional-index";
import { Location } from "history";
import { observer } from "mobx-react";
-import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useState } from "react";
-import { useDrag, useDrop } from "react-dnd";
-import { getEmptyImage } from "react-dnd-html5-backend";
import { useLocation } from "react-router-dom";
-import styled, { useTheme } from "styled-components";
+import styled from "styled-components";
import Star from "~/models/Star";
import Fade from "~/components/Fade";
-import CollectionIcon from "~/components/Icons/CollectionIcon";
-import EmojiIcon from "~/components/Icons/EmojiIcon";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
@@ -22,6 +17,12 @@ import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
+import {
+ useDragStar,
+ useDropToCreateStar,
+ useDropToReorderStar,
+} from "./useDragAndDrop";
+import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
type Props = {
star: Star;
@@ -34,40 +35,6 @@ function useLocationStateStarred() {
return location.state?.starred;
}
-function useLabelAndIcon({ documentId, collectionId }: Star) {
- const { collections, documents } = useStores();
- const theme = useTheme();
-
- if (documentId) {
- const document = documents.get(documentId);
- if (document) {
- return {
- label: document.titleWithDefault,
- icon: document.emoji ? (
-
- ) : (
-
- ),
- };
- }
- }
-
- if (collectionId) {
- const collection = collections.get(collectionId);
- if (collection) {
- return {
- label: collection.name,
- icon: ,
- };
- }
- }
-
- return {
- label: "",
- icon: ,
- };
-}
-
function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -99,54 +66,40 @@ function StarredLink({ star }: Props) {
[]
);
- const { label, icon } = useLabelAndIcon(star);
-
- // Draggable
- const [{ isDragging }, drag, preview] = useDrag({
- type: "star",
- item: () => ({
- star,
- title: label,
- icon,
- }),
- collect: (monitor) => ({
- isDragging: !!monitor.isDragging(),
- }),
- canDrag: () => true,
- });
-
- React.useEffect(() => {
- preview(getEmptyImage(), { captureDraggingState: true });
- }, [preview]);
-
- // Drop to reorder
- const [{ isOverReorder, isDraggingAny }, dropToReorder] = useDrop({
- accept: "star",
- drop: (item: { star: Star }) => {
- const next = star?.next();
-
- void item.star.save({
- index: fractionalIndex(star?.index || null, next?.index || null),
- });
- },
- collect: (monitor) => ({
- isOverReorder: !!monitor.isOver(),
- isDraggingAny: !!monitor.canDrop(),
- }),
- });
+ const getIndex = () => {
+ const next = star?.next();
+ return fractionalIndex(star?.index || null, next?.index || null);
+ };
+ const { label, icon } = useStarLabelAndIcon(star);
+ const [{ isDragging }, draggableRef] = useDragStar(star);
+ const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
+ const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);
const displayChildDocuments = expanded && !isDragging;
+ const cursor = (
+ <>
+ {reorderStarMonitor.isDragging && (
+
+ )}
+ {createStarMonitor.isDragging && (
+
+ )}
+ >
+ );
+
if (documentId) {
const document = documents.get(documentId);
if (!document) {
return null;
}
- const { emoji } = document;
- const label = emoji
- ? document.title.replace(emoji, "")
- : document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -157,7 +110,7 @@ function StarredLink({ star }: Props) {
return (
<>
-
+
))}
- {isDraggingAny && (
-
- )}
+ {cursor}
>
);
@@ -211,13 +162,13 @@ function StarredLink({ star }: Props) {
if (collection) {
return (
<>
-
+
@@ -225,9 +176,7 @@ function StarredLink({ star }: Props) {
collection={collection}
expanded={displayChildDocuments}
/>
- {isDraggingAny && (
-
- )}
+ {cursor}
>
);
diff --git a/app/components/Sidebar/components/useDragAndDrop.ts b/app/components/Sidebar/components/useDragAndDrop.ts
new file mode 100644
index 0000000000000..b95104fc2979b
--- /dev/null
+++ b/app/components/Sidebar/components/useDragAndDrop.ts
@@ -0,0 +1,83 @@
+import fractionalIndex from "fractional-index";
+import * as React from "react";
+import { ConnectDragSource, useDrag, useDrop } from "react-dnd";
+import { getEmptyImage } from "react-dnd-html5-backend";
+import Star from "~/models/Star";
+import useStores from "~/hooks/useStores";
+import { DragObject } from "./SidebarLink";
+import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
+
+/**
+ * Hook for shared logic that allows dragging a Starred item
+ *
+ * @param star The related Star model.
+ */
+export function useDragStar(
+ star: Star
+): [{ isDragging: boolean }, ConnectDragSource] {
+ const id = star.id;
+ const { label: title, icon } = useStarLabelAndIcon(star);
+ const [{ isDragging }, draggableRef, preview] = useDrag({
+ type: "star",
+ item: () => ({ id, title, icon }),
+ collect: (monitor) => ({
+ isDragging: !!monitor.isDragging(),
+ }),
+ canDrag: () => true,
+ });
+
+ React.useEffect(() => {
+ preview(getEmptyImage(), { captureDraggingState: true });
+ }, [preview]);
+
+ return [{ isDragging }, draggableRef];
+}
+
+/**
+ * Hook for shared logic that allows dropping documents and collections to create a star
+ *
+ * @param getIndex A function to get the index of the current item where the star should be inserted.
+ */
+export function useDropToCreateStar(getIndex?: () => string) {
+ const { documents, stars, collections } = useStores();
+
+ return useDrop({
+ accept: ["document", "collection"],
+ drop: async (item: DragObject) => {
+ const model = documents.get(item.id) ?? collections?.get(item.id);
+ await model?.star(
+ getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index)
+ );
+ },
+ collect: (monitor) => ({
+ isOverCursor: !!monitor.isOver(),
+ isDragging: ["document", "collection"].includes(
+ String(monitor.getItemType())
+ ),
+ }),
+ });
+}
+
+/**
+ * Hook for shared logic that allows dropping stars to reorder
+ *
+ * @param getIndex A function to get the index of the current item where the star should be inserted.
+ */
+export function useDropToReorderStar(getIndex?: () => string) {
+ const { stars } = useStores();
+
+ return useDrop({
+ accept: "star",
+ drop: async (item: DragObject) => {
+ const star = stars.get(item.id);
+ void star?.save({
+ index:
+ getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index),
+ });
+ },
+ collect: (monitor) => ({
+ isOverCursor: !!monitor.isOver(),
+ isDragging: monitor.getItemType() === "star",
+ }),
+ });
+}
diff --git a/app/components/Sidebar/components/useStarLabelAndIcon.tsx b/app/components/Sidebar/components/useStarLabelAndIcon.tsx
new file mode 100644
index 0000000000000..1595d302205b5
--- /dev/null
+++ b/app/components/Sidebar/components/useStarLabelAndIcon.tsx
@@ -0,0 +1,41 @@
+import { StarredIcon } from "outline-icons";
+import * as React from "react";
+import { useTheme } from "styled-components";
+import Star from "~/models/Star";
+import CollectionIcon from "~/components/Icons/CollectionIcon";
+import EmojiIcon from "~/components/Icons/EmojiIcon";
+import useStores from "~/hooks/useStores";
+
+export function useStarLabelAndIcon({ documentId, collectionId }: Star) {
+ const { collections, documents } = useStores();
+ const theme = useTheme();
+
+ if (documentId) {
+ const document = documents.get(documentId);
+ if (document) {
+ return {
+ label: document.titleWithDefault,
+ icon: document.emoji ? (
+
+ ) : (
+
+ ),
+ };
+ }
+ }
+
+ if (collectionId) {
+ const collection = collections.get(collectionId);
+ if (collection) {
+ return {
+ label: collection.name,
+ icon: ,
+ };
+ }
+ }
+
+ return {
+ label: "",
+ icon: ,
+ };
+}
diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx
index ea7afae0da1c0..d2998a902abe0 100644
--- a/app/components/Switch.tsx
+++ b/app/components/Switch.tsx
@@ -5,7 +5,7 @@ import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
-type Props = React.HTMLAttributes & {
+interface Props extends React.HTMLAttributes {
width?: number;
height?: number;
label?: string;
@@ -14,8 +14,7 @@ type Props = React.HTMLAttributes & {
checked?: boolean;
disabled?: boolean;
onChange: (event: React.ChangeEvent) => unknown;
- id?: string;
-};
+}
function Switch({
width = 32,
diff --git a/app/components/Table.tsx b/app/components/Table.tsx
index 66b00b777589e..52d01ef4fd1ce 100644
--- a/app/components/Table.tsx
+++ b/app/components/Table.tsx
@@ -309,6 +309,7 @@ const Head = styled.th`
color: ${s("textSecondary")};
font-weight: 500;
z-index: 1;
+ cursor: var(--pointer) !important;
:first-child {
padding-left: 0;
diff --git a/app/components/Time.tsx b/app/components/Time.tsx
index 99336de4c2871..b1beabe7f4519 100644
--- a/app/components/Time.tsx
+++ b/app/components/Time.tsx
@@ -1,10 +1,11 @@
import * as React from "react";
import { dateToRelative } from "@shared/utils/date";
+import type { Props as LocaleTimeProps } from "~/components/LocaleTime";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LocaleTime = lazyWithRetry(() => import("~/components/LocaleTime"));
-type Props = React.ComponentProps & {
+type Props = LocaleTimeProps & {
onClick?: () => void;
};
diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx
index 67489fd288a07..64bf735441cdb 100644
--- a/app/components/Tooltip.tsx
+++ b/app/components/Tooltip.tsx
@@ -3,6 +3,7 @@ import * as React from "react";
import styled, { createGlobalStyle } from "styled-components";
import { roundArrow } from "tippy.js";
import { s } from "@shared/styles";
+import useMobile from "~/hooks/useMobile";
export type Props = Omit & {
tooltip?: React.ReactChild | React.ReactChild[];
@@ -10,9 +11,11 @@ export type Props = Omit & {
};
function Tooltip({ shortcut, tooltip, delay = 50, ...rest }: Props) {
+ const isMobile = useMobile();
+
let content = <>{tooltip}>;
- if (!tooltip) {
+ if (!tooltip || isMobile) {
return rest.children ?? null;
}
diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx
index 7aeb3c1e5dcc9..e7d6fff2fe2dc 100644
--- a/app/components/UserDialogs.tsx
+++ b/app/components/UserDialogs.tsx
@@ -21,11 +21,7 @@ export function UserChangeToViewerDialog({ user, onSubmit }: Props) {
};
return (
-
+
{t(
"Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content",
{
@@ -47,11 +43,7 @@ export function UserChangeToMemberDialog({ user, onSubmit }: Props) {
};
return (
-
+
{t("Are you sure you want to make {{ userName }} a member?", {
userName: user.name,
})}
@@ -94,11 +86,7 @@ export function UserChangeToAdminDialog({ user, onSubmit }: Props) {
};
return (
-
+
{t(
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
{
@@ -119,11 +107,7 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
};
return (
-
+
{t(
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
{
diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx
index b47bc9e080f46..b86eaafe98190 100644
--- a/app/components/WebsocketProvider.tsx
+++ b/app/components/WebsocketProvider.tsx
@@ -3,8 +3,10 @@ import find from "lodash/find";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
+import { withTranslation, WithTranslation } from "react-i18next";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
+import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -16,6 +18,7 @@ import Pin from "~/models/Pin";
import Star from "~/models/Star";
import Subscription from "~/models/Subscription";
import Team from "~/models/Team";
+import User from "~/models/User";
import withStores from "~/components/withStores";
import {
PartialWithId,
@@ -34,7 +37,7 @@ type SocketWithAuthentication = Socket & {
export const WebsocketContext =
React.createContext(null);
-type Props = RootStore;
+type Props = WithTranslation & RootStore;
@observer
class WebsocketProvider extends React.Component {
@@ -397,14 +400,20 @@ class WebsocketProvider extends React.Component {
err instanceof NotFoundError
) {
collections.remove(event.collectionId);
- memberships.remove(`${event.userId}-${event.collectionId}`);
+ memberships.revoke({
+ userId: event.userId,
+ collectionId: event.collectionId,
+ });
return;
}
}
documents.removeCollectionDocuments(event.collectionId);
} else {
- memberships.remove(`${event.userId}-${event.collectionId}`);
+ memberships.revoke({
+ userId: event.userId,
+ collectionId: event.collectionId,
+ });
}
}
);
@@ -431,6 +440,16 @@ class WebsocketProvider extends React.Component {
"fileOperations.update",
(event: PartialWithId) => {
fileOperations.add(event);
+
+ if (
+ event.state === FileOperationState.Complete &&
+ event.type === FileOperationType.Import &&
+ event.user?.id === auth.user?.id
+ ) {
+ toast.success(event.name, {
+ description: this.props.t("Your import completed"),
+ });
+ }
}
);
@@ -448,15 +467,22 @@ class WebsocketProvider extends React.Component {
}
);
+ this.socket.on("users.demote", async (event: PartialWithId) => {
+ if (auth.user && event.id === auth.user.id) {
+ documents.all.forEach((document) => policies.remove(document.id));
+ await collections.fetchAll();
+ }
+ });
+
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
- this.socket.on("join", (event: any) => {
+ this.socket.on("join", (event) => {
this.socket?.emit("join", event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
- this.socket.on("leave", (event: any) => {
+ this.socket.on("leave", (event) => {
this.socket?.emit("leave", event);
});
};
@@ -470,4 +496,4 @@ class WebsocketProvider extends React.Component {
}
}
-export default withStores(WebsocketProvider);
+export default withTranslation()(withStores(WebsocketProvider));
diff --git a/app/editor/components/BlockMenu.tsx b/app/editor/components/BlockMenu.tsx
index 06d600d3b2caa..0e697a11b6fc7 100644
--- a/app/editor/components/BlockMenu.tsx
+++ b/app/editor/components/BlockMenu.tsx
@@ -10,7 +10,7 @@ type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "trigger"
> &
- Required>;
+ Required>;
function BlockMenu(props: Props) {
const dictionary = useDictionary();
diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx
index efd24aa75f3b3..0cdb603c23dd2 100644
--- a/app/editor/components/EmojiMenu.tsx
+++ b/app/editor/components/EmojiMenu.tsx
@@ -5,6 +5,7 @@ import capitalize from "lodash/capitalize";
import sortBy from "lodash/sortBy";
import React from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
+import { isMac } from "@shared/utils/browser";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
@@ -18,12 +19,16 @@ type Emoji = {
attrs: { markup: string; "data-name": string };
};
-void init({ data });
+init({
+ data,
+ noCountryFlags: isMac() ? false : undefined,
+});
+
let searcher: FuzzySearch;
type Props = Omit<
SuggestionsMenuProps,
- "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger"
+ "renderMenuItem" | "items" | "embeds" | "trigger"
>;
const EmojiMenu = (props: Props) => {
diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx
index 0f5e9653bebde..ff21ada9e898d 100644
--- a/app/editor/components/FindAndReplace.tsx
+++ b/app/editor/components/FindAndReplace.tsx
@@ -198,7 +198,7 @@ export default function FindAndReplace({ readOnly }: Props) {
const style: React.CSSProperties = React.useMemo(
() => ({
- position: "absolute",
+ position: "fixed",
left: "initial",
top: 60,
right: 16,
@@ -263,6 +263,7 @@ export default function FindAndReplace({ readOnly }: Props) {
unstable_finalFocusRef={finalFocusRef}
style={style}
aria-label={t("Find and replace")}
+ scrollable={false}
width={420}
>
@@ -347,6 +348,7 @@ const SearchModifiers = styled(Flex)`
`;
const StyledInput = styled(Input)`
+ width: 196px;
flex: 1;
`;
@@ -365,4 +367,5 @@ const ButtonLarge = styled(ButtonSmall)`
const Content = styled(Flex)`
padding: 8px 0;
margin-bottom: -16px;
+ position: static;
`;
diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx
index d451adeb683ca..7f55010c39a39 100644
--- a/app/editor/components/FloatingToolbar.tsx
+++ b/app/editor/components/FloatingToolbar.tsx
@@ -1,6 +1,7 @@
import { NodeSelection } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import * as React from "react";
+import { Portal as ReactPortal } from "react-portal";
import styled, { css } from "styled-components";
import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
@@ -8,6 +9,8 @@ import { depths, s } from "@shared/styles";
import { Portal } from "~/components/Portal";
import useComponentSize from "~/hooks/useComponentSize";
import useEventListener from "~/hooks/useEventListener";
+import useMobile from "~/hooks/useMobile";
+import useWindowSize from "~/hooks/useWindowSize";
import Logger from "~/utils/Logger";
import { useEditor } from "./EditorContext";
@@ -181,50 +184,77 @@ function usePosition({
}
}
-const FloatingToolbar = React.forwardRef(
- (props: Props, ref: React.RefObject) => {
- const menuRef = ref || React.createRef();
- const [isSelectingText, setSelectingText] = React.useState(false);
+const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
+ props: Props,
+ ref: React.RefObject
+) {
+ const menuRef = ref || React.createRef();
+ const [isSelectingText, setSelectingText] = React.useState(false);
- let position = usePosition({
- menuRef,
- active: props.active,
- });
+ let position = usePosition({
+ menuRef,
+ active: props.active,
+ });
- if (isSelectingText) {
- position = defaultPosition;
+ if (isSelectingText) {
+ position = defaultPosition;
+ }
+
+ useEventListener("mouseup", () => {
+ setSelectingText(false);
+ });
+
+ useEventListener("mousedown", () => {
+ if (!props.active) {
+ setSelectingText(true);
}
+ });
- useEventListener("mouseup", () => {
- setSelectingText(false);
- });
-
- useEventListener("mousedown", () => {
- if (!props.active) {
- setSelectingText(true);
- }
- });
-
- return (
-
-
- {props.children}
-
-
- );
+ const isMobile = useMobile();
+ const { height } = useWindowSize();
+
+ if (isMobile) {
+ if (!props.children) {
+ return null;
+ }
+
+ if (props.active) {
+ const rect = document.body.getBoundingClientRect();
+ return (
+
+
+ {props.children}
+
+
+ );
+ }
+
+ return null;
}
-);
+
+ return (
+
+
+ {props.children}
+
+
+ );
+});
type WrapperProps = {
active?: boolean;
@@ -252,6 +282,28 @@ const arrow = (props: WrapperProps) =>
`
: "";
+const MobileWrapper = styled.div`
+ position: absolute;
+ left: 0;
+ right: 0;
+
+ width: 100vw;
+ padding: 10px 6px;
+ background-color: ${s("menuBackground")};
+ border-top: 1px solid ${s("divider")};
+ box-sizing: border-box;
+ z-index: ${depths.editorToolbar};
+
+ &:after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 100px;
+ background-color: ${s("menuBackground")};
+ }
+`;
+
const Wrapper = styled.div`
will-change: opacity, transform;
padding: 6px;
diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx
index 43a75a3272ee9..fd83ac88496c7 100644
--- a/app/editor/components/LinkEditor.tsx
+++ b/app/editor/components/LinkEditor.tsx
@@ -25,7 +25,7 @@ import Tooltip from "./Tooltip";
export type SearchResult = {
title: string;
- subtitle?: string;
+ subtitle?: React.ReactNode;
url: string;
};
diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx
index ed9ed384c5474..dc90d77fd113a 100644
--- a/app/editor/components/MentionMenu.tsx
+++ b/app/editor/components/MentionMenu.tsx
@@ -32,14 +32,14 @@ interface MentionItem extends MenuItem {
type Props = Omit<
SuggestionsMenuProps,
- "renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "trigger"
+ "renderMenuItem" | "items" | "embeds" | "trigger"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState([]);
const { t } = useTranslation();
- const { users, auth } = useStores();
+ const { auth, users } = useStores();
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const { data, loading, request } = useRequest(
@@ -69,7 +69,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
id: v4(),
type: MentionType.User,
modelId: user.id,
- actorId: auth.user?.id,
+ actorId: auth.currentUserId ?? undefined,
label: user.name,
},
}));
@@ -77,7 +77,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
- }, [auth.user?.id, loading, data]);
+ }, [auth.currentUserId, loading, data]);
// Prevent showing the menu until we have data otherwise it will be positioned
// incorrectly due to the height being unknown.
diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx
index 5798a9d74453c..1bb44571801de 100644
--- a/app/editor/components/SelectionToolbar.tsx
+++ b/app/editor/components/SelectionToolbar.tsx
@@ -33,6 +33,7 @@ type Props = {
isTemplate: boolean;
readOnly?: boolean;
canComment?: boolean;
+ canUpdate?: boolean;
onOpen: () => void;
onClose: () => void;
onSearchLink?: (term: string) => Promise;
@@ -197,12 +198,12 @@ export default function SelectionToolbar(props: Props) {
);
};
- const { onCreateLink, isTemplate, rtl, canComment, ...rest } = props;
+ const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
+ props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
- // no toolbar in read-only without commenting or when dragging
if ((readOnly && !canComment) || isDragging) {
return null;
}
@@ -231,7 +232,7 @@ export default function SelectionToolbar(props: Props) {
} else if (isDividerSelection) {
items = getDividerMenuItems(state, dictionary);
} else if (readOnly) {
- items = getReadOnlyMenuItems(state, dictionary);
+ items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
} else {
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
}
@@ -244,6 +245,9 @@ export default function SelectionToolbar(props: Props) {
if (item.name && !commands[item.name]) {
return false;
}
+ if (item.visible === false) {
+ return false;
+ }
return true;
});
diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx
index 1866a65a0ddcb..407a75209d227 100644
--- a/app/editor/components/SuggestionsMenu.tsx
+++ b/app/editor/components/SuggestionsMenu.tsx
@@ -60,7 +60,6 @@ export type Props = {
uploadFile?: (file: File) => Promise;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
- onLinkToolbarOpen?: () => void;
onClose: (insertNewLine?: boolean) => void;
embeds?: EmbedDescriptor[];
renderMenuItem: (
@@ -252,17 +251,11 @@ function SuggestionsMenu(props: Props) {
return triggerFilePick("*");
case "embed":
return triggerLinkInput(item);
- case "link": {
- handleClearSearch();
- props.onClose();
- props.onLinkToolbarOpen?.();
- return;
- }
default:
insertNode(item);
}
},
- [insertNode, handleClearSearch, props]
+ [insertNode]
);
const close = React.useCallback(() => {
diff --git a/shared/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx
similarity index 66%
rename from shared/editor/extensions/BlockMenu.tsx
rename to app/editor/extensions/BlockMenu.tsx
index f2173239aa519..8413b1a7ae993 100644
--- a/shared/editor/extensions/BlockMenu.tsx
+++ b/app/editor/extensions/BlockMenu.tsx
@@ -1,24 +1,24 @@
+import { action } from "mobx";
import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
-import { SuggestionsMenuType } from "../plugins/Suggestions";
-import { findParentNode } from "../queries/findParentNode";
-import { EventType } from "../types";
-import Suggestion from "./Suggestion";
+import { WidgetProps } from "@shared/editor/lib/Extension";
+import { findParentNode } from "@shared/editor/queries/findParentNode";
+import Suggestion from "~/editor/extensions/Suggestion";
+import BlockMenu from "../components/BlockMenu";
-export default class BlockMenu extends Suggestion {
+export default class BlockMenuExtension extends Suggestion {
get defaultOptions() {
return {
- type: SuggestionsMenuType.Block,
openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
};
}
get name() {
- return "blockmenu";
+ return "block-menu";
}
get plugins() {
@@ -54,12 +54,12 @@ export default class BlockMenu extends Suggestion {
Decoration.widget(
parent.pos,
() => {
- button.addEventListener("click", () => {
- this.editor.events.emit(EventType.SuggestionsMenuOpen, {
- type: SuggestionsMenuType.Block,
- query: "",
- });
- });
+ button.addEventListener(
+ "click",
+ action(() => {
+ this.state.open = true;
+ })
+ );
return button;
},
{
@@ -96,4 +96,28 @@ export default class BlockMenu extends Suggestion {
}),
];
}
+
+ widget = ({ rtl }: WidgetProps) => {
+ const { props, view } = this.editor;
+ return (
+ {
+ if (insertNewLine) {
+ const transaction = view.state.tr.split(view.state.selection.to);
+ view.dispatch(transaction);
+ view.focus();
+ }
+
+ this.state.open = false;
+ })}
+ uploadFile={props.uploadFile}
+ onFileUploadStart={props.onFileUploadStart}
+ onFileUploadStop={props.onFileUploadStop}
+ embeds={props.embeds}
+ />
+ );
+ };
}
diff --git a/app/editor/extensions/EmojiMenu.tsx b/app/editor/extensions/EmojiMenu.tsx
new file mode 100644
index 0000000000000..813fecc028869
--- /dev/null
+++ b/app/editor/extensions/EmojiMenu.tsx
@@ -0,0 +1,45 @@
+import { action } from "mobx";
+import * as React from "react";
+import { WidgetProps } from "@shared/editor/lib/Extension";
+import Suggestion from "~/editor/extensions/Suggestion";
+import EmojiMenu from "../components/EmojiMenu";
+
+/**
+ * Languages using the colon character with a space in front in standard
+ * punctuation. In this case the trigger is only matched once there is additional
+ * text after the colon.
+ */
+const languagesUsingColon = ["fr"];
+
+export default class EmojiMenuExtension extends Suggestion {
+ get defaultOptions() {
+ const languageIsUsingColon =
+ typeof window === "undefined"
+ ? false
+ : languagesUsingColon.includes(window.navigator.language.slice(0, 2));
+
+ return {
+ openRegex: new RegExp(
+ `(?:^|\\s|\\():([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
+ ),
+ closeRegex:
+ /(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
+ enabledInTable: true,
+ };
+ }
+
+ get name() {
+ return "emoji-menu";
+ }
+
+ widget = ({ rtl }: WidgetProps) => (
+ {
+ this.state.open = false;
+ })}
+ />
+ );
+}
diff --git a/shared/editor/extensions/FindAndReplace.ts b/app/editor/extensions/FindAndReplace.tsx
similarity index 95%
rename from shared/editor/extensions/FindAndReplace.ts
rename to app/editor/extensions/FindAndReplace.tsx
index f702159894085..f3c53782c1b02 100644
--- a/shared/editor/extensions/FindAndReplace.ts
+++ b/app/editor/extensions/FindAndReplace.tsx
@@ -2,12 +2,14 @@ import escapeRegExp from "lodash/escapeRegExp";
import { Node } from "prosemirror-model";
import { Command, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
+import * as React from "react";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
-import Extension from "../lib/Extension";
+import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
+import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
-export default class FindAndReplace extends Extension {
+export default class FindAndReplaceExtension extends Extension {
public get name() {
return "find-and-replace";
}
@@ -292,6 +294,10 @@ export default class FindAndReplace extends Extension {
];
}
+ public widget = ({ readOnly }: WidgetProps) => (
+
+ );
+
private results: { from: number; to: number }[] = [];
private currentResultIndex = 0;
private searchTerm = "";
diff --git a/app/editor/extensions/HoverPreviews.tsx b/app/editor/extensions/HoverPreviews.tsx
new file mode 100644
index 0000000000000..6ddca9607fa21
--- /dev/null
+++ b/app/editor/extensions/HoverPreviews.tsx
@@ -0,0 +1,80 @@
+import { action, observable } from "mobx";
+import { Plugin } from "prosemirror-state";
+import { EditorView } from "prosemirror-view";
+import * as React from "react";
+import Extension from "@shared/editor/lib/Extension";
+import HoverPreview from "~/components/HoverPreview";
+
+interface HoverPreviewsOptions {
+ /** Delay before the target is considered "hovered" and callback is triggered. */
+ delay: number;
+}
+
+export default class HoverPreviews extends Extension {
+ state: {
+ activeLinkElement: HTMLElement | null;
+ } = observable({
+ activeLinkElement: null,
+ });
+
+ get defaultOptions(): HoverPreviewsOptions {
+ return {
+ delay: 500,
+ };
+ }
+
+ get name() {
+ return "hover-previews";
+ }
+
+ get plugins() {
+ const isHoverTarget = (target: Element | null, view: EditorView) =>
+ target instanceof HTMLElement &&
+ this.editor.elementRef.current?.contains(target) &&
+ (!view.editable || (view.editable && !view.hasFocus()));
+
+ let hoveringTimeout: ReturnType;
+
+ return [
+ new Plugin({
+ props: {
+ handleDOMEvents: {
+ mouseover: (view: EditorView, event: MouseEvent) => {
+ const target = (event.target as HTMLElement)?.closest(
+ ".use-hover-preview"
+ );
+ if (isHoverTarget(target, view)) {
+ hoveringTimeout = setTimeout(
+ action(() => {
+ this.state.activeLinkElement = target as HTMLElement;
+ }),
+ this.options.delay
+ );
+ }
+ return false;
+ },
+ mouseout: action((view: EditorView, event: MouseEvent) => {
+ const target = (event.target as HTMLElement)?.closest(
+ ".use-hover-preview"
+ );
+ if (isHoverTarget(target, view)) {
+ clearTimeout(hoveringTimeout);
+ this.state.activeLinkElement = null;
+ }
+ return false;
+ }),
+ },
+ },
+ }),
+ ];
+ }
+
+ widget = () => (
+ {
+ this.state.activeLinkElement = null;
+ })}
+ />
+ );
+}
diff --git a/app/editor/extensions/MentionMenu.tsx b/app/editor/extensions/MentionMenu.tsx
new file mode 100644
index 0000000000000..a7f4119f8c4ef
--- /dev/null
+++ b/app/editor/extensions/MentionMenu.tsx
@@ -0,0 +1,31 @@
+import { action } from "mobx";
+import * as React from "react";
+import { WidgetProps } from "@shared/editor/lib/Extension";
+import Suggestion from "~/editor/extensions/Suggestion";
+import MentionMenu from "../components/MentionMenu";
+
+export default class MentionMenuExtension extends Suggestion {
+ get defaultOptions() {
+ return {
+ // ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
+ openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
+ closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
+ enabledInTable: true,
+ };
+ }
+
+ get name() {
+ return "mention-menu";
+ }
+
+ widget = ({ rtl }: WidgetProps) => (
+ {
+ this.state.open = false;
+ })}
+ />
+ );
+}
diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts
new file mode 100644
index 0000000000000..e6c328dc2f640
--- /dev/null
+++ b/app/editor/extensions/Suggestion.ts
@@ -0,0 +1,73 @@
+import { action, observable } from "mobx";
+import { InputRule } from "prosemirror-inputrules";
+import { NodeType, Schema } from "prosemirror-model";
+import { EditorState, Plugin } from "prosemirror-state";
+import { isInTable } from "prosemirror-tables";
+import Extension from "@shared/editor/lib/Extension";
+import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
+import isInCode from "@shared/editor/queries/isInCode";
+
+export default class Suggestion extends Extension {
+ state: {
+ open: boolean;
+ query: string;
+ } = observable({
+ open: false,
+ query: "",
+ });
+
+ get plugins(): Plugin[] {
+ return [new SuggestionsMenuPlugin(this.options, this.state)];
+ }
+
+ keys() {
+ return {
+ Backspace: action((state: EditorState) => {
+ const { $from } = state.selection;
+ const textBefore = $from.parent.textBetween(
+ Math.max(0, $from.parentOffset - 500), // 500 = max match
+ Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
+ null,
+ "\ufffc"
+ );
+
+ if (this.options.openRegex.test(textBefore)) {
+ return false;
+ }
+
+ this.state.open = false;
+ return false;
+ }),
+ };
+ }
+
+ inputRules = (_options: { type: NodeType; schema: Schema }) => [
+ new InputRule(
+ this.options.openRegex,
+ action((state: EditorState, match: RegExpMatchArray) => {
+ const { parent } = state.selection.$from;
+ if (
+ match &&
+ (parent.type.name === "paragraph" ||
+ parent.type.name === "heading") &&
+ (!isInCode(state) || this.options.enabledInCode) &&
+ (!isInTable(state) || this.options.enabledInTable)
+ ) {
+ this.state.open = true;
+ this.state.query = match[1];
+ }
+ return null;
+ })
+ ),
+ new InputRule(
+ this.options.closeRegex,
+ action((_: EditorState, match: RegExpMatchArray) => {
+ if (match) {
+ this.state.open = false;
+ this.state.query = "";
+ }
+ return null;
+ })
+ ),
+ ];
+}
diff --git a/app/editor/index.tsx b/app/editor/index.tsx
index 08bc8dcca1b60..2c0346403023b 100644
--- a/app/editor/index.tsx
+++ b/app/editor/index.tsx
@@ -23,17 +23,20 @@ import {
import { Decoration, EditorView, NodeViewConstructor } from "prosemirror-view";
import * as React from "react";
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
+import insertFiles from "@shared/editor/commands/insertFiles";
import Styles from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds";
-import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
+import Extension, {
+ CommandFactory,
+ WidgetProps,
+} from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
-import { richExtensions, withComments } from "@shared/editor/nodes";
+import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
-import { SuggestionsMenuType } from "@shared/editor/plugins/Suggestions";
import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
@@ -42,21 +45,13 @@ import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger";
-import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
-import EmojiMenu from "./components/EmojiMenu";
-import FindAndReplace from "./components/FindAndReplace";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
-import MentionMenu from "./components/MentionMenu";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
-const extensions = withComments(richExtensions);
-
-export { default as Extension } from "@shared/editor/lib/Extension";
-
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
id?: string;
@@ -123,8 +118,6 @@ export type Props = {
href: string,
event: MouseEvent | React.MouseEvent
) => void;
- /** Callback when user hovers on any link in the document */
- onHoverLink?: (element: HTMLAnchorElement) => boolean;
/** Callback when user presses any key with document focused */
onKeyDown?: (event: React.KeyboardEvent) => void;
/** Collection of embed types to render in the document */
@@ -147,12 +140,8 @@ type State = {
isEditorFocused: boolean;
/** If the toolbar for a text selection is visible */
selectionToolbarOpen: boolean;
- /** If a suggestions menu is visible */
- suggestionsMenuOpen: SuggestionsMenuType | false;
/** If the insert link toolbar is visible */
linkToolbarOpen: boolean;
- /** The query for the suggestion menu */
- query: string;
};
/**
@@ -181,10 +170,8 @@ export class Editor extends React.PureComponent<
state: State = {
isRTL: false,
isEditorFocused: false,
- suggestionsMenuOpen: false,
selectionToolbarOpen: false,
linkToolbarOpen: false,
- query: "",
};
isBlurred = true;
@@ -203,6 +190,7 @@ export class Editor extends React.PureComponent<
[name: string]: NodeViewConstructor;
};
+ widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
nodes: { [name: string]: NodeSpec };
marks: { [name: string]: MarkSpec };
commands: Record;
@@ -213,14 +201,6 @@ export class Editor extends React.PureComponent<
public constructor(props: Props & ThemeProps) {
super(props);
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
- this.events.on(
- EventType.SuggestionsMenuOpen,
- this.handleOpenSuggestionsMenu
- );
- this.events.on(
- EventType.SuggestionsMenuClose,
- this.handleCloseSuggestionsMenu
- );
}
/**
@@ -278,7 +258,6 @@ export class Editor extends React.PureComponent<
if (
!this.isBlurred &&
!this.state.isEditorFocused &&
- !this.state.suggestionsMenuOpen &&
!this.state.linkToolbarOpen &&
!this.state.selectionToolbarOpen
) {
@@ -289,7 +268,6 @@ export class Editor extends React.PureComponent<
if (
this.isBlurred &&
(this.state.isEditorFocused ||
- this.state.suggestionsMenuOpen ||
this.state.linkToolbarOpen ||
this.state.selectionToolbarOpen)
) {
@@ -309,6 +287,7 @@ export class Editor extends React.PureComponent<
this.nodes = this.createNodes();
this.marks = this.createMarks();
this.schema = this.createSchema();
+ this.widgets = this.createWidgets();
this.plugins = this.createPlugins();
this.rulePlugins = this.createRulePlugins();
this.keymaps = this.createKeymaps();
@@ -377,6 +356,10 @@ export class Editor extends React.PureComponent<
});
}
+ private createWidgets() {
+ return this.extensions.widgets;
+ }
+
private createNodes() {
return this.extensions.nodes;
}
@@ -584,6 +567,25 @@ export class Editor extends React.PureComponent<
window?.getSelection()?.removeAllRanges();
};
+ /**
+ * Insert files at the current selection.
+ * =
+ * @param event The source event
+ * @param files The files to insert
+ * @returns True if the files were inserted
+ */
+ public insertFiles = (
+ event: React.ChangeEvent,
+ files: File[]
+ ) =>
+ insertFiles(
+ this.view,
+ event,
+ this.view.state.selection.to,
+ files,
+ this.props
+ );
+
/**
* Returns true if the trimmed content of the editor is an empty string.
*
@@ -682,8 +684,6 @@ export class Editor extends React.PureComponent<
this.setState((state) => ({
...state,
selectionToolbarOpen: true,
- suggestionsMenuOpen: false,
- query: "",
}));
};
@@ -700,9 +700,7 @@ export class Editor extends React.PureComponent<
private handleOpenLinkToolbar = () => {
this.setState((state) => ({
...state,
- suggestionsMenuOpen: false,
linkToolbarOpen: true,
- query: "",
}));
};
@@ -713,37 +711,6 @@ export class Editor extends React.PureComponent<
}));
};
- private handleOpenSuggestionsMenu = (data: {
- type: SuggestionsMenuType;
- query: string;
- }) => {
- this.setState((state) => ({
- ...state,
- suggestionsMenuOpen: data.type,
- query: data.query,
- }));
- };
-
- private handleCloseSuggestionsMenu = (
- type: SuggestionsMenuType,
- insertNewLine?: boolean
- ) => {
- if (insertNewLine) {
- const transaction = this.view.state.tr.split(
- this.view.state.selection.to
- );
- this.view.dispatch(transaction);
- this.view.focus();
- }
- if (type && this.state.suggestionsMenuOpen !== type) {
- return;
- }
- this.setState((state) => ({
- ...state,
- suggestionsMenuOpen: false,
- }));
- };
-
public render() {
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
this.props;
@@ -772,84 +739,32 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
/>
{this.view && (
- <>
-
- {this.commands.find && }
- >
+
)}
- {!readOnly && this.view && (
- <>
- {this.marks.link && (
-
- )}
- {this.nodes.emoji && (
-
- this.handleCloseSuggestionsMenu(
- SuggestionsMenuType.Emoji,
- insertNewLine
- )
- }
- />
- )}
- {this.nodes.mention && (
-
- this.handleCloseSuggestionsMenu(
- SuggestionsMenuType.Mention,
- insertNewLine
- )
- }
- />
- )}
-
- this.handleCloseSuggestionsMenu(
- SuggestionsMenuType.Block,
- insertNewLine
- )
- }
- uploadFile={this.props.uploadFile}
- onLinkToolbarOpen={this.handleOpenLinkToolbar}
- onFileUploadStart={this.props.onFileUploadStart}
- onFileUploadStop={this.props.onFileUploadStop}
- embeds={this.props.embeds}
- />
- >
+ {!readOnly && this.view && this.marks.link && (
+
)}
+ {this.widgets &&
+ Object.values(this.widgets).map((Widget, index) => (
+
+ ))}
diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx
index f795fc56da47e..301c2610d37af 100644
--- a/app/editor/menus/block.tsx
+++ b/app/editor/menus/block.tsx
@@ -14,7 +14,6 @@ import {
StarredIcon,
WarningIcon,
InfoIcon,
- LinkIcon,
AttachmentIcon,
ClockIcon,
CalendarIcon,
@@ -95,13 +94,6 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
icon: ,
keywords: "picture photo",
},
- {
- name: "link",
- title: dictionary.link,
- icon: ,
- shortcut: `${metaDisplay} k`,
- keywords: "link url uri href",
- },
{
name: "video",
title: dictionary.video,
diff --git a/app/editor/menus/readOnly.tsx b/app/editor/menus/readOnly.tsx
index e86d9eca2e343..2a9dd331ebf23 100644
--- a/app/editor/menus/readOnly.tsx
+++ b/app/editor/menus/readOnly.tsx
@@ -7,12 +7,14 @@ import { Dictionary } from "~/hooks/useDictionary";
export default function readOnlyMenuItems(
state: EditorState,
+ canUpdate: boolean,
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
return [
{
+ visible: canUpdate,
name: "comment",
tooltip: dictionary.comment,
label: dictionary.comment,
diff --git a/app/hooks/useCollectionTrees.ts b/app/hooks/useCollectionTrees.ts
index 7d8ed57842538..6ce03bc2439c3 100644
--- a/app/hooks/useCollectionTrees.ts
+++ b/app/hooks/useCollectionTrees.ts
@@ -1,5 +1,6 @@
import * as React from "react";
import { NavigationNode, NavigationNodeType } from "@shared/types";
+import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
@@ -66,7 +67,9 @@ export default function useCollectionTrees(): NavigationNode[] {
title: collection.name,
url: collection.url,
type: NavigationNodeType.Collection,
- children: collection.documents || [],
+ children: collection.documents
+ ? sortNavigationNodes(collection.documents, collection.sort, true)
+ : [],
parent: null,
};
diff --git a/app/hooks/useCurrentTeam.ts b/app/hooks/useCurrentTeam.ts
index db5c386e72db2..d64579ada7da9 100644
--- a/app/hooks/useCurrentTeam.ts
+++ b/app/hooks/useCurrentTeam.ts
@@ -1,8 +1,23 @@
import invariant from "invariant";
+import Team from "~/models/Team";
import useStores from "./useStores";
-export default function useCurrentTeam() {
+/**
+ * Returns the current team, or undefined if there is no current team and `rejectOnEmpty` is set to
+ * false.
+ *
+ * @param options.rejectOnEmpty - If true, throws an error if there is no current team. Defaults to true.
+ */
+function useCurrentTeam(options: { rejectOnEmpty: false }): Team | undefined;
+function useCurrentTeam(options?: { rejectOnEmpty: true }): Team;
+function useCurrentTeam({
+ rejectOnEmpty = true,
+}: { rejectOnEmpty?: boolean } = {}) {
const { auth } = useStores();
- invariant(auth.team, "team required");
- return auth.team;
+ if (rejectOnEmpty) {
+ invariant(auth.team, "team required");
+ }
+ return auth.team || undefined;
}
+
+export default useCurrentTeam;
diff --git a/app/hooks/useCurrentUser.ts b/app/hooks/useCurrentUser.ts
index 53fb54c8a9e94..267b39dcee33e 100644
--- a/app/hooks/useCurrentUser.ts
+++ b/app/hooks/useCurrentUser.ts
@@ -1,8 +1,23 @@
import invariant from "invariant";
+import User from "~/models/User";
import useStores from "./useStores";
-export default function useCurrentUser() {
+/**
+ * Returns the current user, or undefined if there is no current user and `rejectOnEmpty` is set to
+ * false.
+ *
+ * @param options.rejectOnEmpty - If true, throws an error if there is no current user. Defaults to true.
+ */
+function useCurrentUser(options: { rejectOnEmpty: false }): User | undefined;
+function useCurrentUser(options?: { rejectOnEmpty: true }): User;
+function useCurrentUser({
+ rejectOnEmpty = true,
+}: { rejectOnEmpty?: boolean } = {}) {
const { auth } = useStores();
- invariant(auth.user, "user required");
- return auth.user;
+ if (rejectOnEmpty) {
+ invariant(auth.user, "user required");
+ }
+ return auth.user || undefined;
}
+
+export default useCurrentUser;
diff --git a/app/hooks/useEditorClickHandlers.ts b/app/hooks/useEditorClickHandlers.ts
new file mode 100644
index 0000000000000..822a2e9e8c802
--- /dev/null
+++ b/app/hooks/useEditorClickHandlers.ts
@@ -0,0 +1,57 @@
+import * as React from "react";
+import { useHistory } from "react-router-dom";
+import { isInternalUrl } from "@shared/utils/urls";
+import { isModKey } from "~/utils/keyboard";
+import { sharedDocumentPath } from "~/utils/routeHelpers";
+import { isHash } from "~/utils/urls";
+
+export default function useEditorClickHandlers({
+ shareId,
+}: {
+ shareId?: string;
+}) {
+ const history = useHistory();
+ const handleClickLink = React.useCallback(
+ (href: string, event: MouseEvent) => {
+ // on page hash
+ if (isHash(href)) {
+ window.location.href = href;
+ return;
+ }
+
+ if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
+ // relative
+ let navigateTo = href;
+
+ // probably absolute
+ if (href[0] !== "/") {
+ try {
+ const url = new URL(href);
+ navigateTo = url.pathname + url.hash;
+ } catch (err) {
+ navigateTo = href;
+ }
+ }
+
+ // Link to our own API should be opened in a new tab, not in the app
+ if (navigateTo.startsWith("/api/")) {
+ window.open(href, "_blank");
+ return;
+ }
+
+ // If we're navigating to an internal document link then prepend the
+ // share route to the URL so that the document is loaded in context
+ if (shareId && navigateTo.includes("/doc/")) {
+ navigateTo = sharedDocumentPath(shareId, navigateTo);
+ }
+
+ history.push(navigateTo);
+ } else if (href) {
+ window.open(href, "_blank");
+ }
+ },
+ [history, shareId]
+ );
+
+ return { handleClickLink };
+}
diff --git a/app/hooks/useEmbeds.ts b/app/hooks/useEmbeds.ts
index 5526b9369688b..56482a999b00e 100644
--- a/app/hooks/useEmbeds.ts
+++ b/app/hooks/useEmbeds.ts
@@ -1,6 +1,6 @@
import find from "lodash/find";
import * as React from "react";
-import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
+import embeds from "@shared/editor/embeds";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Logger from "~/utils/Logger";
@@ -9,8 +9,7 @@ import useStores from "./useStores";
/**
* Hook to get all embed configuration for the current team
*
- * @param loadIfMissing Should we load integration settings if they are not
- * locally available
+ * @param loadIfMissing Should we load integration settings if they are not locally available
* @returns A list of embed descriptors
*/
export default function useEmbeds(loadIfMissing = false) {
@@ -36,14 +35,18 @@ export default function useEmbeds(loadIfMissing = false) {
return React.useMemo(
() =>
embeds.map((e) => {
- const em: Integration | undefined = find(
- integrations.orderedData,
- (i) => i.service === e.component.name.toLowerCase()
- );
- return new EmbedDescriptor({
- ...e,
- settings: em?.settings,
- });
+ // Find any integrations that match this embed and inject the settings
+ const integration: Integration | undefined =
+ find(
+ integrations.orderedData,
+ (integration) => integration.service === e.name
+ );
+
+ if (integration?.settings) {
+ e.settings = integration.settings;
+ }
+
+ return e;
}),
[integrations.orderedData]
);
diff --git a/app/hooks/useEventListener.ts b/app/hooks/useEventListener.ts
index 37627f6e6f854..66ff46a4048bd 100644
--- a/app/hooks/useEventListener.ts
+++ b/app/hooks/useEventListener.ts
@@ -12,7 +12,7 @@ import * as React from "react";
export default function useEventListener(
eventName: string,
handler: T,
- element: Window | Node = window,
+ element: Window | VisualViewport | Node | null = window,
options: AddEventListenerOptions = {}
) {
const savedHandler = React.useRef();
diff --git a/app/hooks/usePaginatedRequest.ts b/app/hooks/usePaginatedRequest.ts
new file mode 100644
index 0000000000000..caabc9e2092d2
--- /dev/null
+++ b/app/hooks/usePaginatedRequest.ts
@@ -0,0 +1,98 @@
+import uniqBy from "lodash/uniqBy";
+import * as React from "react";
+import { PaginationParams } from "~/types";
+import useRequest from "./useRequest";
+
+type RequestResponse = {
+ /** The return value of the paginated request function. */
+ data: T[] | undefined;
+ /** The request error, if any. */
+ error: unknown;
+ /** Whether the request is currently in progress. */
+ loading: boolean;
+ /** Function to trigger next page request. */
+ next: () => void;
+ /** Page number */
+ page: number;
+ /** Offset */
+ offset: number;
+ /** Marks the end of pagination */
+ end: boolean;
+};
+
+const INITIAL_OFFSET = 0;
+const DEFAULT_LIMIT = 10;
+
+/**
+ * A hook to make paginated API request and track its state within a component.
+ *
+ * @param requestFn The function to call to make the request, it should return a promise.
+ * @param params Pagination params(limit, offset etc) to be passed to requestFn.
+ * @returns
+ */
+export default function usePaginatedRequest(
+ requestFn: (params?: PaginationParams | undefined) => Promise,
+ params: PaginationParams = {}
+): RequestResponse {
+ const [data, setData] = React.useState();
+ const [offset, setOffset] = React.useState(INITIAL_OFFSET);
+ const [page, setPage] = React.useState(0);
+ const [end, setEnd] = React.useState(false);
+ const displayLimit = params.limit || DEFAULT_LIMIT;
+ const fetchLimit = displayLimit + 1;
+ const [paginatedReq, setPaginatedReq] = React.useState(
+ () => () =>
+ requestFn({
+ ...params,
+ offset: 0,
+ limit: fetchLimit,
+ })
+ );
+
+ const {
+ data: response,
+ error,
+ loading,
+ request,
+ } = useRequest(paginatedReq);
+
+ React.useEffect(() => {
+ void request();
+ }, [request]);
+
+ React.useEffect(() => {
+ if (response && !loading) {
+ setData((prev) =>
+ uniqBy((prev ?? []).concat(response.slice(0, displayLimit)), "id")
+ );
+ setPage((prev) => prev + 1);
+ setEnd(response.length <= displayLimit);
+ }
+ }, [response, displayLimit, loading]);
+
+ React.useEffect(() => {
+ if (offset) {
+ setPaginatedReq(
+ () => () =>
+ requestFn({
+ ...params,
+ offset,
+ limit: fetchLimit,
+ })
+ );
+ }
+ }, [offset, fetchLimit, requestFn]);
+
+ const next = React.useCallback(() => {
+ setOffset((prev) => prev + displayLimit);
+ }, [displayLimit]);
+
+ React.useEffect(() => {
+ setEnd(false);
+ setData(undefined);
+ setPage(0);
+ setOffset(0);
+ }, [requestFn]);
+
+ return { data, next, loading, error, page, offset, end };
+}
diff --git a/app/hooks/usePrevious.ts b/app/hooks/usePrevious.ts
index 4dbc6a474aa40..6e395ab3c37d2 100644
--- a/app/hooks/usePrevious.ts
+++ b/app/hooks/usePrevious.ts
@@ -1,9 +1,19 @@
import * as React from "react";
-export default function usePrevious(value: T): T | void {
+/**
+ * A hook to get the previous value of a variable.
+ *
+ * @param value The value to track.
+ * @param onlyTruthy Whether to include only truthy values.
+ * @returns The previous value of the variable.
+ */
+export default function usePrevious(value: T, onlyTruthy = false): T | void {
const ref = React.useRef();
React.useEffect(() => {
+ if (onlyTruthy && !value) {
+ return;
+ }
ref.current = value;
});
diff --git a/app/hooks/useRequest.ts b/app/hooks/useRequest.ts
index e8af4b1cdacac..603cd44932ff3 100644
--- a/app/hooks/useRequest.ts
+++ b/app/hooks/useRequest.ts
@@ -16,7 +16,7 @@ type RequestResponse = {
* A hook to make an API request and track its state within a component.
*
* @param requestFn The function to call to make the request, it should return a promise.
- * @returns
+ * @returns An object containing the request state and a function to start the request.
*/
export default function useRequest(
requestFn: () => Promise
diff --git a/app/hooks/useUserLocale.ts b/app/hooks/useUserLocale.ts
index f8628d9fd9661..7a981589bebd9 100644
--- a/app/hooks/useUserLocale.ts
+++ b/app/hooks/useUserLocale.ts
@@ -1,4 +1,4 @@
-import useStores from "./useStores";
+import useCurrentUser from "./useCurrentUser";
/**
* Returns the user's locale, or undefined if the user is not logged in.
@@ -7,12 +7,12 @@ import useStores from "./useStores";
* @returns The user's locale, or undefined if the user is not logged in
*/
export default function useUserLocale(languageCode?: boolean) {
- const { auth } = useStores();
+ const user = useCurrentUser({ rejectOnEmpty: false });
- if (!auth.user?.language) {
+ if (!user?.language) {
return undefined;
}
- const { language } = auth.user;
+ const { language } = user;
return languageCode ? language.split("_")[0] : language;
}
diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts
index 01c4715a49e00..a607a97ee8369 100644
--- a/app/hooks/useWindowSize.ts
+++ b/app/hooks/useWindowSize.ts
@@ -10,24 +10,25 @@ import useThrottledCallback from "./useThrottledCallback";
*/
export default function useWindowSize() {
const [windowSize, setWindowSize] = React.useState({
- width: window.innerWidth,
- height: window.innerHeight,
+ width: window.visualViewport?.width || window.innerWidth,
+ height: window.visualViewport?.height || window.innerHeight,
});
const handleResize = useThrottledCallback(() => {
+ const width = window.visualViewport?.width || window.innerWidth;
+ const height = window.visualViewport?.height || window.innerHeight;
+
setWindowSize((state) => {
- if (
- window.innerWidth === state.width &&
- window.innerHeight === state.height
- ) {
+ if (width === state.width && height === state.height) {
return state;
}
- return { width: window.innerWidth, height: window.innerHeight };
+ return { width, height };
});
}, 100);
useEventListener("resize", handleResize);
+ useEventListener("resize", handleResize, window.visualViewport);
// Call handler right away so state gets updated with initial window size
React.useEffect(() => {
diff --git a/app/index.tsx b/app/index.tsx
index 5b10151b0c762..bfd845f8b7ddb 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -20,6 +20,7 @@ import env from "~/env";
import { initI18n } from "~/utils/i18n";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
+import MobileScrollWrapper from "./components/MobileScrollWrapper";
import Routes from "./routes";
import Logger from "./utils/Logger";
import history from "./utils/history";
@@ -60,7 +61,7 @@ if (element) {
- <>
+
@@ -68,7 +69,7 @@ if (element) {
- >
+
diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx
index 479a48eb50a8d..e52d59fb1157c 100644
--- a/app/menus/DocumentMenu.tsx
+++ b/app/menus/DocumentMenu.tsx
@@ -9,6 +9,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
+import { UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
@@ -41,6 +42,8 @@ import {
openDocumentComments,
createDocumentFromTemplate,
createNestedDocument,
+ shareDocument,
+ copyDocument,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -255,6 +258,7 @@ function DocumentMenu({
actionToMenuItem(unstarDocument, context),
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
+ ...(isMobile ? [actionToMenuItem(shareDocument, context)] : []),
{
type: "separator",
},
@@ -290,6 +294,7 @@ function DocumentMenu({
actionToMenuItem(openDocumentHistory, context),
actionToMenuItem(openDocumentInsights, context),
actionToMenuItem(downloadDocument, context),
+ actionToMenuItem(copyDocument, context),
actionToMenuItem(printDocument, context),
{
type: "separator",
@@ -324,7 +329,13 @@ function DocumentMenu({
label={t("Full width")}
checked={document.fullWidth}
onChange={(ev) => {
- document.fullWidth = ev.currentTarget.checked;
+ const fullWidth = ev.currentTarget.checked;
+ user.setPreference(
+ UserPreference.FullWidthDocuments,
+ fullWidth
+ );
+ void user.save();
+ document.fullWidth = fullWidth;
void document.save();
}}
/>
diff --git a/app/menus/NewTemplateMenu.tsx b/app/menus/NewTemplateMenu.tsx
index defe590ac0d56..d89f1b2d9afb4 100644
--- a/app/menus/NewTemplateMenu.tsx
+++ b/app/menus/NewTemplateMenu.tsx
@@ -35,7 +35,7 @@ function NewTemplateMenu() {
collections.orderedData.reduce
- )}
-
-
-
- {this.query ? (
+ const handleEscape = () => searchInputRef.current?.focus();
+ const showEmpty = !loading && query && data?.length === 0;
+
+ return (
+
+
+ {loading && }
+ {props.notFound && (
+
+
{t("Not Found")}
+
+ {t("We were unable to find the page you’re looking for.")}
+
+
+ )}
+
+
+
+ {query ? (
+ <>
- this.handleFilterChange({
- includeArchived,
- })
+ handleFilterChange({ includeArchived })
}
/>
- this.handleFilterChange({
- collectionId,
- })
+ handleFilterChange({ collectionId })
}
/>
- this.handleFilterChange({
- userId,
- })
- }
+ userId={userId}
+ onSelect={(userId) => handleFilterChange({ userId })}
/>
- this.handleFilterChange({
- dateFilter,
- })
- }
+ dateFilter={dateFilter}
+ onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/>
) => {
+ handleFilterChange({ titleFilter: ev.target.checked });
+ }}
+ checked={titleFilter}
/>
- ) : (
-
- )}
- {showEmpty && (
-
-
-
- No documents found for your search filters.
-
-
-
- )}
-
-
- {(compositeProps) =>
- results?.map((result) => {
- const document = documents.data.get(result.document.id);
- if (!document) {
- return null;
- }
- return (
-
- );
- })
- }
-
- {this.allowLoadMore && (
-
+ {showEmpty && (
+
+
+
+ {t("No documents found for your search filters.")}
+
+
+
)}
-
-
-
- );
- }
+
+
+ {(compositeProps) =>
+ data?.length
+ ? data.map((result) => (
+
+ ))
+ : null
+ }
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
}
const Centered = styled(Flex)`
@@ -467,6 +311,7 @@ const Filters = styled(Flex)`
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
+ ${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
@@ -485,4 +330,4 @@ const SearchTitlesFilter = styled(Switch)`
font-weight: 400;
`;
-export default withTranslation()(withStores(withRouter(Search)));
+export default observer(Search);
diff --git a/app/scenes/Search/components/RecentSearches.tsx b/app/scenes/Search/components/RecentSearches.tsx
index 67f09993c3cfb..5f725f2b8fb5d 100644
--- a/app/scenes/Search/components/RecentSearches.tsx
+++ b/app/scenes/Search/components/RecentSearches.tsx
@@ -28,7 +28,9 @@ function RecentSearches(
const [isPreloaded] = React.useState(searches.recent.length > 0);
React.useEffect(() => {
- void searches.fetchPage({});
+ void searches.fetchPage({
+ source: "app",
+ });
}, [searches]);
const content = searches.recent.length ? (
diff --git a/app/scenes/Search/components/SearchInput.tsx b/app/scenes/Search/components/SearchInput.tsx
index d645b9c1cd794..2487d96001a3a 100644
--- a/app/scenes/Search/components/SearchInput.tsx
+++ b/app/scenes/Search/components/SearchInput.tsx
@@ -33,7 +33,7 @@ function SearchInput(
return (
-
+
{
- await auth.updateTeam({
- avatarUrl,
- });
+ await team.save({ avatarUrl });
toast.success(t("Logo updated"));
};
@@ -288,8 +277,8 @@ function Details() {
/>
-
-
+ {data.length ? (
+
+
+
+ ) : null}
);
}
diff --git a/app/scenes/Settings/components/DomainManagement.tsx b/app/scenes/Settings/components/DomainManagement.tsx
index 906c10b29f843..5f535fae28456 100644
--- a/app/scenes/Settings/components/DomainManagement.tsx
+++ b/app/scenes/Settings/components/DomainManagement.tsx
@@ -11,7 +11,6 @@ import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useCurrentTeam from "~/hooks/useCurrentTeam";
-import useStores from "~/hooks/useStores";
import SettingRow from "./SettingRow";
type Props = {
@@ -19,7 +18,6 @@ type Props = {
};
function DomainManagement({ onSuccess }: Props) {
- const { auth } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
@@ -35,16 +33,14 @@ function DomainManagement({ onSuccess }: Props) {
const handleSaveDomains = React.useCallback(async () => {
try {
- await auth.updateTeam({
- allowedDomains,
- });
+ await team.save({ allowedDomains });
onSuccess();
setExistingDomainsTouched(false);
updateLastKnownDomainCount(allowedDomains.length);
} catch (err) {
toast.error(err.message);
}
- }, [auth, allowedDomains, onSuccess]);
+ }, [team, allowedDomains, onSuccess]);
const handleRemoveDomain = async (index: number) => {
const newDomains = allowedDomains.filter((_, i) => index !== i);
@@ -132,7 +128,7 @@ function DomainManagement({ onSuccess }: Props) {
Save changes
diff --git a/app/scenes/Settings/components/DropToImport.tsx b/app/scenes/Settings/components/DropToImport.tsx
index 733f5f45cd546..64d6cb2f7511e 100644
--- a/app/scenes/Settings/components/DropToImport.tsx
+++ b/app/scenes/Settings/components/DropToImport.tsx
@@ -42,9 +42,11 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
});
await collections.import(attachment.id, format);
onSubmit();
- toast.success(
- t("Your import is being processed, you can safely leave this page")
- );
+ toast.message(file.name, {
+ description: t(
+ "Your import is being processed, you can safely leave this page"
+ ),
+ });
} catch (err) {
toast.error(err.message);
} finally {
diff --git a/app/scenes/Settings/components/FileOperationListItem.tsx b/app/scenes/Settings/components/FileOperationListItem.tsx
index 87729bab43b9d..13b33e609d147 100644
--- a/app/scenes/Settings/components/FileOperationListItem.tsx
+++ b/app/scenes/Settings/components/FileOperationListItem.tsx
@@ -18,6 +18,7 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import FileOperationMenu from "~/menus/FileOperationMenu";
+import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
fileOperation: FileOperation;
@@ -80,7 +81,6 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
content: (
@@ -97,6 +97,10 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
fileOperation.state === FileOperationState.Complete) ||
fileOperation.type === FileOperationType.Import;
+ const selfHostedHelp = isCloudHosted
+ ? ""
+ : `. ${t("Check server logs for more details.")}`;
+
return (
{
subtitle={
<>
{stateMapping[fileOperation.state]} •
- {fileOperation.error && <>{fileOperation.error} • >}
+ {fileOperation.error && (
+ <>
+ {fileOperation.error}
+ {selfHostedHelp} •
+ >
+ )}
{t(`{{userName}} requested`, {
userName:
user.id === fileOperation.user.id
diff --git a/app/scenes/Settings/components/PeopleTable.tsx b/app/scenes/Settings/components/PeopleTable.tsx
index 1910725ef2d81..dcbc72a0d50d5 100644
--- a/app/scenes/Settings/components/PeopleTable.tsx
+++ b/app/scenes/Settings/components/PeopleTable.tsx
@@ -58,8 +58,13 @@ function PeopleTable({ canManage, ...rest }: Props) {
Cell: observer(({ row }: { row: { original: User } }) => (
{!row.original.lastActiveAt && {t("Invited")}}
- {row.original.isAdmin && {t("Admin")}}
- {row.original.isViewer && {t("Viewer")}}
+ {row.original.isAdmin ? (
+ {t("Admin")}
+ ) : row.original.isViewer ? (
+ {t("Viewer")}
+ ) : (
+ {t("Editor")}
+ )}
{row.original.isSuspended && {t("Suspended")}}
)),
diff --git a/app/scenes/Settings/components/SharesTable.tsx b/app/scenes/Settings/components/SharesTable.tsx
index 52032c0e0a589..e39af90b985bf 100644
--- a/app/scenes/Settings/components/SharesTable.tsx
+++ b/app/scenes/Settings/components/SharesTable.tsx
@@ -8,6 +8,7 @@ import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
import TableFromParams from "~/components/TableFromParams";
import Time from "~/components/Time";
+import Tooltip from "~/components/Tooltip";
import ShareMenu from "~/menus/ShareMenu";
type Props = Omit, "columns"> & {
@@ -15,9 +16,10 @@ type Props = Omit, "columns"> & {
canManage: boolean;
};
-function SharesTable({ canManage, ...rest }: Props) {
+function SharesTable({ canManage, data, ...rest }: Props) {
const { t } = useTranslation();
const theme = useTheme();
+ const hasDomain = data.some((share) => share.domain);
const columns = React.useMemo(
() =>
@@ -29,23 +31,28 @@ function SharesTable({ canManage, ...rest }: Props) {
disableSortBy: true,
Cell: observer(({ value }: { value: string }) => <>{value}>),
},
+ {
+ id: "who",
+ Header: t("Shared by"),
+ accessor: "createdById",
+ disableSortBy: true,
+ Cell: observer(
+ ({ row }: { value: string; row: { original: Share } }) => (
+
+ {row.original.createdBy && (
+
+ )}
+ {row.original.createdBy.name}
+
+ )
+ ),
+ },
{
id: "createdAt",
Header: t("Date shared"),
accessor: "createdAt",
- Cell: observer(
- ({ value, row }: { value: string; row: { original: Share } }) =>
- value ? (
-
- {row.original.createdBy && (
-
- )}
-
-
- ) : null
+ Cell: observer(({ value }: { value: string }) =>
+ value ? : null
),
},
{
@@ -63,11 +70,21 @@ function SharesTable({ canManage, ...rest }: Props) {
Cell: observer(({ value }: { value: string }) =>
value ? (
-
+
+
+
) : null
),
},
+ hasDomain
+ ? {
+ id: "domain",
+ Header: t("Domain"),
+ accessor: "domain",
+ disableSortBy: true,
+ }
+ : undefined,
{
id: "views",
Header: t("Views"),
@@ -89,10 +106,10 @@ function SharesTable({ canManage, ...rest }: Props) {
}
: undefined,
].filter((i) => i),
- [t, theme.accent, canManage]
+ [t, theme.accent, hasDomain, canManage]
);
- return ;
+ return ;
}
export default SharesTable;
diff --git a/app/scenes/TeamDelete.tsx b/app/scenes/TeamDelete.tsx
index 152528c1bffdb..68c73156ed3e2 100644
--- a/app/scenes/TeamDelete.tsx
+++ b/app/scenes/TeamDelete.tsx
@@ -64,36 +64,37 @@ function TeamDelete({ onSubmit }: Props) {
const workspaceName = team.name;
return (
-
-
-
+
+
);
}
diff --git a/app/scenes/TeamNew.tsx b/app/scenes/TeamNew.tsx
index 81702e3aac6c6..d2925df0351b9 100644
--- a/app/scenes/TeamNew.tsx
+++ b/app/scenes/TeamNew.tsx
@@ -67,7 +67,7 @@ function TeamNew({ user }: Props) {
-
-
+
+
);
}
diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts
index eb767e2c9a456..54bd1c8904c1a 100644
--- a/app/stores/AuthStore.ts
+++ b/app/stores/AuthStore.ts
@@ -2,7 +2,7 @@ import * as Sentry from "@sentry/react";
import invariant from "invariant";
import { observable, action, computed, autorun, runInAction } from "mobx";
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
-import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types";
+import { CustomTheme } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
@@ -10,17 +10,19 @@ import Policy from "~/models/Policy";
import Team from "~/models/Team";
import User from "~/models/User";
import env from "~/env";
+import { PartialWithId } from "~/types";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
+import Store from "./base/Store";
const AUTH_STORE = "AUTH_STORE";
const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"];
type PersistedData = {
- user?: User;
- team?: Team;
+ user?: PartialWithId;
+ team?: PartialWithId;
collaborationToken?: string;
availableTeams?: {
id: string;
@@ -46,14 +48,14 @@ export type Config = {
providers: Provider[];
};
-export default class AuthStore {
- /* The user that is currently signed in. */
+export default class AuthStore extends Store {
+ /* The ID of the user that is currently signed in. */
@observable
- user?: User | null;
+ currentUserId?: string | null;
- /* The team that the current user is signed into. */
+ /* The ID of the team that is currently signed in. */
@observable
- team?: Team | null;
+ currentTeamId?: string | null;
/* A short-lived token to be used to authenticate with the collaboration server. */
@observable
@@ -69,21 +71,10 @@ export default class AuthStore {
isSignedIn: boolean;
}[];
- /* A list of cancan policies for the current user. */
- @observable
- policies: Policy[] = [];
-
/* The authentication provider the user signed in with. */
@observable
lastSignedIn?: string | null;
- /* Whether the user is currently saving their profile or team settings. */
- @observable
- isSaving = false;
-
- @observable
- isFetching = true;
-
/* Whether the user is currently suspended. */
@observable
isSuspended = false;
@@ -99,12 +90,14 @@ export default class AuthStore {
rootStore: RootStore;
constructor(rootStore: RootStore) {
+ super(rootStore, Team);
+
this.rootStore = rootStore;
// attempt to load the previous state of this store from localstorage
const data: PersistedData = Storage.get(AUTH_STORE) || {};
this.rehydrate(data);
- void this.fetch();
+ void this.fetchAuth();
// persists this entire store to localstorage whenever any keys are changed
autorun(() => {
@@ -138,21 +131,44 @@ export default class AuthStore {
@action
rehydrate(data: PersistedData) {
- this.user = data.user ? new User(data.user, this as any) : undefined;
- this.team = data.team ? new Team(data.team, this as any) : undefined;
+ if (data.policies) {
+ this.addPolicies(data.policies);
+ }
+ if (data.team) {
+ this.add(data.team);
+ }
+ if (data.user) {
+ this.rootStore.users.add(data.user);
+ }
+
+ this.currentUserId = data.user?.id;
this.collaborationToken = data.collaborationToken;
this.lastSignedIn = getCookie("lastSignedIn");
- this.addPolicies(data.policies);
}
- addPolicies(policies?: Policy[]) {
- if (policies) {
- // cache policies in this store so that they are persisted between sessions
- this.policies = policies;
- policies.forEach((policy) => this.rootStore.policies.add(policy));
- }
+ /** The current user */
+ @computed
+ get user() {
+ return this.currentUserId
+ ? this.rootStore.users.get(this.currentUserId)
+ : undefined;
+ }
+
+ /** The current team */
+ @computed
+ get team() {
+ return this.orderedData[0];
}
+ /** The current team's policies */
+ @computed
+ get policies() {
+ return this.currentTeamId
+ ? [this.rootStore.policies.get(this.currentTeamId)]
+ : [];
+ }
+
+ /** Whether the user is signed in */
@computed
get authenticated(): boolean {
return !!this.user && !!this.team;
@@ -177,7 +193,7 @@ export default class AuthStore {
};
@action
- fetch = async () => {
+ fetchAuth = async () => {
this.isFetching = true;
try {
@@ -185,21 +201,23 @@ export default class AuthStore {
credentials: "same-origin",
});
invariant(res?.data, "Auth not available");
- runInAction("AuthStore#fetch", () => {
+
+ runInAction("AuthStore#refresh", () => {
+ const { data } = res;
this.addPolicies(res.policies);
- const { user, team } = res.data;
- this.user = new User(user, this as any);
- this.team = new Team(team, this as any);
+ this.add(data.team);
+ this.rootStore.users.add(data.user);
+ this.currentUserId = data.user.id;
+ this.currentTeamId = data.team.id;
+
this.availableTeams = res.data.availableTeams;
this.collaborationToken = res.data.collaborationToken;
if (env.SENTRY_DSN) {
Sentry.configureScope(function (scope) {
- scope.setUser({
- id: user.id,
- });
- scope.setExtra("team", team.name);
- scope.setExtra("teamId", team.id);
+ scope.setUser({ id: this.currentUserId });
+ scope.setExtra("team", this.team.name);
+ scope.setExtra("teamId", this.team.id);
});
}
@@ -207,16 +225,16 @@ export default class AuthStore {
// Occurs when the (sub)domain is changed in admin and the user hits an old url
const { hostname, pathname } = window.location;
- if (this.team.domain) {
- if (this.team.domain !== hostname) {
- window.location.href = `${team.url}${pathname}`;
+ if (data.team.domain) {
+ if (data.team.domain !== hostname) {
+ window.location.href = `${data.team.url}${pathname}`;
return;
}
} else if (
isCloudHosted &&
- parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "")
+ parseDomain(hostname).teamSubdomain !== (data.team.subdomain ?? "")
) {
- window.location.href = `${team.url}${pathname}`;
+ window.location.href = `${data.team.url}${pathname}`;
return;
}
@@ -250,79 +268,28 @@ export default class AuthStore {
deleteUser = async (data: { code: string }) => {
await client.post(`/users.delete`, data);
runInAction("AuthStore#deleteUser", () => {
- this.user = null;
- this.team = null;
+ this.currentUserId = null;
+ this.currentTeamId = null;
this.collaborationToken = null;
this.availableTeams = this.availableTeams?.filter(
(team) => team.id !== this.team?.id
);
- this.policies = [];
});
};
@action
deleteTeam = async (data: { code: string }) => {
await client.post(`/teams.delete`, data);
+
runInAction("AuthStore#deleteTeam", () => {
- this.user = null;
+ this.currentUserId = null;
+ this.currentTeamId = null;
this.availableTeams = this.availableTeams?.filter(
(team) => team.id !== this.team?.id
);
- this.policies = [];
});
};
- @action
- updateUser = async (params: {
- name?: string;
- avatarUrl?: string | null;
- language?: string;
- preferences?: UserPreferences;
- }) => {
- this.isSaving = true;
- const previousData = this.user?.toAPI();
-
- try {
- this.user?.updateData(params);
- const res = await client.post(`/users.update`, params);
- invariant(res?.data, "User response not available");
- this.user?.updateData(res.data);
- this.addPolicies(res.policies);
- } catch (err) {
- this.user?.updateData(previousData);
- throw err;
- } finally {
- this.isSaving = false;
- }
- };
-
- @action
- updateTeam = async (params: {
- name?: string;
- avatarUrl?: string | null | undefined;
- sharing?: boolean;
- defaultCollectionId?: string | null;
- subdomain?: string | null | undefined;
- allowedDomains?: string[] | null | undefined;
- preferences?: TeamPreferences;
- }) => {
- this.isSaving = true;
- const previousData = this.team?.toAPI();
-
- try {
- this.team?.updateData(params);
- const res = await client.post(`/team.update`, params);
- invariant(res?.data, "Team response not available");
- this.team?.updateData(res.data);
- this.addPolicies(res.policies);
- } catch (err) {
- this.team?.updateData(previousData);
- throw err;
- } finally {
- this.isSaving = false;
- }
- };
-
@action
createTeam = async (params: { name: string }) => {
this.isSaving = true;
@@ -378,13 +345,12 @@ export default class AuthStore {
}
// clear all credentials from cache (and local storage via autorun)
- this.user = null;
- this.team = null;
+ this.currentUserId = null;
+ this.currentTeamId = null;
this.collaborationToken = null;
- this.policies = [];
// Tell the host application we logged out, if any – allows window cleanup.
void Desktop.bridge?.onLogout?.();
- this.rootStore.logout();
+ this.rootStore.clear();
};
}
diff --git a/app/stores/CollectionGroupMembershipsStore.ts b/app/stores/CollectionGroupMembershipsStore.ts
index bf1fcc6ad8f55..2a76e8141da3b 100644
--- a/app/stores/CollectionGroupMembershipsStore.ts
+++ b/app/stores/CollectionGroupMembershipsStore.ts
@@ -71,15 +71,32 @@ export default class CollectionGroupMembershipsStore extends Store m.groupId === groupId && m.collectionId === collectionId
+ );
+ if (membership) {
+ this.remove(membership.id);
+ }
}
@action
removeCollectionMemberships = (collectionId: string) => {
this.data.forEach((membership, key) => {
- if (key.includes(collectionId)) {
+ if (membership.collectionId === collectionId) {
this.remove(key);
}
});
};
+
+ /**
+ * Find a collection group membership by collectionId and groupId
+ *
+ * @param collectionId The collection ID
+ * @param groupId The group ID
+ * @returns The collection group membership or undefined if not found.
+ */
+ find = (collectionId: string, groupId: string) =>
+ Array.from(this.data.values()).find(
+ (m) => m.groupId === groupId && m.collectionId === collectionId
+ );
}
diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts
index 009c551746a5c..efb6db5487a7e 100644
--- a/app/stores/CollectionsStore.ts
+++ b/app/stores/CollectionsStore.ts
@@ -11,7 +11,6 @@ import {
} from "@shared/types";
import Collection from "~/models/Collection";
import { client } from "~/utils/ApiClient";
-import { AuthorizationError, NotFoundError } from "~/utils/errors";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -38,8 +37,7 @@ export default class CollectionsStore extends Store {
}
/**
- * Returns the currently active collection, or undefined if not in the context
- * of a collection.
+ * Returns the currently active collection, or undefined if not in the context of a collection.
*
* @returns The active Collection or undefined
*/
@@ -68,6 +66,25 @@ export default class CollectionsStore extends Store {
});
}
+ /**
+ * Returns all collections that are require explicit permission to access.
+ */
+ @computed
+ get private(): Collection[] {
+ return this.all.filter((collection) => collection.isPrivate);
+ }
+
+ /**
+ * Returns all collections that are accessible by default.
+ */
+ @computed
+ get nonPrivate(): Collection[] {
+ return this.all.filter((collection) => !collection.isPrivate);
+ }
+
+ /**
+ * Returns all collections that are accessible to the current user.
+ */
@computed
get all(): Collection[] {
return sortBy(
@@ -164,32 +181,10 @@ export default class CollectionsStore extends Store {
}
@action
- async fetch(
- id: string,
- options: Record = {}
- ): Promise {
- const item = this.get(id) || this.getByUrl(id);
- if (item && !options.force) {
- return item;
- }
- this.isFetching = true;
-
- try {
- const res = await client.post(`/collections.info`, {
- id,
- });
- invariant(res?.data, "Collection not available");
- this.addPolicies(res.policies);
- return this.add(res.data);
- } catch (err) {
- if (err instanceof AuthorizationError || err instanceof NotFoundError) {
- this.remove(id);
- }
-
- throw err;
- } finally {
- this.isFetching = false;
- }
+ async fetch(id: string, options?: { force: boolean }): Promise {
+ const model = await super.fetch(id, options);
+ await model.fetchDocuments(options);
+ return model;
}
@computed
@@ -201,9 +196,10 @@ export default class CollectionsStore extends Store {
);
}
- star = async (collection: Collection) => {
+ star = async (collection: Collection, index?: string) => {
await this.rootStore.stars.create({
collectionId: collection.id,
+ index,
});
};
diff --git a/app/stores/CommentsStore.ts b/app/stores/CommentsStore.ts
index 9f1603375a4d9..d1c664c1e1532 100644
--- a/app/stores/CommentsStore.ts
+++ b/app/stores/CommentsStore.ts
@@ -10,8 +10,6 @@ import RootStore from "./RootStore";
import Store from "./base/Store";
export default class CommentsStore extends Store {
- apiEndpoint = "comments";
-
constructor(rootStore: RootStore) {
super(rootStore, Comment);
}
diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts
index 6921dbd4957cf..fc01384f36125 100644
--- a/app/stores/DocumentsStore.ts
+++ b/app/stores/DocumentsStore.ts
@@ -43,9 +43,6 @@ export default class DocumentsStore extends Store {
{ sharedTree: NavigationNode; team: PublicTeam } | undefined
> = new Map();
- @observable
- searchCache: Map = new Map();
-
@observable
backlinks: Map = new Map();
@@ -173,10 +170,6 @@ export default class DocumentsStore extends Store {
return naturalSort(this.inCollection(collectionId), "title");
}
- searchResults(query: string): SearchResult[] | undefined {
- return this.searchCache.get(query);
- }
-
@computed
get archived(): Document[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
@@ -367,7 +360,10 @@ export default class DocumentsStore extends Store {
this.fetchNamedPage("list", options);
@action
- searchTitles = async (query: string, options?: SearchParams) => {
+ searchTitles = async (
+ query: string,
+ options?: SearchParams
+ ): Promise => {
const compactedOptions = omitBy(options, (o) => !o);
const res = await client.post("/documents.search_titles", {
...compactedOptions,
@@ -388,15 +384,12 @@ export default class DocumentsStore extends Store {
return null;
}
return {
+ id: document.id,
document,
};
})
);
- const existing = this.searchCache.get(query) || [];
- // splice modifies any existing results, taking into account pagination
- existing.splice(0, existing.length, ...results);
- this.searchCache.set(query, existing);
- return res.data;
+ return results;
};
@action
@@ -431,11 +424,7 @@ export default class DocumentsStore extends Store {
};
})
);
- const existing = this.searchCache.get(query) || [];
- // splice modifies any existing results, taking into account pagination
- existing.splice(options.offset || 0, options.limit || 0, ...results);
- this.searchCache.set(query, existing);
- return res.data;
+ return results;
};
@action
@@ -739,9 +728,10 @@ export default class DocumentsStore extends Store {
});
};
- star = (document: Document) =>
+ star = (document: Document, index?: string) =>
this.rootStore.stars.create({
documentId: document.id,
+ index,
});
unstar = (document: Document) => {
diff --git a/app/stores/MembershipsStore.ts b/app/stores/MembershipsStore.ts
index 6e361fb686f50..103f08c2554a2 100644
--- a/app/stores/MembershipsStore.ts
+++ b/app/stores/MembershipsStore.ts
@@ -71,16 +71,41 @@ export default class MembershipsStore extends Store {
id: collectionId,
userId,
});
- this.remove(`${userId}-${collectionId}`);
- this.rootStore.users.remove(userId);
+ this.revoke({ userId, collectionId });
}
@action
removeCollectionMemberships = (collectionId: string) => {
this.data.forEach((membership, key) => {
- if (key.includes(collectionId)) {
+ if (membership.collectionId === collectionId) {
this.remove(key);
}
});
};
+
+ @action
+ revoke = ({
+ userId,
+ collectionId,
+ }: {
+ collectionId: string;
+ userId: string;
+ }) => {
+ const membership = this.find(collectionId, userId);
+ if (membership) {
+ this.remove(membership.id);
+ }
+ };
+
+ /**
+ * Find a collection user membership by collectionId and userId
+ *
+ * @param collectionId The collection ID
+ * @param userId The user ID
+ * @returns The collection user membership or undefined if not found.
+ */
+ find = (collectionId: string, userId: string) =>
+ Array.from(this.data.values()).find(
+ (m) => m.userId === userId && m.collectionId === collectionId
+ );
}
diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts
index 27fa44ace0b0a..9b46d7afbe77d 100644
--- a/app/stores/RootStore.ts
+++ b/app/stores/RootStore.ts
@@ -1,3 +1,6 @@
+import invariant from "invariant";
+import lowerFirst from "lodash/lowerFirst";
+import pluralize from "pluralize";
import ApiKeysStore from "./ApiKeysStore";
import AuthStore from "./AuthStore";
import AuthenticationProvidersStore from "./AuthenticationProvidersStore";
@@ -25,6 +28,7 @@ import UiStore from "./UiStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
import WebhookSubscriptionsStore from "./WebhookSubscriptionStore";
+import Store from "./base/Store";
export default class RootStore {
apiKeys: ApiKeysStore;
@@ -56,41 +60,79 @@ export default class RootStore {
webhookSubscriptions: WebhookSubscriptionsStore;
constructor() {
- // PoliciesStore must be initialized before AuthStore
- this.policies = new PoliciesStore(this);
- this.apiKeys = new ApiKeysStore(this);
- this.authenticationProviders = new AuthenticationProvidersStore(this);
- this.auth = new AuthStore(this);
- this.collections = new CollectionsStore(this);
- this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
- this.comments = new CommentsStore(this);
- this.dialogs = new DialogsStore();
- this.documents = new DocumentsStore(this);
- this.events = new EventsStore(this);
- this.groups = new GroupsStore(this);
- this.groupMemberships = new GroupMembershipsStore(this);
- this.integrations = new IntegrationsStore(this);
- this.memberships = new MembershipsStore(this);
- this.notifications = new NotificationsStore(this);
- this.pins = new PinsStore(this);
- this.presence = new DocumentPresenceStore();
- this.revisions = new RevisionsStore(this);
- this.searches = new SearchesStore(this);
- this.shares = new SharesStore(this);
- this.stars = new StarsStore(this);
- this.subscriptions = new SubscriptionsStore(this);
- this.ui = new UiStore();
- this.users = new UsersStore(this);
- this.views = new ViewsStore(this);
- this.fileOperations = new FileOperationsStore(this);
- this.webhookSubscriptions = new WebhookSubscriptionsStore(this);
+ // Models
+ this.registerStore(ApiKeysStore);
+ this.registerStore(AuthenticationProvidersStore);
+ this.registerStore(CollectionsStore);
+ this.registerStore(CollectionGroupMembershipsStore);
+ this.registerStore(CommentsStore);
+ this.registerStore(DocumentsStore);
+ this.registerStore(EventsStore);
+ this.registerStore(GroupsStore);
+ this.registerStore(GroupMembershipsStore);
+ this.registerStore(IntegrationsStore);
+ this.registerStore(MembershipsStore);
+ this.registerStore(NotificationsStore);
+ this.registerStore(PinsStore);
+ this.registerStore(PoliciesStore);
+ this.registerStore(RevisionsStore);
+ this.registerStore(SearchesStore);
+ this.registerStore(SharesStore);
+ this.registerStore(StarsStore);
+ this.registerStore(SubscriptionsStore);
+ this.registerStore(UsersStore);
+ this.registerStore(ViewsStore);
+ this.registerStore(FileOperationsStore);
+ this.registerStore(WebhookSubscriptionsStore);
+
+ // Non-models
+ this.registerStore(DocumentPresenceStore, "presence");
+ this.registerStore(DialogsStore, "dialogs");
+ this.registerStore(UiStore, "ui");
+
+ // AuthStore must be initialized last as it makes use of the other stores.
+ this.registerStore(AuthStore, "auth");
+ }
+
+ /**
+ * Get a store by model name.
+ *
+ * @param modelName
+ */
+ public getStoreForModelName(
+ modelName: string
+ ): RootStore[K] {
+ const storeName = this.getStoreNameForModelName(modelName);
+ const store = this[storeName];
+ invariant(store, `No store found for model name "${modelName}"`);
+
+ return store;
}
- logout() {
+ /**
+ * Clear all data from the stores except for auth and ui.
+ */
+ public clear() {
Object.getOwnPropertyNames(this)
.filter((key) => ["auth", "ui"].includes(key) === false)
.forEach((key) => {
this[key]?.clear?.();
});
}
+
+ /**
+ * Register a store with the root store.
+ *
+ * @param StoreClass
+ */
+ private registerStore(StoreClass: T, name?: string) {
+ // @ts-expect-error TS thinks we are instantiating an abstract class.
+ const store = new StoreClass(this);
+ const storeName = name ?? this.getStoreNameForModelName(store.modelName);
+ this[storeName] = store;
+ }
+
+ private getStoreNameForModelName(modelName: string) {
+ return pluralize(lowerFirst(modelName));
+ }
}
diff --git a/app/stores/SearchesStore.ts b/app/stores/SearchesStore.ts
index 6cc6c89f32b5e..b18bc6bfb3a53 100644
--- a/app/stores/SearchesStore.ts
+++ b/app/stores/SearchesStore.ts
@@ -7,14 +7,14 @@ import Store, { RPCAction } from "./base/Store";
export default class SearchesStore extends Store {
actions = [RPCAction.List, RPCAction.Delete];
- apiEndpoint = "searches";
-
constructor(rootStore: RootStore) {
super(rootStore, SearchQuery);
}
@computed
get recent(): SearchQuery[] {
- return uniqBy(this.orderedData, "query").slice(0, 8);
+ return uniqBy(this.orderedData, "query")
+ .filter((search) => search.source === "app")
+ .slice(0, 8);
}
}
diff --git a/app/stores/SharesStore.ts b/app/stores/SharesStore.ts
index da63a95f2e41d..3f0f60de5ed33 100644
--- a/app/stores/SharesStore.ts
+++ b/app/stores/SharesStore.ts
@@ -60,7 +60,7 @@ export default class SharesStore extends Store {
this.isFetching = true;
try {
- const res = await client.post(`/${this.modelName}s.info`, {
+ const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
});
diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts
index e8ac7980893e5..059ce0d8fc59d 100644
--- a/app/stores/base/Store.ts
+++ b/app/stores/base/Store.ts
@@ -1,8 +1,10 @@
import invariant from "invariant";
+import flatten from "lodash/flatten";
import lowerFirst from "lodash/lowerFirst";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
-import { Class } from "utility-types";
+import pluralize from "pluralize";
+import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Model from "~/models/base/Model";
@@ -22,8 +24,6 @@ export enum RPCAction {
type FetchPageParams = PaginationParams & Record;
-export const DEFAULT_PAGINATION_LIMIT = 25;
-
export const PAGINATION_SYMBOL = Symbol.for("pagination");
export default abstract class Store {
@@ -39,7 +39,7 @@ export default abstract class Store {
@observable
isLoaded = false;
- model: Class;
+ model: typeof Model;
modelName: string;
@@ -56,13 +56,13 @@ export default abstract class Store {
RPCAction.Count,
];
- constructor(rootStore: RootStore, model: Class) {
+ constructor(rootStore: RootStore, model: typeof Model) {
this.rootStore = rootStore;
this.model = model;
- this.modelName = lowerFirst(model.name).replace(/\d$/, "");
+ this.modelName = model.modelName;
if (!this.apiEndpoint) {
- this.apiEndpoint = `${this.modelName}s`;
+ this.apiEndpoint = pluralize(lowerFirst(model.modelName));
}
}
@@ -89,6 +89,7 @@ export default abstract class Store {
return existingModel;
}
+ // @ts-expect-error TS thinks that we're instantiating an abstract class here
const newModel = new ModelClass(item, this);
this.data.set(newModel.id, newModel);
return newModel;
@@ -103,20 +104,21 @@ export default abstract class Store {
const inverseRelations = getInverseRelationsForModelClass(this.model);
inverseRelations.forEach((relation) => {
- // TODO: Need a better way to get the store for a given model name.
- const store = this.rootStore[`${relation.modelName.toLowerCase()}s`];
- const items = store.orderedData.filter(
- (item: Model) => item[relation.idKey] === id
- );
-
- if (relation.options.onDelete === "cascade") {
- items.forEach((item: Model) => store.remove(item.id));
- }
-
- if (relation.options.onDelete === "null") {
- items.forEach((item: Model) => {
- item[relation.idKey] = null;
- });
+ const store = this.rootStore.getStoreForModelName(relation.modelName);
+ if ("orderedData" in store) {
+ const items = (store.orderedData as Model[]).filter(
+ (item) => item[relation.idKey] === id
+ );
+
+ if (relation.options.onDelete === "cascade") {
+ items.forEach((item) => store.remove(item.id));
+ }
+
+ if (relation.options.onDelete === "null") {
+ items.forEach((item) => {
+ item[relation.idKey] = null;
+ });
+ }
}
});
@@ -268,6 +270,20 @@ export default abstract class Store {
}
};
+ @action
+ fetchAll = async (): Promise => {
+ const limit = Pagination.defaultLimit;
+ const response = await this.fetchPage({ limit });
+ const pages = Math.ceil(response[PAGINATION_SYMBOL].total / limit);
+ const fetchPages = [];
+ for (let page = 1; page < pages; page++) {
+ fetchPages.push(this.fetchPage({ offset: page * limit, limit }));
+ }
+
+ const results = await Promise.all(fetchPages);
+ return flatten(results);
+ };
+
@computed
get orderedData(): T[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
diff --git a/app/typings/styled-components.d.ts b/app/typings/styled-components.d.ts
index f30b74449dfe5..bb65ceb13a9c9 100644
--- a/app/typings/styled-components.d.ts
+++ b/app/typings/styled-components.d.ts
@@ -81,7 +81,6 @@ declare module "styled-components" {
accent: string;
yellow: string;
warmGrey: string;
- searchHighlight: string;
danger: string;
warning: string;
success: string;
diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts
index d7867f6c4b88b..0275487e11f79 100644
--- a/app/utils/ApiClient.ts
+++ b/app/utils/ApiClient.ts
@@ -12,6 +12,7 @@ import {
NetworkError,
NotFoundError,
OfflineError,
+ PaymentRequiredError,
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
@@ -160,6 +161,10 @@ class ApiClient {
throw new BadRequestError(error.message);
}
+ if (response.status === 402) {
+ throw new PaymentRequiredError(error.message);
+ }
+
if (response.status === 403) {
if (error.error === "user_suspended") {
await stores.auth.logout(false, false);
diff --git a/app/utils/errors.ts b/app/utils/errors.ts
index 67f4b176363bf..421d6ad3f2ba5 100644
--- a/app/utils/errors.ts
+++ b/app/utils/errors.ts
@@ -8,6 +8,8 @@ export class NetworkError extends ExtendableError {}
export class NotFoundError extends ExtendableError {}
+export class PaymentRequiredError extends ExtendableError {}
+
export class OfflineError extends ExtendableError {}
export class ServiceUnavailableError extends ExtendableError {}
diff --git a/app/utils/isTextInput.ts b/app/utils/isTextInput.ts
index 517af877cd720..e312a8b8ab9c2 100644
--- a/app/utils/isTextInput.ts
+++ b/app/utils/isTextInput.ts
@@ -1,8 +1,9 @@
const inputs = ["input", "select", "button", "textarea"]; // detect if node is a text input element
-export default function isTextInput(element: HTMLElement): boolean {
- return (
+export default function isTextInput(element: Element): boolean {
+ return !!(
element &&
+ element.tagName &&
(inputs.indexOf(element.tagName.toLowerCase()) !== -1 ||
element.attributes.getNamedItem("role")?.value === "textbox" ||
element.attributes.getNamedItem("contenteditable")?.value === "true")
diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts
index c32d6abc40921..840330a97cb09 100644
--- a/app/utils/routeHelpers.ts
+++ b/app/utils/routeHelpers.ts
@@ -2,9 +2,10 @@ import queryString from "query-string";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
+import env from "~/env";
export function homePath(): string {
- return "/home";
+ return env.ROOT_SHARE_ID ? "/" : "/home";
}
export function draftsPath(): string {
@@ -115,6 +116,10 @@ export function searchPath(
}
export function sharedDocumentPath(shareId: string, docPath?: string) {
+ if (shareId === env.ROOT_SHARE_ID) {
+ return docPath ? docPath : "/";
+ }
+
return docPath ? `/s/${shareId}${docPath}` : `/s/${shareId}`;
}
diff --git a/package.json b/package.json
index 5433bd5e09d0d..841ab08b33d90 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"build": "yarn clean && yarn vite:build && yarn build:i18n && yarn build:server",
"start": "node ./build/server/index.js",
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"",
- "dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
+ "dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
"lint": "eslint app server shared plugins",
"prepare": "husky install",
@@ -28,16 +28,16 @@
"test:app": "TZ=UTC jest --config=.jestconfig.json --selectProjects app",
"test:shared": "TZ=UTC jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
"test:server": "TZ=UTC jest --config=.jestconfig.json --selectProjects server",
- "vite:dev": "vite",
- "vite:build": "vite build",
- "vite:preview": "vite preview"
+ "vite:dev": "VITE_CJS_IGNORE_WARNING=true vite",
+ "vite:build": "VITE_CJS_IGNORE_WARNING=true vite build",
+ "vite:preview": "VITE_CJS_IGNORE_WARNING=true vite preview"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/outline"
},
"engines": {
- "node": ">= 14 <=18"
+ "node": ">= 18 <=20"
},
"repository": {
"type": "git",
@@ -48,9 +48,9 @@
],
"dependencies": {
"@babel/core": "^7.22.5",
- "@babel/plugin-proposal-decorators": "^7.21.0",
+ "@babel/plugin-proposal-decorators": "^7.23.2",
"@babel/plugin-transform-destructuring": "^7.22.5",
- "@babel/plugin-transform-regenerator": "^7.22.10",
+ "@babel/plugin-transform-regenerator": "^7.23.3",
"@babel/preset-env": "^7.20.0",
"@babel/preset-react": "^7.22.15",
"@benrbray/prosemirror-math": "^0.2.2",
@@ -70,9 +70,9 @@
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
- "@sentry/node": "^7.59.2",
- "@sentry/react": "^7.51.2",
- "@sentry/tracing": "^7.51.2",
+ "@sentry/node": "^7.85.0",
+ "@sentry/react": "^7.85.0",
+ "@sentry/tracing": "^7.85.0",
"@tippyjs/react": "^4.2.6",
"@tommoor/remove-markdown": "^0.3.2",
"@types/form-data": "^2.5.0",
@@ -80,11 +80,11 @@
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
"autotrack": "^2.4.1",
- "aws-sdk": "^2.1464.0",
+ "aws-sdk": "^2.1510.0",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
"body-scroll-lock": "^4.0.0-beta.0",
- "bull": "^4.11.3",
+ "bull": "^4.11.5",
"cancan": "3.1.0",
"chalk": "^4.1.0",
"class-validator": "^0.14.0",
@@ -92,35 +92,35 @@
"compressorjs": "^1.2.1",
"cookie": "^0.5.0",
"copy-to-clipboard": "^3.3.3",
- "core-js": "^3.30.2",
+ "core-js": "^3.33.3",
"crypto-js": "^4.2.0",
- "datadog-metrics": "^0.11.0",
+ "css-inline": "^0.11.2",
+ "datadog-metrics": "^0.11.1",
"date-fns": "^2.30.0",
"dd-trace": "^3.33.0",
"diff": "^5.1.0",
"dotenv": "^4.0.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.5.2",
- "emoji-regex": "^10.2.1",
+ "emoji-regex": "^10.3.0",
"es6-error": "^4.1.1",
- "fetch-retry": "^5.0.5",
+ "fetch-retry": "^5.0.6",
"fetch-with-proxy": "^3.0.1",
"focus-visible": "^5.2.0",
"form-data": "^4.0.0",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
- "fs-extra": "^11.1.1",
+ "fs-extra": "^11.2.0",
"fuzzy-search": "^3.2.1",
"glob": "^8.1.0",
"http-errors": "2.0.0",
"i18next": "^22.5.1",
- "i18next-fs-backend": "^2.1.5",
+ "i18next-fs-backend": "^2.3.1",
"i18next-http-backend": "^2.2.2",
- "inline-css": "^4.0.2",
"invariant": "^2.2.4",
"ioredis": "^5.3.2",
"is-printable-key-event": "^1.0.0",
- "jsdom": "^22.0.0",
+ "jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.8",
@@ -130,13 +130,13 @@
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
"koa-logger": "^3.2.1",
- "koa-mount": "^3.0.0",
+ "koa-mount": "^4.0.0",
"koa-router": "7.4.0",
"koa-send": "5.0.1",
"koa-sslify": "5.0.1",
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
- "mammoth": "^1.5.1",
+ "mammoth": "^1.6.0",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -157,20 +157,21 @@
"patch-package": "^7.0.2",
"pg": "^8.11.1",
"pg-tsquery": "^8.4.1",
+ "pluralize": "^8.0.0",
"polished": "^4.2.2",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.5.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.3.2",
- "prosemirror-inputrules": "^1.2.1",
+ "prosemirror-inputrules": "^1.3.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.11.0",
"prosemirror-model": "^1.19.2",
"prosemirror-schema-list": "^1.3.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.3.4",
- "prosemirror-transform": "^1.7.3",
+ "prosemirror-transform": "^1.8.0",
"prosemirror-view": "^1.32.0",
"query-string": "^7.1.3",
"quoted-printable": "^1.0.1",
@@ -183,7 +184,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
"react-dropzone": "^11.7.1",
- "react-helmet-async": "^1.3.0",
+ "react-helmet-async": "^2.0.1",
"react-hook-form": "^7.41.5",
"react-i18next": "^12.3.1",
"react-merge-refs": "^2.0.2",
@@ -194,19 +195,19 @@
"react-waypoint": "^10.3.0",
"react-window": "^1.8.9",
"reakit": "^1.3.11",
- "reflect-metadata": "^0.1.13",
+ "reflect-metadata": "^0.1.14",
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.0.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.5.2",
- "sequelize": "^6.33.0",
+ "sequelize": "^6.35.2",
"sequelize-cli": "^6.6.1",
"sequelize-encrypted": "^1.0.0",
- "sequelize-typescript": "^2.1.5",
+ "sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
- "slugify": "^1.6.5",
+ "slugify": "^1.6.6",
"smooth-scroll-into-view-if-needed": "^1.1.33",
"socket.io": "^4.7.2",
"socket.io-client": "^4.6.1",
@@ -218,7 +219,7 @@
"styled-components-breakpoint": "^2.1.1",
"styled-normalize": "^8.0.7",
"throng": "^5.0.0",
- "tiny-cookie": "^2.4.1",
+ "tiny-cookie": "^2.5.1",
"tmp": "^0.2.1",
"turndown": "^7.1.2",
"umzug": "^3.2.1",
@@ -226,87 +227,89 @@
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
"validator": "13.9.0",
- "vite": "^4.4.11",
- "vite-plugin-pwa": "^0.14.4",
+ "vite": "^5.0.5",
+ "vite-plugin-pwa": "^0.17.0",
"winston": "^3.10.0",
"ws": "^7.5.9",
"y-indexeddb": "^9.0.11",
"y-protocols": "^1.0.5",
+ "yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.22.4"
},
"devDependencies": {
- "@babel/cli": "^7.21.5",
- "@babel/preset-typescript": "^7.21.4",
+ "@babel/cli": "^7.23.4",
+ "@babel/preset-typescript": "^7.23.3",
"@faker-js/faker": "^8.0.2",
"@relative-ci/agent": "^4.1.10",
- "@types/addressparser": "^1.0.1",
+ "@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.0",
- "@types/crypto-js": "^4.1.2",
+ "@types/crypto-js": "^4.2.1",
"@types/diff": "^5.0.4",
"@types/emoji-regex": "^9.2.0",
- "@types/enzyme": "^3.10.13",
- "@types/enzyme-adapter-react-16": "^1.0.6",
+ "@types/enzyme": "^3.10.18",
+ "@types/enzyme-adapter-react-16": "^1.0.9",
"@types/express-useragent": "^1.0.2",
"@types/formidable": "^2.0.6",
- "@types/fs-extra": "^11.0.1",
+ "@types/fs-extra": "^11.0.4",
"@types/fuzzy-search": "^2.1.2",
"@types/glob": "^8.0.1",
"@types/google.analytics": "^0.0.42",
- "@types/inline-css": "^3.0.1",
"@types/invariant": "^2.2.35",
"@types/jest": "^29.4.0",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.0",
"@types/koa": "^2.13.6",
- "@types/koa-compress": "^4.0.3",
- "@types/koa-helmet": "^6.0.4",
- "@types/koa-logger": "^3.1.2",
- "@types/koa-mount": "^4.0.1",
- "@types/koa-router": "^7.4.4",
- "@types/koa-send": "^4.1.3",
- "@types/koa-sslify": "^4.0.3",
+ "@types/koa-compress": "^4.0.6",
+ "@types/koa-helmet": "^6.0.8",
+ "@types/koa-logger": "^3.1.5",
+ "@types/koa-mount": "^4.0.5",
+ "@types/koa-router": "^7.4.8",
+ "@types/koa-send": "^4.1.6",
+ "@types/koa-sslify": "^4.0.6",
"@types/koa-useragent": "^2.1.2",
"@types/markdown-it": "^12.2.3",
- "@types/markdown-it-container": "^2.0.6",
- "@types/markdown-it-emoji": "^2.0.2",
+ "@types/markdown-it-container": "^2.0.9",
+ "@types/markdown-it-emoji": "^2.0.4",
"@types/mermaid": "^9.2.0",
- "@types/mime-types": "^2.1.1",
- "@types/natural-sort": "^0.0.22",
- "@types/node": "18.18.6",
- "@types/node-fetch": "^2.6.5",
- "@types/nodemailer": "^6.4.9",
- "@types/passport-oauth2": "^1.4.11",
- "@types/quoted-printable": "^1.0.0",
- "@types/randomstring": "^1.1.8",
+ "@types/mime-types": "^2.1.4",
+ "@types/natural-sort": "^0.0.24",
+ "@types/node": "20.10.0",
+ "@types/node-fetch": "^2.6.9",
+ "@types/nodemailer": "^6.4.14",
+ "@types/passport-oauth2": "^1.4.15",
+ "@types/pluralize": "^0.0.33",
+ "@types/quoted-printable": "^1.0.2",
+ "@types/randomstring": "^1.1.11",
"@types/react": "^17.0.34",
- "@types/react-avatar-editor": "^13.0.0",
- "@types/react-color": "^3.0.9",
+ "@types/react-avatar-editor": "^13.0.2",
+ "@types/react-color": "^3.0.10",
"@types/react-dom": "^17.0.11",
- "@types/react-helmet": "^6.1.7",
- "@types/react-portal": "^4.0.4",
- "@types/react-router-dom": "^5.3.2",
- "@types/react-table": "^7.7.14",
+ "@types/react-helmet": "^6.1.9",
+ "@types/react-portal": "^4.0.6",
+ "@types/react-router-dom": "^5.3.3",
+ "@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.2",
- "@types/react-window": "^1.8.6",
- "@types/readable-stream": "^4.0.2",
- "@types/redis-info": "^3.0.0",
- "@types/refractor": "^3.0.2",
- "@types/resolve-path": "^1.4.0",
- "@types/semver": "^7.5.2",
- "@types/sequelize": "^4.28.16",
- "@types/slug": "^5.0.3",
- "@types/stoppable": "^1.1.1",
- "@types/styled-components": "^5.1.26",
- "@types/throng": "^5.0.4",
- "@types/tmp": "^0.2.3",
- "@types/turndown": "^5.0.1",
- "@types/utf8": "^3.0.1",
- "@types/validator": "^13.7.17",
+ "@types/react-window": "^1.8.8",
+ "@types/readable-stream": "^4.0.10",
+ "@types/redis-info": "^3.0.3",
+ "@types/refractor": "^3.4.0",
+ "@types/resolve-path": "^1.4.2",
+ "@types/semver": "^7.5.6",
+ "@types/sequelize": "^4.28.19",
+ "@types/slug": "^5.0.7",
+ "@types/stoppable": "^1.1.3",
+ "@types/styled-components": "^5.1.32",
+ "@types/throng": "^5.0.7",
+ "@types/tmp": "^0.2.6",
+ "@types/turndown": "^5.0.4",
+ "@types/utf8": "^3.0.3",
+ "@types/validator": "^13.11.7",
+ "@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"babel-eslint": "^10.1.0",
- "babel-jest": "^29.6.4",
+ "babel-jest": "^29.7.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
@@ -323,11 +326,11 @@
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
- "eslint-plugin-react": "^7.20.0",
+ "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.2",
"i18next-parser": "^7.9.0",
- "jest-cli": "^29.6.4",
+ "jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.6.4",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
@@ -336,7 +339,7 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
- "rollup-plugin-webpack-stats": "^0.2.0",
+ "rollup-plugin-webpack-stats": "^0.2.2",
"terser": "^5.19.2",
"typescript": "^5.0.0",
"vite-plugin-static-copy": "^0.17.0",
@@ -345,12 +348,13 @@
"resolutions": {
"body-scroll-lock": "^4.0.0-beta.0",
"d3": "^7.0.0",
+ "debug": "4.3.4",
"node-fetch": "^2.6.12",
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4",
"qs": "6.9.7",
- "rollup": "^3.14.0"
+ "rollup": "^4.5.1"
},
- "version": "0.72.0"
+ "version": "0.74.0"
}
diff --git a/plugins/email/server/auth/email.ts b/plugins/email/server/auth/email.ts
index 1ec173b8b0bdf..a67fddbfcfdd2 100644
--- a/plugins/email/server/auth/email.ts
+++ b/plugins/email/server/auth/email.ts
@@ -102,7 +102,7 @@ router.get("email.callback", async (ctx) => {
}
if (user.isSuspended) {
- return ctx.redirect("/?notice=suspended");
+ return ctx.redirect("/?notice=user-suspended");
}
if (user.isInvited) {
diff --git a/plugins/storage/server/api/files.ts b/plugins/storage/server/api/files.ts
index a5c9e2b84e415..2ab8d3aa675f6 100644
--- a/plugins/storage/server/api/files.ts
+++ b/plugins/storage/server/api/files.ts
@@ -30,7 +30,12 @@ router.post(
rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(),
validate(T.FilesCreateSchema),
- multipart({ maximumFileSize: env.FILE_STORAGE_UPLOAD_MAX_SIZE }),
+ multipart({
+ maximumFileSize: Math.max(
+ env.FILE_STORAGE_UPLOAD_MAX_SIZE,
+ env.MAXIMUM_IMPORT_SIZE
+ ),
+ }),
async (ctx: APIContext) => {
const actor = ctx.state.auth.user;
const { key } = ctx.input.body;
diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts
index 230e871cc7d4f..827b150df375e 100644
--- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts
+++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts
@@ -441,7 +441,7 @@ export default class DeliverWebhookTask extends BaseTask {
event,
subscription,
payload: {
- id: `${event.userId}-${event.collectionId}`,
+ id: event.data.membershipId,
model: model && presentMembership(model),
collection: model && presentCollection(model.collection!),
user: model && presentUser(model.user),
@@ -468,7 +468,7 @@ export default class DeliverWebhookTask extends BaseTask {
event,
subscription,
payload: {
- id: `${event.modelId}-${event.collectionId}`,
+ id: event.data.membershipId,
model: model && presentCollectionGroupMembership(model),
collection: model && presentCollection(model.collection!),
group: model && presentGroup(model.group),
diff --git a/public/images/gitlab.png b/public/images/gitlab.png
new file mode 100644
index 0000000000000..66bd9a1edfa78
Binary files /dev/null and b/public/images/gitlab.png differ
diff --git a/public/images/icon-192.png b/public/images/icon-192.png
new file mode 100644
index 0000000000000..4637f7f8bd19f
Binary files /dev/null and b/public/images/icon-192.png differ
diff --git a/public/images/icon-256.png b/public/images/icon-256.png
deleted file mode 100644
index df8464e3ff6a1..0000000000000
Binary files a/public/images/icon-256.png and /dev/null differ
diff --git a/public/images/icon-512.png b/public/images/icon-512.png
index 8c01167bb7831..4fa1e55370499 100644
Binary files a/public/images/icon-512.png and b/public/images/icon-512.png differ
diff --git a/public/images/instagram.png b/public/images/instagram.png
new file mode 100644
index 0000000000000..3ee28e7b243d1
Binary files /dev/null and b/public/images/instagram.png differ
diff --git a/server/collaboration/ConnectionLimitExtension.ts b/server/collaboration/ConnectionLimitExtension.ts
index 3bb4e401739f5..3077009d97f35 100644
--- a/server/collaboration/ConnectionLimitExtension.ts
+++ b/server/collaboration/ConnectionLimitExtension.ts
@@ -1,6 +1,6 @@
import {
Extension,
- onConnectPayload,
+ connectedPayload,
onDisconnectPayload,
} from "@hocuspocus/server";
import env from "@server/env";
@@ -41,10 +41,10 @@ export class ConnectionLimitExtension implements Extension {
}
/**
- * onConnect hook
- * @param data The connect payload
+ * connected hook
+ * @param data The connected payload
*/
- onConnect({ documentName, socketId }: withContext) {
+ connected({ documentName, socketId }: withContext) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
diff --git a/server/collaboration/LoggerExtension.ts b/server/collaboration/LoggerExtension.ts
index 473f2a46472cd..d6540267c0287 100644
--- a/server/collaboration/LoggerExtension.ts
+++ b/server/collaboration/LoggerExtension.ts
@@ -1,8 +1,9 @@
import {
- onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
Extension,
+ connectedPayload,
+ onConnectPayload,
} from "@hocuspocus/server";
import Logger from "@server/logging/Logger";
import { withContext } from "./types";
@@ -18,6 +19,13 @@ export default class LoggerExtension implements Extension {
Logger.info("multiplayer", `New connection to "${data.documentName}"`);
}
+ async connected(data: withContext) {
+ Logger.info(
+ "multiplayer",
+ `Authenticated connection to "${data.documentName}"`
+ );
+ }
+
async onDisconnect(data: withContext) {
Logger.info("multiplayer", `Closed connection to "${data.documentName}"`, {
userId: data.context.user?.id,
diff --git a/server/collaboration/MetricsExtension.ts b/server/collaboration/MetricsExtension.ts
index b5ee6c26cc36c..1b73749f8f5a0 100644
--- a/server/collaboration/MetricsExtension.ts
+++ b/server/collaboration/MetricsExtension.ts
@@ -1,9 +1,9 @@
import {
onChangePayload,
- onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
Extension,
+ connectedPayload,
} from "@hocuspocus/server";
import Metrics from "@server/logging/Metrics";
import { withContext } from "./types";
@@ -28,7 +28,7 @@ export default class MetricsExtension implements Extension {
});
}
- async onConnect({ documentName, instance }: withContext) {
+ async connected({ documentName, instance }: withContext) {
Metrics.increment("collaboration.connect", {
documentName,
});
diff --git a/server/commands/accountProvisioner.ts b/server/commands/accountProvisioner.ts
index b32f598d0e739..11d00f2179e65 100644
--- a/server/commands/accountProvisioner.ts
+++ b/server/commands/accountProvisioner.ts
@@ -1,7 +1,6 @@
import invariant from "invariant";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import {
- AuthenticationError,
InvalidAuthenticationError,
AuthenticationProviderDisabledError,
} from "@server/errors";
@@ -127,62 +126,59 @@ async function accountProvisioner({
throw AuthenticationProviderDisabledError();
}
- try {
- const result = await userProvisioner({
- name: userParams.name,
- email: userParams.email,
- isAdmin: isNewTeam || undefined,
- avatarUrl: userParams.avatarUrl,
- teamId: team.id,
- ip,
- authentication: emailMatchOnly
- ? undefined
- : {
- authenticationProviderId: authenticationProvider.id,
- ...authenticationParams,
- expiresAt: authenticationParams.expiresIn
- ? new Date(Date.now() + authenticationParams.expiresIn * 1000)
- : undefined,
- },
- });
- const { isNewUser, user } = result;
-
- if (isNewUser) {
- await new WelcomeEmail({
- to: user.email,
- teamUrl: team.url,
- }).schedule();
- }
+ result = await userProvisioner({
+ name: userParams.name,
+ email: userParams.email,
+ isAdmin: isNewTeam || undefined,
+ avatarUrl: userParams.avatarUrl,
+ teamId: team.id,
+ ip,
+ authentication: emailMatchOnly
+ ? undefined
+ : {
+ authenticationProviderId: authenticationProvider.id,
+ ...authenticationParams,
+ expiresAt: authenticationParams.expiresIn
+ ? new Date(Date.now() + authenticationParams.expiresIn * 1000)
+ : undefined,
+ },
+ });
+ const { isNewUser, user } = result;
- if (isNewUser || isNewTeam) {
- let provision = isNewTeam;
+ // TODO: Move to processor
+ if (isNewUser) {
+ await new WelcomeEmail({
+ to: user.email,
+ teamUrl: team.url,
+ }).schedule();
+ }
- // accounts for the case where a team is provisioned, but the user creation
- // failed. In this case we have a valid previously created team but no
- // onboarding collection.
- if (!isNewTeam) {
- const count = await Collection.count({
- where: {
- teamId: team.id,
- },
- });
- provision = count === 0;
- }
+ if (isNewUser || isNewTeam) {
+ let provision = isNewTeam;
- if (provision) {
- await team.provisionFirstCollection(user.id);
- }
+ // accounts for the case where a team is provisioned, but the user creation
+ // failed. In this case we have a valid previously created team but no
+ // onboarding collection.
+ if (!isNewTeam) {
+ const count = await Collection.count({
+ where: {
+ teamId: team.id,
+ },
+ });
+ provision = count === 0;
}
- return {
- user,
- team,
- isNewUser,
- isNewTeam,
- };
- } catch (err) {
- throw AuthenticationError(err.message);
+ if (provision) {
+ await team.provisionFirstCollection(user.id);
+ }
}
+
+ return {
+ user,
+ team,
+ isNewUser,
+ isNewTeam,
+ };
}
export default traceFunction({
diff --git a/server/commands/collectionDestroyer.ts b/server/commands/collectionDestroyer.ts
index 69180c97c0cd2..6d120d2e06c2b 100644
--- a/server/commands/collectionDestroyer.ts
+++ b/server/commands/collectionDestroyer.ts
@@ -1,5 +1,5 @@
-import { Transaction } from "sequelize";
-import { Collection, Event, User } from "@server/models";
+import { Transaction, Op } from "sequelize";
+import { Collection, Document, Event, User } from "@server/models";
type Props = {
/** The collection to delete */
@@ -20,6 +20,23 @@ export default async function collectionDestroyer({
}: Props) {
await collection.destroy({ transaction });
+ await Document.update(
+ {
+ lastModifiedById: user.id,
+ deletedAt: new Date(),
+ },
+ {
+ transaction,
+ where: {
+ teamId: collection.teamId,
+ collectionId: collection.id,
+ archivedAt: {
+ [Op.is]: null,
+ },
+ },
+ }
+ );
+
await Event.create(
{
name: "collections.delete",
diff --git a/server/commands/documentCollaborativeUpdater.ts b/server/commands/documentCollaborativeUpdater.ts
index da7c84878b021..31e9b59be2818 100644
--- a/server/commands/documentCollaborativeUpdater.ts
+++ b/server/commands/documentCollaborativeUpdater.ts
@@ -41,7 +41,8 @@ export default async function documentCollaborativeUpdater({
});
const state = Y.encodeStateAsUpdate(ydoc);
- const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
+ const content = yDocToProsemirrorJSON(ydoc, "default");
+ const node = Node.fromJSON(schema, content);
const text = serializer.serialize(node, undefined);
const isUnchanged = document.text === text;
const lastModifiedById = userId ?? document.lastModifiedById;
@@ -63,6 +64,7 @@ export default async function documentCollaborativeUpdater({
await document.update(
{
text,
+ content,
state: Buffer.from(state),
lastModifiedById,
collaboratorIds,
diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts
index 022d10b302915..cd8f88fd151bc 100644
--- a/server/commands/documentCreator.ts
+++ b/server/commands/documentCreator.ts
@@ -1,27 +1,32 @@
import { Transaction } from "sequelize";
+import { Optional } from "utility-types";
import { Document, Event, User } from "@server/models";
-import DocumentHelper from "@server/models/helpers/DocumentHelper";
+import TextHelper from "@server/models/helpers/TextHelper";
-type Props = {
- id?: string;
- urlId?: string;
- title: string;
- emoji?: string | null;
- text?: string;
+type Props = Optional<
+ Pick<
+ Document,
+ | "id"
+ | "urlId"
+ | "title"
+ | "text"
+ | "emoji"
+ | "collectionId"
+ | "parentDocumentId"
+ | "importId"
+ | "template"
+ | "fullWidth"
+ | "sourceMetadata"
+ | "editorVersion"
+ | "publishedAt"
+ | "createdAt"
+ | "updatedAt"
+ >
+> & {
state?: Buffer;
publish?: boolean;
- collectionId?: string | null;
- parentDocumentId?: string | null;
- importId?: string;
- publishedAt?: Date;
- template?: boolean;
templateDocument?: Document | null;
- fullWidth?: boolean;
- createdAt?: Date;
- updatedAt?: Date;
user: User;
- editorVersion?: string;
- source?: "import";
ip?: string;
transaction?: Transaction;
};
@@ -46,7 +51,7 @@ export default async function documentCreator({
user,
editorVersion,
publishedAt,
- source,
+ sourceMetadata,
ip,
transaction,
}: Props): Promise {
@@ -82,14 +87,15 @@ export default async function documentCreator({
templateId,
publishedAt,
importId,
+ sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
emoji: templateDocument ? templateDocument.emoji : emoji,
- title: DocumentHelper.replaceTemplateVariables(
+ title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user
),
- text: await DocumentHelper.replaceImagesWithAttachments(
- DocumentHelper.replaceTemplateVariables(
+ text: await TextHelper.replaceImagesWithAttachments(
+ TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.text : text,
user
),
@@ -112,7 +118,7 @@ export default async function documentCreator({
teamId: document.teamId,
actorId: user.id,
data: {
- source,
+ source: importId ? "import" : undefined,
title: document.title,
templateId,
},
@@ -137,7 +143,7 @@ export default async function documentCreator({
teamId: document.teamId,
actorId: user.id,
data: {
- source,
+ source: importId ? "import" : undefined,
title: document.title,
},
ip,
diff --git a/server/commands/documentImporter.ts b/server/commands/documentImporter.ts
index 46ba48b46118e..94f9a8dda1df3 100644
--- a/server/commands/documentImporter.ts
+++ b/server/commands/documentImporter.ts
@@ -10,8 +10,8 @@ import parseTitle from "@shared/utils/parseTitle";
import { DocumentValidation } from "@shared/validations";
import { traceFunction } from "@server/logging/tracing";
import { User } from "@server/models";
-import DocumentHelper from "@server/models/helpers/DocumentHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
+import TextHelper from "@server/models/helpers/TextHelper";
import turndownService from "@server/utils/turndown";
import { FileImportError, InvalidRequestError } from "../errors";
@@ -179,7 +179,6 @@ async function documentImporter({
let title = fileName.replace(/\.[^/.]+$/, "");
let text = await fileInfo.getMarkdown(content);
- text = text.trim();
// find and extract emoji near the beginning of the document.
const regex = emojiRegex();
@@ -191,19 +190,20 @@ async function documentImporter({
// If the first line of the imported text looks like a markdown heading
// then we can use this as the document title rather than the file name.
- if (text.startsWith("# ")) {
+ if (text.trim().startsWith("# ")) {
const result = parseTitle(text);
title = result.title;
text = text
+ .trim()
.replace(new RegExp(`#\\s+${escapeRegExp(title)}`), "")
.trimStart();
}
// Replace any
generated by the turndown plugin with escaped newlines
// to match our hardbreak parser.
- text = text.replace(/
/gi, "\\n");
+ text = text.trim().replace(/
/gi, "\\n");
- text = await DocumentHelper.replaceImagesWithAttachments(
+ text = await TextHelper.replaceImagesWithAttachments(
text,
user,
ip,
diff --git a/server/commands/documentLoader.ts b/server/commands/documentLoader.ts
index ab6173f553501..e64d7243ad983 100644
--- a/server/commands/documentLoader.ts
+++ b/server/commands/documentLoader.ts
@@ -7,6 +7,7 @@ import {
InvalidRequestError,
AuthorizationError,
AuthenticationError,
+ PaymentRequiredError,
} from "@server/errors";
import { Collection, Document, Share, User, Team } from "@server/models";
import { authorize, can } from "@server/policies";
@@ -119,6 +120,10 @@ export default async function loadDocument({
throw NotFoundError("Document could not be found for shareId");
}
+ if (document.isTrialImport) {
+ throw PaymentRequiredError();
+ }
+
// If the user has access to read the document, we can just update
// the last access date and return the document without additional checks.
const canReadDocument = user && can(user, "read", document);
@@ -202,6 +207,10 @@ export default async function loadDocument({
user && authorize(user, "read", document);
}
+ if (document.isTrialImport) {
+ throw PaymentRequiredError();
+ }
+
collection = document.collection;
}
diff --git a/server/commands/teamPermanentDeleter.ts b/server/commands/teamPermanentDeleter.ts
index 53e83f30a69b1..72f41bfd356c9 100644
--- a/server/commands/teamPermanentDeleter.ts
+++ b/server/commands/teamPermanentDeleter.ts
@@ -29,7 +29,7 @@ async function teamPermanentDeleter(team: Team) {
Logger.info(
"commands",
- `Permanently deleting team ${team.name} (${team.id})`
+ `Permanently destroying team ${team.name} (${team.id})`
);
const teamId = team.id;
let transaction!: Transaction;
diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts
index 37640991969dc..a7f11409233db 100644
--- a/server/commands/userInviter.ts
+++ b/server/commands/userInviter.ts
@@ -89,7 +89,7 @@ export default async function userInviter({
teamUrl: team.url,
}).schedule();
- if (env.ENVIRONMENT === "development") {
+ if (env.isDevelopment) {
Logger.info(
"email",
`Sign in immediately: ${
diff --git a/server/commands/userProvisioner.ts b/server/commands/userProvisioner.ts
index cf8f3f47b6ef1..f0101be89d4ab 100644
--- a/server/commands/userProvisioner.ts
+++ b/server/commands/userProvisioner.ts
@@ -4,6 +4,7 @@ import {
InvalidAuthenticationError,
InviteRequiredError,
} from "@server/errors";
+import Logger from "@server/logging/Logger";
import { Event, Team, User, UserAuthentication } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -211,6 +212,10 @@ export default async function userProvisioner({
// If the team settings are set to require invites, and there's no existing user record,
// throw an error and fail user creation.
if (team?.inviteRequired) {
+ Logger.info("authentication", "Sign in without invitation", {
+ teamId: team.id,
+ email,
+ });
throw InviteRequiredError();
}
diff --git a/server/emails/mailer.tsx b/server/emails/mailer.tsx
index 4d4f6f09c3a66..f53e44b6b64d7 100644
--- a/server/emails/mailer.tsx
+++ b/server/emails/mailer.tsx
@@ -8,8 +8,7 @@ import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import { baseStyles } from "./templates/components/EmailLayout";
-const useTestEmailService =
- env.ENVIRONMENT === "development" && !env.SMTP_USERNAME;
+const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
type SendMailOptions = {
to: string;
@@ -192,7 +191,7 @@ export class Mailer {
name: env.SMTP_NAME,
host: env.SMTP_HOST,
port: env.SMTP_PORT,
- secure: env.SMTP_SECURE ?? env.ENVIRONMENT === "production",
+ secure: env.SMTP_SECURE ?? env.isProduction,
auth: env.SMTP_USERNAME
? {
user: env.SMTP_USERNAME,
diff --git a/server/emails/templates/CommentCreatedEmail.tsx b/server/emails/templates/CommentCreatedEmail.tsx
index 157c5607c0523..2212369ab552b 100644
--- a/server/emails/templates/CommentCreatedEmail.tsx
+++ b/server/emails/templates/CommentCreatedEmail.tsx
@@ -1,12 +1,11 @@
-import inlineCss from "inline-css";
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
-import env from "@server/env";
import { Collection, Comment, Document } from "@server/models";
-import DocumentHelper from "@server/models/helpers/DocumentHelper";
+import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
+import TextHelper from "@server/models/helpers/TextHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -75,7 +74,7 @@ export default class CommentCreatedEmail extends BaseEmail<
}
);
- content = await DocumentHelper.attachmentsToSignedUrls(
+ content = await TextHelper.attachmentsToSignedUrls(
content,
document.teamId,
(4 * Day) / 1000
@@ -83,12 +82,7 @@ export default class CommentCreatedEmail extends BaseEmail<
if (content) {
// inline all css so that it works in as many email providers as possible.
- body = await inlineCss(content, {
- url: env.URL,
- applyStyleTags: true,
- applyLinkTags: false,
- removeStyleTags: true,
- });
+ body = HTMLHelper.inlineCSS(content);
}
const isReply = !!comment.parentCommentId;
diff --git a/server/emails/templates/CommentMentionedEmail.tsx b/server/emails/templates/CommentMentionedEmail.tsx
index b99aa4b1a34eb..e356b81db5c06 100644
--- a/server/emails/templates/CommentMentionedEmail.tsx
+++ b/server/emails/templates/CommentMentionedEmail.tsx
@@ -1,12 +1,11 @@
-import inlineCss from "inline-css";
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
-import env from "@server/env";
import { Collection, Comment, Document } from "@server/models";
-import DocumentHelper from "@server/models/helpers/DocumentHelper";
+import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
+import TextHelper from "@server/models/helpers/TextHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -67,7 +66,7 @@ export default class CommentMentionedEmail extends BaseEmail<
}
);
- content = await DocumentHelper.attachmentsToSignedUrls(
+ content = await TextHelper.attachmentsToSignedUrls(
content,
document.teamId,
(4 * Day) / 1000
@@ -75,12 +74,7 @@ export default class CommentMentionedEmail extends BaseEmail<
if (content) {
// inline all css so that it works in as many email providers as possible.
- body = await inlineCss(content, {
- url: env.URL,
- applyStyleTags: true,
- applyLinkTags: false,
- removeStyleTags: true,
- });
+ body = HTMLHelper.inlineCSS(content);
}
return {
diff --git a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx
index 9d967c7ab7bcd..ce215a1b1994c 100644
--- a/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx
+++ b/server/emails/templates/DocumentPublishedOrUpdatedEmail.tsx
@@ -1,10 +1,9 @@
-import inlineCss from "inline-css";
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
-import env from "@server/env";
import { Document, Collection, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
+import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
@@ -65,7 +64,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
const revision = await Revision.findByPk(revisionId);
if (revision) {
- const before = await revision.previous();
+ const before = await revision.before();
const content = await DocumentHelper.toEmailDiff(before, revision, {
includeTitle: false,
centered: false,
@@ -73,14 +72,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
});
// inline all css so that it works in as many email providers as possible.
- body = content
- ? await inlineCss(content, {
- url: env.URL,
- applyStyleTags: true,
- applyLinkTags: false,
- removeStyleTags: true,
- })
- : undefined;
+ body = content ? HTMLHelper.inlineCSS(content) : undefined;
}
}
diff --git a/server/emails/templates/SigninEmail.tsx b/server/emails/templates/SigninEmail.tsx
index f3be4b8d2b365..3db5f2952798d 100644
--- a/server/emails/templates/SigninEmail.tsx
+++ b/server/emails/templates/SigninEmail.tsx
@@ -41,7 +41,7 @@ signin page at: ${teamUrl}
}
protected render({ token, client, teamUrl }: Props) {
- if (env.ENVIRONMENT === "development") {
+ if (env.isDevelopment) {
logger.debug("email", `Sign-In link: ${this.signinLink(token, client)}`);
}
diff --git a/server/emails/templates/WelcomeEmail.tsx b/server/emails/templates/WelcomeEmail.tsx
index 57f54ef20d6ed..a7cb90796915a 100644
--- a/server/emails/templates/WelcomeEmail.tsx
+++ b/server/emails/templates/WelcomeEmail.tsx
@@ -41,7 +41,7 @@ ${teamUrl}/home
}
protected render({ teamUrl }: Props) {
- const welcomLink = `${teamUrl}/home?ref=welcome-email`;
+ const welcomeLink = `${teamUrl}/home?ref=welcome-email`;
return (
@@ -64,7 +64,7 @@ ${teamUrl}/home
- Open {env.APP_NAME}
+ Open {env.APP_NAME}