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 ( - -
- {children} + + {children} + + - -
+
+ ); }; 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
{item.title}
; + return
{item.title}
; } 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((filtered, collection) => { const can = policies.abilities(collection.id); - if (can.update) { + if (can.createDocument) { filtered.push({ type: "route", to: newTemplatePath(collection.id), diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index 11378019d717f..09a0a9b6535a6 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -195,7 +195,7 @@ function UserMenu({ user }: Props) { }, { type: "button", - title: t("Activate account"), + title: t("Activate user"), onClick: handleActivate, visible: !user.isInvited && user.isSuspended, }, diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts index 5d69535fc06aa..fdf652d8627ce 100644 --- a/app/models/ApiKey.ts +++ b/app/models/ApiKey.ts @@ -3,6 +3,8 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; class ApiKey extends Model { + static modelName = "ApiKey"; + @Field @observable id: string; diff --git a/app/models/AuthenticationProvider.ts b/app/models/AuthenticationProvider.ts index 2e80dd151b53b..1c56852e1d28e 100644 --- a/app/models/AuthenticationProvider.ts +++ b/app/models/AuthenticationProvider.ts @@ -3,6 +3,8 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; class AuthenticationProvider extends Model { + static modelName = "AuthenticationProvider"; + id: string; displayName: string; diff --git a/app/models/Collection.ts b/app/models/Collection.ts index eb6b0c7ec3adb..942ab39aa2fac 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -13,6 +13,8 @@ import { client } from "~/utils/ApiClient"; import Field from "./decorators/Field"; export default class Collection extends ParanoidModel { + static modelName = "Collection"; + store: CollectionsStore; @observable @@ -273,7 +275,7 @@ export default class Collection extends ParanoidModel { } @action - star = async () => this.store.star(this); + star = async (index?: string) => this.store.star(this, index); @action unstar = async () => this.store.unstar(this); diff --git a/app/models/CollectionGroupMembership.ts b/app/models/CollectionGroupMembership.ts index 1dbab80a0a33e..ece2c8b83231a 100644 --- a/app/models/CollectionGroupMembership.ts +++ b/app/models/CollectionGroupMembership.ts @@ -1,14 +1,25 @@ import { observable } from "mobx"; import { CollectionPermission } from "@shared/types"; +import Collection from "./Collection"; +import Group from "./Group"; import Model from "./base/Model"; +import Relation from "./decorators/Relation"; class CollectionGroupMembership extends Model { + static modelName = "CollectionGroupMembership"; + id: string; groupId: string; + @Relation(() => Group, { onDelete: "cascade" }) + group: Group; + collectionId: string; + @Relation(() => Collection, { onDelete: "cascade" }) + collection: Collection; + @observable permission: CollectionPermission; } diff --git a/app/models/Comment.ts b/app/models/Comment.ts index bfcda0cfa0769..8fc2c3660e3c0 100644 --- a/app/models/Comment.ts +++ b/app/models/Comment.ts @@ -8,6 +8,8 @@ import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; class Comment extends Model { + static modelName = "Comment"; + /** * Map to keep track of which users are currently typing a reply in this * comments thread. diff --git a/app/models/Document.ts b/app/models/Document.ts index cd079a48c2102..48835e56651f7 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -11,9 +11,11 @@ import DocumentsStore from "~/stores/DocumentsStore"; import User from "~/models/User"; import { client } from "~/utils/ApiClient"; import { settingsPath } from "~/utils/routeHelpers"; +import Collection from "./Collection"; import View from "./View"; import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; +import Relation from "./decorators/Relation"; type SaveOptions = { publish?: boolean; @@ -22,6 +24,8 @@ type SaveOptions = { }; export default class Document extends ParanoidModel { + static modelName = "Document"; + constructor(fields: Record, store: DocumentsStore) { super(fields, store); @@ -57,6 +61,12 @@ export default class Document extends ParanoidModel { @observable collectionId?: string | null; + /** + * The comment that this comment is a reply to. + */ + @Relation(() => Collection, { onDelete: "cascade" }) + collection?: Collection; + /** * The text content of the document as Markdown. */ @@ -274,6 +284,11 @@ export default class Document extends ParanoidModel { return floor((this.tasks.completed / this.tasks.total) * 100); } + @computed + get pathTo() { + return this.collection?.pathToDocument(this.id) ?? []; + } + get titleWithDefault(): string { return this.title || i18n.t("Untitled"); } @@ -328,7 +343,7 @@ export default class Document extends ParanoidModel { }; @action - star = () => this.store.star(this); + star = (index?: string) => this.store.star(this, index); @action unstar = () => this.store.unstar(this); diff --git a/app/models/Event.ts b/app/models/Event.ts index 49f230afcdee5..22a882d9bf468 100644 --- a/app/models/Event.ts +++ b/app/models/Event.ts @@ -3,6 +3,8 @@ import Model from "./base/Model"; import Relation from "./decorators/Relation"; class Event extends Model { + static modelName = "Event"; + id: string; name: string; diff --git a/app/models/FileOperation.ts b/app/models/FileOperation.ts index 8673a6f5a6f0f..ba0bcdc7169ac 100644 --- a/app/models/FileOperation.ts +++ b/app/models/FileOperation.ts @@ -5,6 +5,8 @@ import User from "./User"; import Model from "./base/Model"; class FileOperation extends Model { + static modelName = "FileOperation"; + id: string; @observable diff --git a/app/models/Group.ts b/app/models/Group.ts index 63772e0c253b4..0e62f408553f4 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -3,6 +3,8 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; class Group extends Model { + static modelName = "Group"; + @Field @observable id: string; diff --git a/app/models/GroupMembership.ts b/app/models/GroupMembership.ts index 3ba10ddacd69d..e4dcf4c75bd0a 100644 --- a/app/models/GroupMembership.ts +++ b/app/models/GroupMembership.ts @@ -1,16 +1,20 @@ +import Group from "./Group"; import User from "./User"; import Model from "./base/Model"; import Relation from "./decorators/Relation"; class GroupMembership extends Model { - id: string; + static modelName = "GroupMembership"; userId: string; - groupId: string; - @Relation(() => User, { onDelete: "cascade" }) user: User; + + groupId: string; + + @Relation(() => Group, { onDelete: "cascade" }) + group: Group; } export default GroupMembership; diff --git a/app/models/Integration.ts b/app/models/Integration.ts index 508647c3142ee..84984ce565e36 100644 --- a/app/models/Integration.ts +++ b/app/models/Integration.ts @@ -8,6 +8,8 @@ import Model from "~/models/base/Model"; import Field from "./decorators/Field"; class Integration extends Model { + static modelName = "Integration"; + id: string; type: IntegrationType; diff --git a/app/models/Membership.ts b/app/models/Membership.ts index 1c6ef4ebbd02a..5b49edd056d72 100644 --- a/app/models/Membership.ts +++ b/app/models/Membership.ts @@ -3,6 +3,8 @@ import { CollectionPermission } from "@shared/types"; import Model from "./base/Model"; class Membership extends Model { + static modelName = "Membership"; + id: string; userId: string; diff --git a/app/models/Notification.ts b/app/models/Notification.ts index e4a99b6743af9..4e27e04c54899 100644 --- a/app/models/Notification.ts +++ b/app/models/Notification.ts @@ -15,6 +15,8 @@ import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; class Notification extends Model { + static modelName = "Notification"; + @Field @observable id: string; diff --git a/app/models/Pin.ts b/app/models/Pin.ts index 5a4082ace390d..1c27fdaa12386 100644 --- a/app/models/Pin.ts +++ b/app/models/Pin.ts @@ -6,6 +6,8 @@ import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; class Pin extends Model { + static modelName = "Pin"; + /** The collection ID that the document is pinned to. If empty the document is pinned to home. */ collectionId: string; diff --git a/app/models/Policy.ts b/app/models/Policy.ts index ebfea332c7c2d..553fd95c2cbb6 100644 --- a/app/models/Policy.ts +++ b/app/models/Policy.ts @@ -2,6 +2,8 @@ import { observable } from "mobx"; import Model from "./base/Model"; class Policy extends Model { + static modelName = "Policy"; + id: string; @observable diff --git a/app/models/Revision.ts b/app/models/Revision.ts index da157883cf969..a2b1954bcce69 100644 --- a/app/models/Revision.ts +++ b/app/models/Revision.ts @@ -6,6 +6,8 @@ import Model from "./base/Model"; import Relation from "./decorators/Relation"; class Revision extends Model { + static modelName = "Revision"; + /** The document ID that the revision is related to */ documentId: string; diff --git a/app/models/SearchQuery.ts b/app/models/SearchQuery.ts index c164c698fc60b..558681926cda6 100644 --- a/app/models/SearchQuery.ts +++ b/app/models/SearchQuery.ts @@ -2,10 +2,18 @@ import { client } from "~/utils/ApiClient"; import Model from "./base/Model"; class SearchQuery extends Model { - id: string; + static modelName = "Search"; + /** + * The query string, automatically truncated to 255 characters. + */ query: string; + /** + * Where the query originated. + */ + source: "api" | "app" | "slack"; + delete = async () => { this.isSaving = true; diff --git a/app/models/Share.ts b/app/models/Share.ts index ea1d7b850a371..4b065d03254b8 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -6,6 +6,8 @@ import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; class Share extends Model { + static modelName = "Share"; + @Field @observable published: boolean; @@ -27,6 +29,10 @@ class Share extends Model { @observable urlId: string; + @Field + @observable + domain: string; + @observable documentTitle: string; diff --git a/app/models/Star.ts b/app/models/Star.ts index 4d9f83df508be..347f6385b01fa 100644 --- a/app/models/Star.ts +++ b/app/models/Star.ts @@ -7,6 +7,8 @@ import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; class Star extends Model { + static modelName = "Star"; + /** The sort order of the star */ @Field @observable diff --git a/app/models/Subscription.ts b/app/models/Subscription.ts index 0f9627c616815..e9d3bd70c7143 100644 --- a/app/models/Subscription.ts +++ b/app/models/Subscription.ts @@ -9,6 +9,8 @@ import Relation from "./decorators/Relation"; * A subscription represents a request for a user to receive notifications for a document. */ class Subscription extends Model { + static modelName = "Subscription"; + /** The user ID subscribing */ userId: string; diff --git a/app/models/Team.ts b/app/models/Team.ts index 76e658d01afba..436fcbe2f3f9a 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -6,6 +6,8 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; class Team extends Model { + static modelName = "Team"; + @Field @observable id: string; diff --git a/app/models/User.ts b/app/models/User.ts index e1814ac6c9a04..2344d9ea6c64a 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -16,6 +16,8 @@ import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; class User extends ParanoidModel { + static modelName = "User"; + @Field @observable id: string; diff --git a/app/models/View.ts b/app/models/View.ts index 42efb0abcfc05..4cc4e934e62fd 100644 --- a/app/models/View.ts +++ b/app/models/View.ts @@ -1,13 +1,19 @@ import { action, observable } from "mobx"; +import Document from "./Document"; import User from "./User"; import Model from "./base/Model"; import Relation from "./decorators/Relation"; class View extends Model { + static modelName = "View"; + id: string; documentId: string; + @Relation(() => Document) + document?: Document; + firstViewedAt: string; @observable diff --git a/app/models/WebhookSubscription.ts b/app/models/WebhookSubscription.ts index 72197eee27402..400858fa8333d 100644 --- a/app/models/WebhookSubscription.ts +++ b/app/models/WebhookSubscription.ts @@ -3,6 +3,8 @@ import Model from "./base/Model"; import Field from "./decorators/Field"; class WebhookSubscription extends Model { + static modelName = "WebhookSubscription"; + @Field @observable id: string; diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index 147fac3c28546..fcefdb6d3d303 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -3,8 +3,11 @@ import { set, observable, action } from "mobx"; import type Store from "~/stores/base/Store"; import Logger from "~/utils/Logger"; import { getFieldsForModel } from "../decorators/Field"; +import { getRelationsForModelClass } from "../decorators/Relation"; export default abstract class Model { + static modelName: string; + @observable id: string; @@ -14,6 +17,7 @@ export default abstract class Model { @observable isNew: boolean; + @observable createdAt: string; @observable @@ -27,6 +31,36 @@ export default abstract class Model { this.isNew = !this.id; } + /** + * Ensures all the defined relations for the model are in memory + * + * @returns A promise that resolves when loading is complete. + */ + async loadRelations() { + const relations = getRelationsForModelClass( + this.constructor as typeof Model + ); + if (!relations) { + return; + } + + for (const properties of relations.values()) { + const store = this.store.rootStore.getStoreForModelName( + properties.relationClassResolver().modelName + ); + if ("fetch" in store) { + await store.fetch(this[properties.idKey]); + } + } + } + + /** + * Persists the model to the server API + * + * @param params Specific fields to save, if not provided the model will be serialized + * @param options Options to pass to the store + * @returns A promise that resolves with the updated model + */ save = async ( params?: Record, options?: Record @@ -91,7 +125,7 @@ export default abstract class Model { * Returns a plain object representation of fields on the model for * persistence to the server API * - * @returns {Record} + * @returns A plain object representation of the model */ toAPI = (): Record => { const fields = getFieldsForModel(this); @@ -102,7 +136,7 @@ export default abstract class Model { * Returns a plain object representation of all the properties on the model * overrides the native toJSON method to avoid attempting to serialize store * - * @returns {Record} + * @returns A plain object representation of the model */ toJSON() { const output: Partial = {}; diff --git a/app/models/decorators/Relation.ts b/app/models/decorators/Relation.ts index ec8dec8b76ba4..84c64da9d0d0a 100644 --- a/app/models/decorators/Relation.ts +++ b/app/models/decorators/Relation.ts @@ -35,7 +35,9 @@ export const getInverseRelationsForModelClass = (targetClass: typeof Model) => { relations.forEach((relation, modelName) => { relation.forEach((properties, propertyName) => { - if (properties.relationClassResolver().name === targetClass.name) { + if ( + properties.relationClassResolver().modelName === targetClass.modelName + ) { inverseRelations.set(propertyName, { ...properties, modelName, @@ -47,6 +49,9 @@ export const getInverseRelationsForModelClass = (targetClass: typeof Model) => { return inverseRelations; }; +export const getRelationsForModelClass = (targetClass: typeof Model) => + relations.get(targetClass.modelName); + /** * A decorator that records this key as a relation field on the model. * Properties decorated with @Relation will merge and read their data from @@ -66,13 +71,13 @@ export default function Relation( // this to determine how to update relations when a model is deleted. if (options) { const configForClass = - relations.get(target.constructor.name) || new Map(); + relations.get(target.constructor.modelName) || new Map(); configForClass.set(propertyKey, { options, relationClassResolver: classResolver, idKey, }); - relations.set(target.constructor.name, configForClass); + relations.set(target.constructor.modelName, configForClass); } Object.defineProperty(target, propertyKey, { @@ -83,9 +88,9 @@ export default function Relation( return undefined; } - const relationClassName = classResolver().name; + const relationClassName = classResolver().modelName; const store = - this.store.rootStore[`${relationClassName.toLowerCase()}s`]; + this.store.rootStore.getStoreForModelName(relationClassName); invariant(store, `Store for ${relationClassName} not found`); return store.get(id); @@ -94,9 +99,9 @@ export default function Relation( this[idKey] = newValue ? newValue.id : undefined; if (newValue) { - const relationClassName = classResolver().name; + const relationClassName = classResolver().modelName; const store = - this.store.rootStore[`${relationClassName.toLowerCase()}s`]; + this.store.rootStore.getStoreForModelName(relationClassName); invariant(store, `Store for ${relationClassName} not found`); store.add(newValue); diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 89af249934caa..3f90c125b8792 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect"; import DelayedMount from "~/components/DelayedMount"; import FullscreenLoading from "~/components/FullscreenLoading"; import Route from "~/components/ProfiledRoute"; +import env from "~/env"; import useQueryNotices from "~/hooks/useQueryNotices"; import lazyWithRetry from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; @@ -25,30 +26,43 @@ export default function Routes() { } > - - - - - + {env.ROOT_SHARE_ID ? ( + + + + + + + ) : ( + + + + + - - + + - - + + - - - - + + + + + )} ); } diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index d33e8ac300867..cec205498516d 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -170,12 +170,12 @@ function CollectionScene() { )} - + diff --git a/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx b/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx index b3dd13706ae6b..8a89957de1fb5 100644 --- a/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx +++ b/app/scenes/CollectionPermissions/AddGroupsToCollection.tsx @@ -17,6 +17,7 @@ import Modal from "~/components/Modal"; import PaginatedList from "~/components/PaginatedList"; import Text from "~/components/Text"; import useBoolean from "~/hooks/useBoolean"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; type Props = { @@ -29,11 +30,11 @@ function AddGroupsToCollection(props: Props) { const [newGroupModalOpen, handleNewGroupModalOpen, handleNewGroupModalClose] = useBoolean(false); const [query, setQuery] = React.useState(""); - - const { auth, collectionGroupMemberships, groups, policies } = useStores(); - const { fetchPage: fetchGroups } = groups; - + const team = useCurrentTeam(); + const { collectionGroupMemberships, groups, policies } = useStores(); const { t } = useTranslation(); + const { fetchPage: fetchGroups } = groups; + const can = policies.abilities(team.id); const debouncedFetch = React.useMemo( () => debounce((query) => fetchGroups({ query }), 250), @@ -65,13 +66,6 @@ function AddGroupsToCollection(props: Props) { } }; - const { user, team } = auth; - if (!user || !team) { - return null; - } - - const can = policies.abilities(team.id); - return ( {can.createGroup ? ( diff --git a/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx b/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx index 25c82d60b3cc9..889e78a5248be 100644 --- a/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx +++ b/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx @@ -27,7 +27,7 @@ export default function InputMemberPermissionSelect( value: CollectionPermission.Admin, }, ]} - ariaLabel={t("Permissions")} + ariaLabel={t("Permission")} labelHidden nude {...props} diff --git a/app/scenes/CollectionPermissions/index.tsx b/app/scenes/CollectionPermissions/index.tsx index e305f4e4922c1..f4bf41358cd51 100644 --- a/app/scenes/CollectionPermissions/index.tsx +++ b/app/scenes/CollectionPermissions/index.tsx @@ -267,8 +267,9 @@ function CollectionPermissions({ collectionId }: Props) { handleRemoveGroup(group)} onUpdate={(permission) => handleUpdateGroup(group, permission)} @@ -285,7 +286,7 @@ function CollectionPermissions({ collectionId }: Props) { handleRemoveUser(item)} onUpdate={(permission) => handleUpdateUser(item, permission)} diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index dd4d2aa3e2770..d8dfdec386005 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -18,6 +18,7 @@ import { TeamContext } from "~/components/TeamContext"; import Text from "~/components/Text"; import env from "~/env"; import useBuildTheme from "~/hooks/useBuildTheme"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { AuthorizationError, OfflineError } from "~/utils/errors"; @@ -83,8 +84,9 @@ function useDocumentId(documentSlug: string, response?: Response) { } function SharedDocumentScene(props: Props) { - const { ui, auth } = useStores(); + const { ui } = useStores(); const location = useLocation(); + const user = useCurrentUser({ rejectOnEmpty: false }); const searchParams = React.useMemo( () => new URLSearchParams(location.search), [location.search] @@ -93,7 +95,7 @@ function SharedDocumentScene(props: Props) { const [response, setResponse] = React.useState(); const [error, setError] = React.useState(); const { documents } = useStores(); - const { shareId, documentSlug } = props.match.params; + const { shareId = env.ROOT_SHARE_ID, documentSlug } = props.match.params; const documentId = useDocumentId(documentSlug, response); const themeOverride = ["dark", "light"].includes( searchParams.get("theme") || "" @@ -104,10 +106,10 @@ function SharedDocumentScene(props: Props) { const theme = useBuildTheme(response?.team?.customTheme, themeOverride); React.useEffect(() => { - if (!auth.user) { + if (!user) { void changeLanguage(detectLanguage(), i18n); } - }, [auth, i18n]); + }, [user, i18n]); // ensure the wider page color always matches the theme React.useEffect(() => { @@ -183,7 +185,7 @@ function SharedDocumentScene(props: Props) { title={response.document.title} sidebar={ response.sharedTree?.children.length ? ( - + ) : undefined } > diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx index de9e8e70c959f..4b74b7821aa14 100644 --- a/app/scenes/Document/components/CommentEditor.tsx +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -2,8 +2,14 @@ import * as React from "react"; import { basicExtensions, withComments } from "@shared/editor/nodes"; import Editor, { Props as EditorProps } from "~/components/Editor"; import type { Editor as SharedEditor } from "~/editor"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import MentionMenuExtension from "~/editor/extensions/MentionMenu"; -const extensions = withComments(basicExtensions); +const extensions = [ + ...withComments(basicExtensions), + EmojiMenuExtension, + MentionMenuExtension, +]; const CommentEditor = ( props: EditorProps, diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index bde685bb69bc1..6eae5663d7bda 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -1,16 +1,22 @@ import { m } from "framer-motion"; import { action } from "mobx"; import { observer } from "mobx-react"; +import { ImageIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { VisuallyHidden } from "reakit"; import { toast } from "sonner"; +import { useTheme } from "styled-components"; import { v4 as uuidv4 } from "uuid"; -import { CommentValidation } from "@shared/validations"; +import { getEventFiles } from "@shared/utils/files"; +import { AttachmentValidation, CommentValidation } from "@shared/validations"; import Comment from "~/models/Comment"; import Avatar from "~/components/Avatar"; import ButtonSmall from "~/components/ButtonSmall"; import { useDocumentContext } from "~/components/DocumentContext"; import Flex from "~/components/Flex"; +import NudeButton from "~/components/NudeButton"; +import Tooltip from "~/components/Tooltip"; import type { Editor as SharedEditor } from "~/editor"; import useCurrentUser from "~/hooks/useCurrentUser"; import useOnClickOutside from "~/hooks/useOnClickOutside"; @@ -64,6 +70,8 @@ function CommentForm({ const editorRef = React.useRef(null); const [forceRender, setForceRender] = React.useState(0); const [inputFocused, setInputFocused] = React.useState(autoFocus); + const file = React.useRef(null); + const theme = useTheme(); const { t } = useTranslation(); const { comments } = useStores(); const user = useCurrentUser(); @@ -188,6 +196,23 @@ function CommentForm({ onBlur?.(); }; + const handleFilePicked = (event: React.ChangeEvent) => { + event.stopPropagation(); + event.preventDefault(); + + const files = getEventFiles(event); + if (!files.length) { + return; + } + editorRef.current?.insertFiles(event, files); + }; + + const handleImageUpload = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + file.current?.click(); + }; + // Focus the editor when it's a new comment just mounted, after a delay as the // editor is mounted within a fade transition. React.useEffect(() => { @@ -227,6 +252,15 @@ function CommentForm({ {...presence} {...rest} > + + + {(inputFocused || data) && ( - - - {thread && !thread.isNew ? t("Reply") : t("Post")} - - - {t("Cancel")} - + + + + {thread && !thread.isNew ? t("Reply") : t("Post")} + + + {t("Cancel")} + + + + + + + )} diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 891c4b49f952a..4b12f600a719c 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -5,23 +5,34 @@ import { NavigationNode, TeamPreference } from "@shared/types"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; +import Error402 from "~/scenes/Error402"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import Logger from "~/utils/Logger"; -import { NotFoundError, OfflineError } from "~/utils/errors"; +import { + NotFoundError, + OfflineError, + PaymentRequiredError, +} from "~/utils/errors"; import history from "~/utils/history"; import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers"; import Loading from "./Loading"; type Params = { + /** The document urlId + slugified title */ documentSlug: string; + /** A specific revision id to load. */ revisionId?: string; + /** The share ID to use to load data. */ shareId?: string; }; type LocationState = { + /** The document title, if preloaded */ title?: string; restore?: boolean; revisionId?: string; @@ -41,17 +52,10 @@ type Props = RouteComponentProps & { }; function DataLoader({ match, children }: Props) { - const { - ui, - views, - shares, - comments, - documents, - auth, - revisions, - subscriptions, - } = useStores(); - const { team } = auth; + const { ui, views, shares, comments, documents, revisions, subscriptions } = + useStores(); + const team = useCurrentTeam(); + const user = useCurrentUser(); const [error, setError] = React.useState(null); const { revisionId, shareId, documentSlug } = match.params; @@ -73,7 +77,7 @@ function DataLoader({ match, children }: Props) { : undefined; const isEditRoute = match.path === matchDocumentEdit || match.path.startsWith(settingsPath()); - const isEditing = isEditRoute || !auth.user?.separateEditMode; + const isEditing = isEditRoute || !user?.separateEditMode; const can = usePolicy(document?.id); const location = useLocation(); @@ -180,7 +184,7 @@ function DataLoader({ match, children }: Props) { // Prevents unauthorized request to load share information for the document // when viewing a public share link if (can.read) { - if (team?.getPreference(TeamPreference.Commenting)) { + if (team.getPreference(TeamPreference.Commenting)) { void comments.fetchDocumentComments(document.id, { limit: 100, }); @@ -196,10 +200,16 @@ function DataLoader({ match, children }: Props) { }, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]); if (error) { - return error instanceof OfflineError ? : ; + return error instanceof OfflineError ? ( + + ) : error instanceof PaymentRequiredError ? ( + + ) : ( + + ); } - if (!document || !team || (revisionId && !revision)) { + if (!document || (revisionId && !revision)) { return ( <> diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 1fb3c9ba2d01c..9eb541af137ed 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -165,8 +165,13 @@ class DocumentScene extends React.Component { this.title = title; this.props.document.title = title; } + if (template.emoji) { + this.props.document.emoji = template.emoji; + } + if (template.text) { + this.props.document.text = template.text; + } - this.props.document.text = template.text; this.updateIsDirty(); return this.onSave({ diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index 941f2adb6ae62..bda1965d29f9f 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -10,6 +10,8 @@ import Document from "~/models/Document"; import Revision from "~/models/Revision"; import DocumentMeta from "~/components/DocumentMeta"; import Fade from "~/components/Fade"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { documentPath, documentInsightsPath } from "~/utils/routeHelpers"; @@ -17,26 +19,20 @@ type Props = { /* The document to display meta data for */ document: Document; revision?: Revision; - isDraft: boolean; to?: LocationDescriptor; rtl?: boolean; }; -function TitleDocumentMeta({ - to, - isDraft, - document, - revision, - ...rest -}: Props) { - const { auth, views, comments, ui } = useStores(); +function TitleDocumentMeta({ to, document, revision, ...rest }: Props) { + const { views, comments, ui } = useStores(); const { t } = useTranslation(); - const { team } = auth; const match = useRouteMatch(); + const team = useCurrentTeam(); const documentViews = useObserver(() => views.inDocument(document.id)); const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].userId; const viewsLoadedOnMount = React.useRef(totalViewers > 0); + const can = usePolicy(document.id); const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade; @@ -45,7 +41,7 @@ function TitleDocumentMeta({ return ( - {team?.getPreference(TeamPreference.Commenting) && ( + {team.getPreference(TeamPreference.Commenting) && can.comment && ( <>  •  )} - {totalViewers && !isDraft ? ( + {totalViewers && !document.isDraft && !document.isTemplate ? (  •  {can.update && !readOnly ? ( - + ) : emoji ? ( - + {emojiIcon} ) : null} @@ -279,12 +292,22 @@ const StyledEmojiPicker = styled(EmojiPicker)` ${extraArea(8)} `; -const EmojiWrapper = styled(Flex)<{ dir?: string }>` - position: absolute; - top: 8px; - ${(props) => (props.dir === "rtl" ? "right: -40px" : "left: -40px")}; +const EmojiWrapper = styled(Flex)<{ $position: "top" | "side"; dir?: string }>` height: 32px; width: 32px; + + ${(props) => + props.$position === "top" + ? css` + position: relative; + top: -8px; + ` + : css` + position: absolute; + top: 8px; + ${(props: { dir?: string }) => + props.dir === "rtl" ? "right: -40px" : "left: -40px"}; + `} `; type TitleProps = { diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 64503026b3bba..4a24fcfc99255 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -8,8 +8,16 @@ import { TeamPreference } from "@shared/types"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; import { RefHandle } from "~/components/ContentEditable"; +import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; +import BlockMenuExtension from "~/editor/extensions/BlockMenu"; +import EmojiMenuExtension from "~/editor/extensions/EmojiMenu"; +import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace"; +import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews"; +import MentionMenuExtension from "~/editor/extensions/MentionMenu"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useFocusedComment from "~/hooks/useFocusedComment"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; @@ -18,14 +26,20 @@ import { documentPath, matchDocumentHistory, } from "~/utils/routeHelpers"; -import { useDocumentContext } from "../../../components/DocumentContext"; import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; import DocumentTitle from "./DocumentTitle"; -const extensions = withComments(richExtensions); +const extensions = [ + ...withComments(richExtensions), + BlockMenuExtension, + EmojiMenuExtension, + MentionMenuExtension, + FindAndReplaceExtension, + HoverPreviewsExtension, +]; -type Props = Omit & { +type Props = Omit & { onChangeTitle: (title: string) => void; onChangeEmoji: (emoji: string | null) => void; id: string; @@ -49,8 +63,9 @@ function DocumentEditor(props: Props, ref: React.RefObject) { const { t } = useTranslation(); const match = useRouteMatch(); const focusedComment = useFocusedComment(); - const { ui, comments, auth } = useStores(); - const { user, team } = auth; + const { ui, comments } = useStores(); + const user = useCurrentUser({ rejectOnEmpty: false }); + const team = useCurrentTeam({ rejectOnEmpty: false }); const history = useHistory(); const { document, @@ -161,6 +176,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { : document.title } emoji={document.emoji} + emojiPosition={document.fullWidth ? "top" : "side"} onChangeTitle={onChangeTitle} onChangeEmoji={onChangeEmoji} onGoToNextInput={handleGoToNextInput} @@ -169,7 +185,6 @@ function DocumentEditor(props: Props, ref: React.RefObject) { /> {!shareId && ( { - void auth.fetch().catch(() => { + void auth.fetchAuth().catch(() => { history.replace(homePath()); }); }); diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index 549e3c1ca3f94..424a30b617915 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -31,6 +31,7 @@ function RevisionViewer(props: Props) { documentId={revision.documentId} title={revision.title} emoji={revision.emoji} + emojiPosition={document.fullWidth ? "top" : "side"} readOnly /> - {t("Share")} + {t("Share")} {domain && <>· {domain}} )} diff --git a/app/scenes/Document/components/SharePopover.tsx b/app/scenes/Document/components/SharePopover.tsx index ef95a9f4ecd60..b8b6f89036806 100644 --- a/app/scenes/Document/components/SharePopover.tsx +++ b/app/scenes/Document/components/SharePopover.tsx @@ -27,10 +27,17 @@ import useStores from "~/hooks/useStores"; import useUserLocale from "~/hooks/useUserLocale"; type Props = { + /** The document to share. */ document: Document; + /** The existing share model, if any. */ share: Share | null | undefined; + /** The existing share parent model, if any. */ sharedParent: Share | null | undefined; + /** Whether to hide the title. */ + hideTitle?: boolean; + /** Callback fired when the popover requests to be closed. */ onRequestClose: () => void; + /** Whether the popover is visible. */ visible: boolean; }; @@ -38,6 +45,7 @@ function SharePopover({ document, share, sharedParent, + hideTitle, onRequestClose, visible, }: Props) { @@ -213,10 +221,16 @@ function SharePopover({ return ( <> - - {isPubliclyShared ? : } - {t("Share this document")} - + {!hideTitle && ( + + {isPubliclyShared ? ( + + ) : ( + + )} + {t("Share this document")} + + )} {sharedParent && !document.isDraft && ( diff --git a/app/scenes/Document/components/SidebarLayout.tsx b/app/scenes/Document/components/SidebarLayout.tsx index 6b0f94675e19b..0e909b9026fc6 100644 --- a/app/scenes/Document/components/SidebarLayout.tsx +++ b/app/scenes/Document/components/SidebarLayout.tsx @@ -2,11 +2,11 @@ import { observer } from "mobx-react"; import { BackIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Portal } from "react-portal"; import styled from "styled-components"; import { depths, s, ellipsis } from "@shared/styles"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; +import { Portal } from "~/components/Portal"; import Scrollable from "~/components/Scrollable"; import Tooltip from "~/components/Tooltip"; import useMobile from "~/hooks/useMobile"; @@ -66,7 +66,7 @@ const Backdrop = styled.a` bottom: 0; right: 0; cursor: default; - z-index: ${depths.sidebar - 1}; + z-index: ${depths.mobileSidebar - 1}; background: ${s("backdrop")}; `; diff --git a/app/scenes/DocumentDelete.tsx b/app/scenes/DocumentDelete.tsx index 28d74fbf6be49..81818b96e69d7 100644 --- a/app/scenes/DocumentDelete.tsx +++ b/app/scenes/DocumentDelete.tsx @@ -82,65 +82,65 @@ function DocumentDelete({ document, onSubmit }: Props) { ); return ( - -
+ + + {document.isTemplate ? ( + , + }} + /> + ) : nestedDocumentsCount < 1 ? ( + , + }} + /> + ) : ( + , + }} + /> + )} + + {canArchive && ( - {document.isTemplate ? ( - , - }} - /> - ) : nestedDocumentsCount < 1 ? ( - , - }} - /> - ) : ( - , - }} - /> - )} + + If you’d like the option of referencing or restoring the{" "} + {{ + noun: document.noun, + }}{" "} + in the future, consider archiving it instead. + - {canArchive && ( - - - If you’d like the option of referencing or restoring the{" "} - {{ - noun: document.noun, - }}{" "} - in the future, consider archiving it instead. - - - )} - -    + )} + + {canArchive && ( )} - -
+ +
+ ); } diff --git a/app/scenes/DocumentNew.tsx b/app/scenes/DocumentNew.tsx index aadd2a1fe8121..7fe618cff20da 100644 --- a/app/scenes/DocumentNew.tsx +++ b/app/scenes/DocumentNew.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; import { toast } from "sonner"; +import { UserPreference } from "@shared/types"; import CenteredContent from "~/components/CenteredContent"; import Flex from "~/components/Flex"; import PlaceholderDocument from "~/components/PlaceholderDocument"; @@ -42,7 +43,9 @@ function DocumentNew({ template }: Props) { const document = await documents.create({ collectionId: collection?.id, parentDocumentId, - fullWidth: parentDocument?.fullWidth, + fullWidth: + parentDocument?.fullWidth || + user.getPreference(UserPreference.FullWidthDocuments), templateId: query.get("templateId") ?? undefined, template, title: "", diff --git a/app/scenes/DocumentPermanentDelete.tsx b/app/scenes/DocumentPermanentDelete.tsx index 81cefa0585699..6ae4ed20d1e85 100644 --- a/app/scenes/DocumentPermanentDelete.tsx +++ b/app/scenes/DocumentPermanentDelete.tsx @@ -4,9 +4,8 @@ import { useTranslation, Trans } from "react-i18next"; import { useHistory } from "react-router-dom"; import { toast } from "sonner"; import Document from "~/models/Document"; -import Button from "~/components/Button"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import Flex from "~/components/Flex"; -import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; type Props = { @@ -15,50 +14,37 @@ type Props = { }; function DocumentPermanentDelete({ document, onSubmit }: Props) { - const [isDeleting, setIsDeleting] = React.useState(false); const { t } = useTranslation(); const { documents } = useStores(); const history = useHistory(); - const handleSubmit = React.useCallback( - async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - - try { - setIsDeleting(true); - await documents.delete(document, { - permanent: true, - }); - toast.success(t("Document permanently deleted")); - onSubmit(); - history.push("/trash"); - } catch (err) { - toast.error(err.message); - } finally { - setIsDeleting(false); - } - }, - [document, onSubmit, t, history, documents] - ); + const handleSubmit = async () => { + await documents.delete(document, { + permanent: true, + }); + toast.success(t("Document permanently deleted")); + onSubmit(); + history.push("/trash"); + }; return ( -
- - , - }} - /> - - -
+ + , + }} + /> +
); } diff --git a/app/scenes/Error402.tsx b/app/scenes/Error402.tsx new file mode 100644 index 0000000000000..4709400a93eb4 --- /dev/null +++ b/app/scenes/Error402.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import Empty from "~/components/Empty"; +import Notice from "~/components/Notice"; +import Scene from "~/components/Scene"; + +const Error402 = () => { + const location = useLocation<{ title?: string }>(); + const { t } = useTranslation(); + const title = location.state?.title ?? t("Payment Required"); + + return ( + +

{title}

+ + + This document cannot be viewed with the current edition. Please + upgrade to a paid license to restore access. + + +
+ ); +}; + +export default Error402; diff --git a/app/scenes/Error404.tsx b/app/scenes/Error404.tsx index a78c0c37cf91c..a6a9adc7083b8 100644 --- a/app/scenes/Error404.tsx +++ b/app/scenes/Error404.tsx @@ -3,6 +3,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import Empty from "~/components/Empty"; import Scene from "~/components/Scene"; +import { homePath } from "~/utils/routeHelpers"; const Error404 = () => { const { t } = useTranslation(); @@ -12,7 +13,7 @@ const Error404 = () => { We were unable to find the page you’re looking for. Go to the{" "} - homepage? + homepage? diff --git a/app/scenes/GroupDelete.tsx b/app/scenes/GroupDelete.tsx index a3f464bc6851a..2051d97f37920 100644 --- a/app/scenes/GroupDelete.tsx +++ b/app/scenes/GroupDelete.tsx @@ -2,11 +2,8 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { useHistory } from "react-router-dom"; -import { toast } from "sonner"; import Group from "~/models/Group"; -import Button from "~/components/Button"; -import Flex from "~/components/Flex"; -import Text from "~/components/Text"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import { settingsPath } from "~/utils/routeHelpers"; type Props = { @@ -17,42 +14,30 @@ type Props = { function GroupDelete({ group, onSubmit }: Props) { const { t } = useTranslation(); const history = useHistory(); - const [isDeleting, setIsDeleting] = React.useState(false); - const handleSubmit = async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - setIsDeleting(true); - - try { - await group.delete(); - history.push(settingsPath("groups")); - onSubmit(); - } catch (err) { - toast.error(err.message); - } finally { - setIsDeleting(false); - } + const handleSubmit = async () => { + await group.delete(); + history.push(settingsPath("groups")); + onSubmit(); }; return ( - -
- - , - }} - /> - - -
-
+ + , + }} + /> + ); } diff --git a/app/scenes/GroupMembers/AddPeopleToGroup.tsx b/app/scenes/GroupMembers/AddPeopleToGroup.tsx index 15d5583918695..f8472743b1f33 100644 --- a/app/scenes/GroupMembers/AddPeopleToGroup.tsx +++ b/app/scenes/GroupMembers/AddPeopleToGroup.tsx @@ -16,6 +16,7 @@ import Modal from "~/components/Modal"; import PaginatedList from "~/components/PaginatedList"; import Text from "~/components/Text"; import useBoolean from "~/hooks/useBoolean"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import GroupMemberListItem from "./components/GroupMemberListItem"; @@ -27,7 +28,8 @@ type Props = { function AddPeopleToGroup(props: Props) { const { group } = props; - const { users, auth, groupMemberships } = useStores(); + const { users, groupMemberships } = useStores(); + const team = useCurrentTeam(); const { t } = useTranslation(); const [query, setQuery] = React.useState(""); @@ -69,11 +71,6 @@ function AddPeopleToGroup(props: Props) { } }; - const { user, team } = auth; - if (!user || !team) { - return null; - } - return ( diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index 5a09d0b7d0c93..4110548483e90 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -107,6 +107,7 @@ function Home() { const Documents = styled.div` position: relative; background: ${s("background")}; + transition: ${s("backgroundTransition")}; `; export default observer(Home); diff --git a/app/scenes/Invite.tsx b/app/scenes/Invite.tsx index d078dae53f231..0ee12f21957fb 100644 --- a/app/scenes/Invite.tsx +++ b/app/scenes/Invite.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { LinkIcon, CloseIcon } from "outline-icons"; +import { CloseIcon, CopyIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; @@ -14,6 +14,7 @@ import Flex from "~/components/Flex"; import Input from "~/components/Input"; import InputSelectRole from "~/components/InputSelectRole"; import NudeButton from "~/components/NudeButton"; +import { ResizingHeightContainer } from "~/components/ResizingHeightContainer"; import Text from "~/components/Text"; import Tooltip from "~/components/Tooltip"; import useCurrentTeam from "~/hooks/useCurrentTeam"; @@ -33,25 +34,14 @@ type InviteRequest = { function Invite({ onSubmit }: Props) { const [isSaving, setIsSaving] = React.useState(false); - const [linkCopied, setLinkCopied] = React.useState(false); const [invites, setInvites] = React.useState([ { email: "", name: "", role: UserRole.Member, }, - { - email: "", - name: "", - role: UserRole.Member, - }, - { - email: "", - name: "", - role: UserRole.Member, - }, ]); - const { users } = useStores(); + const { users, collections } = useStores(); const user = useCurrentUser(); const team = useCurrentTeam(); const { t } = useTranslation(); @@ -103,7 +93,7 @@ function Invite({ onSubmit }: Props) { newInvites.push({ email: "", name: "", - role: UserRole.Member, + role: invites[invites.length - 1].role, }); return newInvites; }); @@ -122,7 +112,6 @@ function Invite({ onSubmit }: Props) { ); const handleCopy = React.useCallback(() => { - setLinkCopied(true); toast.success(t("Share link copied")); }, [t]); @@ -137,6 +126,37 @@ function Invite({ onSubmit }: Props) { [] ); + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleAdd(); + } + }, + [handleAdd] + ); + + const collectionCount = collections.nonPrivate.length; + const collectionAccessNote = ( + + Invited members will receive access to{" "} + + {collections.nonPrivate.map((collection) => ( +
  • {collection.name}
  • + ))} + + } + > + + {{ collectionCount }} collections + +
    + . +
    + ); + return (
    {team.guestSignin ? ( @@ -146,7 +166,8 @@ function Invite({ onSubmit }: Props) { values={{ signinMethods: team.signinMethods, }} - /> + />{" "} + {collectionAccessNote} ) : ( @@ -161,12 +182,13 @@ function Invite({ onSubmit }: Props) { As an admin you can also{" "} enable email sign-in. - )} + )}{" "} + {collectionAccessNote} )} {team.subdomain && ( - + -    + /> )} - {invites.map((invite, index) => ( - - handleChange(ev, index)} - placeholder={`example@${predictedDomain}`} - value={invite.email} - required={index === 0} - autoFocus={index === 0} - flex - /> - handleChange(ev, index)} - value={invite.name} - required={!!invite.email} - flex - /> - handleRoleChange(role, index)} - value={invite.role} - labelHidden={index !== 0} - short - /> - {index !== 0 && ( - - - handleRemove(ev, index)}> - - - - - )} - {index === 0 && invites.length > 1 && ( - - - - )} - - ))} + + {invites.map((invite, index) => ( + + handleChange(ev, index)} + placeholder={`example@${predictedDomain}`} + value={invite.email} + required={index === 0} + autoFocus + flex + /> + handleChange(ev, index)} + value={invite.name} + required={!!invite.email} + flex + /> + handleRoleChange(role, index)} + value={invite.role} + labelHidden={index !== 0} + short + /> + {index !== 0 && ( + + + handleRemove(ev, index)}> + + + + + )} + + ))} + {invites.length <= UserValidation.maxInvitesPerRequest ? ( ) : ( @@ -270,12 +288,13 @@ const CopyBlock = styled("div")` `; const Remove = styled("div")` + color: ${s("textTertiary")}; margin-top: 4px; + margin-right: -32px; `; -const Spacer = styled.div` - width: 24px; - height: 24px; +const Def = styled("span")` + text-decoration: underline dotted; `; export default observer(Invite); diff --git a/app/scenes/Login/components/Notices.tsx b/app/scenes/Login/components/Notices.tsx index 5b91caf0e962e..3dd564f853a3f 100644 --- a/app/scenes/Login/components/Notices.tsx +++ b/app/scenes/Login/components/Notices.tsx @@ -84,12 +84,18 @@ export default function Notices() { requesting another. )} - {(notice === "suspended" || notice === "user-suspended") && ( + {notice === "user-suspended" && ( Your account has been suspended. To re-activate your account, please contact a workspace admin. )} + {notice === "team-suspended" && ( + + This workspace has been suspended. Please contact support to restore + access. + + )} {notice === "authentication-provider-disabled" && ( Authentication failed – this login method was disabled by a team diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index de42458902fb4..57f82919cefaa 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -22,6 +22,7 @@ import PageTitle from "~/components/PageTitle"; import TeamLogo from "~/components/TeamLogo"; import Text from "~/components/Text"; import env from "~/env"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useLastVisitedPath from "~/hooks/useLastVisitedPath"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; @@ -43,12 +44,13 @@ function Login({ children }: Props) { const notice = query.get("notice"); const { t } = useTranslation(); + const user = useCurrentUser({ rejectOnEmpty: false }); const { auth } = useStores(); const { config } = auth; const [error, setError] = React.useState(null); const [emailLinkSentTo, setEmailLinkSentTo] = React.useState(""); const isCreate = location.pathname === "/create"; - const rememberLastPath = !!auth.user?.getPreference( + const rememberLastPath = !!user?.getPreference( UserPreference.RememberLastPath ); const [lastVisitedPath] = useLastVisitedPath(); diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index addaae67096fe..0b8b7a7156b47 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -1,18 +1,15 @@ -import isEqual from "lodash/isEqual"; -import { observable, action } from "mobx"; import { observer } from "mobx-react"; import queryString from "query-string"; import * as React from "react"; -import { WithTranslation, withTranslation, Trans } from "react-i18next"; -import { RouteComponentProps, StaticContext, withRouter } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; import { Waypoint } from "react-waypoint"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { v4 as uuidv4 } from "uuid"; +import { Pagination } from "@shared/constants"; +import { hideScrollbars } from "@shared/styles"; import { DateFilter as TDateFilter } from "@shared/types"; -import { SearchParams } from "~/stores/DocumentsStore"; -import RootStore from "~/stores/RootStore"; -import { DEFAULT_PAGINATION_LIMIT } from "~/stores/base/Store"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import DocumentListItem from "~/components/DocumentListItem"; import Empty from "~/components/Empty"; @@ -23,9 +20,12 @@ import RegisterKeyDown from "~/components/RegisterKeyDown"; import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; -import withStores from "~/components/withStores"; +import env from "~/env"; +import usePaginatedRequest from "~/hooks/usePaginatedRequest"; +import useQuery from "~/hooks/useQuery"; +import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; -import Logger from "~/utils/Logger"; +import { SearchResult } from "~/types"; import { searchPath } from "~/utils/routeHelpers"; import { decodeURIComponentSafe } from "~/utils/urls"; import CollectionFilter from "./components/CollectionFilter"; @@ -35,73 +35,102 @@ import SearchInput from "./components/SearchInput"; import StatusFilter from "./components/StatusFilter"; import UserFilter from "./components/UserFilter"; -type Props = RouteComponentProps< - { term: string }, - StaticContext, - { search: string; fromMenu?: boolean } -> & - WithTranslation & - RootStore & { - notFound?: boolean; - }; - -@observer -class Search extends React.Component { - resultListCompositeRef: HTMLDivElement | null | undefined; - recentSearchesCompositeRef: HTMLDivElement | null | undefined; - searchInputRef: HTMLInputElement | null | undefined; - - lastQuery = ""; - - lastParams: SearchParams; - - @observable - query: string = decodeURIComponentSafe(this.props.match.params.term || ""); - - @observable - params: URLSearchParams = new URLSearchParams(this.props.location.search); - - @observable - offset = 0; - - @observable - allowLoadMore = true; - - @observable - isLoading = false; - - componentDidMount() { - this.handleTermChange(); - - if (this.props.location.search) { - this.handleQueryChange(); +type Props = { notFound?: boolean }; + +function Search(props: Props) { + const { t } = useTranslation(); + const { documents, searches } = useStores(); + + // routing + const params = useQuery(); + const location = useLocation(); + const history = useHistory(); + const routeMatch = useRouteMatch<{ term: string }>(); + + // refs + const searchInputRef = React.useRef(null); + const resultListCompositeRef = React.useRef(null); + const recentSearchesCompositeRef = React.useRef(null); + + // filters + const query = decodeURIComponentSafe(routeMatch.params.term ?? ""); + const includeArchived = params.get("includeArchived") === "true"; + const collectionId = params.get("collectionId") ?? undefined; + const userId = params.get("userId") ?? undefined; + const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined; + const titleFilter = params.get("titleFilter") === "true"; + + const filters = React.useMemo( + () => ({ + query, + includeArchived, + collectionId, + userId, + dateFilter, + titleFilter, + }), + [query, includeArchived, collectionId, userId, dateFilter, titleFilter] + ); + + const requestFn = React.useMemo(() => { + // Add to the searches store so this search can immediately appear in the recent searches list + // without a flash of loading. + if (query) { + searches.add({ + id: uuidv4(), + query, + createdAt: new Date().toISOString(), + }); + + return async () => + titleFilter + ? await documents.searchTitles(query, filters) + : await documents.search(query, filters); } - } - componentDidUpdate(prevProps: Props) { - if (prevProps.location.search !== this.props.location.search) { - this.handleQueryChange(); - } + return () => Promise.resolve([] as SearchResult[]); + }, [query, titleFilter, filters, searches, documents]); - if (prevProps.match.params.term !== this.props.match.params.term) { - this.handleTermChange(); - } - } + const { data, next, end, loading } = usePaginatedRequest(requestFn, { + limit: Pagination.defaultLimit, + }); - goBack = () => { - this.props.history.goBack(); + const updateLocation = (query: string) => { + history.replace({ + pathname: searchPath(query), + search: location.search, + }); }; - handleKeyDown = (ev: React.KeyboardEvent) => { + // All filters go through the query string so that searches are bookmarkable, which neccesitates + // some complexity as the query string is the source of truth for the filters. + const handleFilterChange = (search: { + collectionId?: string | undefined; + userId?: string | undefined; + dateFilter?: TDateFilter; + includeArchived?: boolean | undefined; + titleFilter?: boolean | undefined; + }) => { + history.replace({ + pathname: location.pathname, + search: queryString.stringify( + { ...queryString.parse(location.search), ...search }, + { + skipEmptyString: true, + } + ), + }); + }; + + const handleKeyDown = (ev: React.KeyboardEvent) => { if (ev.key === "Enter") { - this.updateLocation(ev.currentTarget.value); - void this.fetchResults(); + updateLocation(ev.currentTarget.value); return; } if (ev.key === "Escape") { ev.preventDefault(); - return this.goBack(); + return history.goBack(); } if (ev.key === "ArrowUp") { @@ -129,312 +158,127 @@ class Search extends React.Component { } } - const firstItem = this.firstResultItem ?? this.firstRecentSearchItem; - firstItem?.focus(); - } - }; - - handleQueryChange = () => { - this.params = new URLSearchParams(this.props.location.search); - this.offset = 0; - this.allowLoadMore = true; - // To prevent "no results" showing before debounce kicks in - this.isLoading = true; - void this.fetchResults(); - }; - - handleTermChange = () => { - const query = decodeURIComponentSafe(this.props.match.params.term || ""); - this.query = query ? query : ""; - this.offset = 0; - this.allowLoadMore = true; - // To prevent "no results" showing before debounce kicks in - this.isLoading = true; - void this.fetchResults(); - }; - - handleFilterChange = (search: { - collectionId?: string | undefined; - userId?: string | undefined; - dateFilter?: TDateFilter; - includeArchived?: boolean | undefined; - titleFilter?: boolean | undefined; - }) => { - this.props.history.replace({ - pathname: this.props.location.pathname, - search: queryString.stringify( - { ...queryString.parse(this.props.location.search), ...search }, - { - skipEmptyString: true, - } - ), - }); - }; - - handleTitleFilterChange = (ev: React.ChangeEvent) => { - this.handleFilterChange({ titleFilter: ev.target.checked }); - }; - - get firstResultItem() { - const linkItems = this.resultListCompositeRef?.querySelectorAll( - "[href]" - ) as NodeListOf; - return linkItems?.[0]; - } - - get firstRecentSearchItem() { - const linkItems = this.recentSearchesCompositeRef?.querySelectorAll( - "li > [href]" - ) as NodeListOf; - return linkItems?.[0]; - } - - get includeArchived() { - return this.params.get("includeArchived") === "true"; - } - - get collectionId() { - const id = this.params.get("collectionId"); - return id ? id : undefined; - } + const firstResultItem = ( + resultListCompositeRef.current?.querySelectorAll( + "[href]" + ) as NodeListOf + )?.[0]; - get userId() { - const id = this.params.get("userId"); - return id ? id : undefined; - } + const firstRecentSearchItem = ( + recentSearchesCompositeRef.current?.querySelectorAll( + "li > [href]" + ) as NodeListOf + )?.[0]; - get dateFilter() { - const id = this.params.get("dateFilter"); - return id ? (id as TDateFilter) : undefined; - } - - get titleFilter() { - return this.params.get("titleFilter") === "true"; - } - - get isFiltered() { - return ( - this.dateFilter || - this.userId || - this.collectionId || - this.includeArchived || - this.titleFilter - ); - } - - get title() { - const query = this.query; - const title = this.props.t("Search"); - if (query) { - return `${query} – ${title}`; - } - return title; - } - - @action - loadMoreResults = async () => { - // Don't paginate if there aren't more results or we’re in the middle of fetching - if (!this.allowLoadMore || this.isLoading) { - return; - } - - // Fetch more results - await this.fetchResults(); - }; - - @action - fetchResults = async () => { - if (this.query.trim()) { - const params = { - offset: this.offset, - limit: DEFAULT_PAGINATION_LIMIT, - dateFilter: this.dateFilter, - includeArchived: this.includeArchived, - includeDrafts: true, - collectionId: this.collectionId, - userId: this.userId, - titleFilter: this.titleFilter, - }; - - // we just requested this thing – no need to try again - if (this.lastQuery === this.query && isEqual(params, this.lastParams)) { - this.isLoading = false; - return; - } - - this.isLoading = true; - this.lastQuery = this.query; - this.lastParams = params; - - try { - const results = this.titleFilter - ? await this.props.documents.searchTitles(this.query, params) - : await this.props.documents.search(this.query, params); - - // Add to the searches store so this search can immediately appear in - // the recent searches list without a flash of load - this.props.searches.add({ - id: uuidv4(), - query: this.query, - createdAt: new Date().toISOString(), - }); - - if (results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT) { - this.allowLoadMore = false; - } else { - this.offset += DEFAULT_PAGINATION_LIMIT; - } - } catch (error) { - Logger.error("Search query failed", error); - this.lastQuery = ""; - } finally { - this.isLoading = false; - } - } else { - this.isLoading = false; - this.lastQuery = this.query; + const firstItem = firstResultItem ?? firstRecentSearchItem; + firstItem?.focus(); } }; - updateLocation = (query: string) => { - this.props.history.replace({ - pathname: searchPath(query), - search: this.props.location.search, - }); - }; - - setResultListCompositeRef = (ref: HTMLDivElement | null) => { - this.resultListCompositeRef = ref; - }; - - setRecentSearchesCompositeRef = (ref: HTMLDivElement | null) => { - this.recentSearchesCompositeRef = ref; - }; - - setSearchInputRef = (ref: HTMLInputElement | null) => { - this.searchInputRef = ref; - }; - - handleEscape = () => { - this.searchInputRef?.focus(); - }; - - render() { - const { documents, notFound, t } = this.props; - const results = documents.searchResults(this.query); - const showEmpty = !this.isLoading && this.query && results?.length === 0; - - return ( - - - {this.isLoading && } - {notFound && ( -
    -

    {t("Not Found")}

    - - {t("We were unable to find the page you’re looking for.")} - -
    - )} - - - - {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() { /> - {can.delete && ( diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index 9c0f1a84f1ea4..8e17aaafd9d08 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -9,23 +9,20 @@ import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useStores from "~/hooks/useStores"; import SettingRow from "./components/SettingRow"; function Features() { - const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...team.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateTeam({ preferences }); + team.setPreference( + ev.target.name as TeamPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await team.save(); toast.success(t("Settings saved")); }; diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 4148391522007..8c727a28087de 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -164,6 +164,7 @@ function Members() { )} } + wide > {t("Members")} diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index ce9875f9502e3..da1f3850fb630 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -19,24 +19,23 @@ import SettingRow from "./components/SettingRow"; function Preferences() { const { t } = useTranslation(); - const { dialogs, auth } = useStores(); + const { dialogs } = useStores(); const user = useCurrentUser(); const team = useCurrentTeam(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...user.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateUser({ preferences }); + user.setPreference( + ev.target.name as UserPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await user.save(); toast.success(t("Preferences saved")); }; const handleLanguageChange = async (language: string) => { - await auth.updateUser({ language }); + await user.save({ language }); toast.success(t("Preferences saved")); }; diff --git a/app/scenes/Settings/Profile.tsx b/app/scenes/Settings/Profile.tsx index a88db05dbc852..f4a3d8db519b2 100644 --- a/app/scenes/Settings/Profile.tsx +++ b/app/scenes/Settings/Profile.tsx @@ -9,12 +9,10 @@ import Input from "~/components/Input"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useCurrentUser from "~/hooks/useCurrentUser"; -import useStores from "~/hooks/useStores"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; const Profile = () => { - const { auth } = useStores(); const user = useCurrentUser(); const form = React.useRef(null); const [name, setName] = React.useState(user.name || ""); @@ -24,9 +22,7 @@ const Profile = () => { ev.preventDefault(); try { - await auth.updateUser({ - name, - }); + await user.save({ name }); toast.success(t("Profile saved")); } catch (err) { toast.error(err.message); @@ -38,9 +34,7 @@ const Profile = () => { }; const handleAvatarUpload = async (avatarUrl: string) => { - await auth.updateUser({ - avatarUrl, - }); + await user.save({ avatarUrl }); toast.success(t("Profile picture updated")); }; @@ -49,7 +43,7 @@ const Profile = () => { }; const isValid = form.current?.checkValidity(); - const { isSaving } = auth; + const { isSaving } = user; return ( }> diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 2cbbb683d0542..8bc811002d682 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -24,7 +24,7 @@ import DomainManagement from "./components/DomainManagement"; import SettingRow from "./components/SettingRow"; function Security() { - const { auth, authenticationProviders, dialogs } = useStores(); + const { authenticationProviders, dialogs } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const theme = useTheme(); @@ -61,13 +61,13 @@ function Security() { async (newData) => { try { setData(newData); - await auth.updateTeam(newData); + await team.save(newData); showSuccessMessage(); } catch (err) { toast.error(err.message); } }, - [auth, showSuccessMessage] + [team, showSuccessMessage] ); const handleChange = React.useCallback( @@ -109,7 +109,6 @@ function Security() { onSubmit={async () => { await saveData(newData); }} - submitText={t("I’m sure")} savingText={`${t("Saving")}…`} danger > @@ -227,7 +226,7 @@ function Security() { value={data.defaultUserRole} options={[ { - label: t("Member"), + label: t("Editor"), value: "member", }, { diff --git a/app/scenes/Settings/Shares.tsx b/app/scenes/Settings/Shares.tsx index 0a69eb63662c9..2ee133a83d915 100644 --- a/app/scenes/Settings/Shares.tsx +++ b/app/scenes/Settings/Shares.tsx @@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import { PAGINATION_SYMBOL } from "~/stores/base/Store"; import Share from "~/models/Share"; +import Fade from "~/components/Fade"; import Heading from "~/components/Heading"; import Notice from "~/components/Notice"; import Scene from "~/components/Scene"; @@ -67,7 +68,7 @@ function Shares() { }, [shares.orderedData, shareIds]); return ( - }> + } wide> {t("Shared Links")} {can.update && !canShareDocuments && ( @@ -93,15 +94,19 @@ function Shares() {
    - + {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) { 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 ?
    + ); } 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

    - +

    diff --git a/server/emails/templates/index.ts b/server/emails/templates/index.ts index bdef23ec6fba6..bea83dc52b797 100644 --- a/server/emails/templates/index.ts +++ b/server/emails/templates/index.ts @@ -1,3 +1,7 @@ +import path from "path"; +import { glob } from "glob"; +import env from "@server/env"; +import Logger from "@server/logging/Logger"; import { requireDirectory } from "@server/utils/fs"; const emails = {}; @@ -13,4 +17,16 @@ requireDirectory(__dirname).forEach(([module, id]) => { emails[id] = Email; }); +const rootDir = env.ENVIRONMENT === "test" ? "" : "build"; +glob + .sync(path.join(rootDir, "plugins/*/server/email/templates/!(*.test).[jt]s")) + .forEach((filePath: string) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const template = require(path.join(process.cwd(), filePath)).default; + + Logger.debug("lifecycle", `Registered email template ${template.name}`); + + emails[template.name] = template; + }); + export default emails; diff --git a/server/env.ts b/server/env.ts index 3d0a2efaac3d8..d014dc8af88a4 100644 --- a/server/env.ts +++ b/server/env.ts @@ -18,6 +18,7 @@ import { IsBoolean, MaxLength, } from "class-validator"; +import uniq from "lodash/uniq"; import { languages } from "@shared/i18n"; import { CannotUseWithout } from "@server/utils/validators"; import Deprecated from "./models/decorators/Deprecated"; @@ -40,7 +41,7 @@ export class Environment { } /** - * The current envionment name. + * The current environment name. */ @IsIn(["development", "production", "staging", "test"]) public ENVIRONMENT = process.env.NODE_ENV ?? "production"; @@ -226,16 +227,20 @@ export class Environment { public DEFAULT_LANGUAGE = process.env.DEFAULT_LANGUAGE ?? "en_US"; /** - * A comma separated list of which services should be enabled on this - * instance – defaults to all. + * A comma list of which services should be enabled on this instance – defaults to all. * * If a services flag is passed it takes priority over the environment variable * for example: --services=web,worker */ - public SERVICES = - getArg("services") ?? - process.env.SERVICES ?? - "collaboration,websockets,worker,web"; + public SERVICES = uniq( + ( + getArg("services") ?? + process.env.SERVICES ?? + "collaboration,websockets,worker,web" + ) + .split(",") + .map((service) => service.toLowerCase().trim()) + ); /** * Auto-redirect to https in production. The default is true but you may set @@ -437,7 +442,7 @@ export class Environment { ); /** - * OICD client credentials. To enable authentication with any + * OIDC client credentials. To enable authentication with any * compatible provider. */ @IsOptional() @@ -686,6 +691,14 @@ export class Environment { @CannotUseWithout("IFRAMELY_URL") public IFRAMELY_API_KEY = this.toOptionalString(process.env.IFRAMELY_API_KEY); + /** + * Enable unsafe-inline in script-src CSP directive + */ + @IsBoolean() + public DEVELOPMENT_UNSAFE_INLINE_CSP = this.toBoolean( + process.env.DEVELOPMENT_UNSAFE_INLINE_CSP ?? "false" + ); + /** * The product name */ @@ -703,6 +716,27 @@ export class Environment { ].includes(this.URL); } + /** + * Returns true if the current installation is running in production. + */ + public get isProduction() { + return this.ENVIRONMENT === "production"; + } + + /** + * Returns true if the current installation is running in the development environment. + */ + public get isDevelopment() { + return this.ENVIRONMENT === "development"; + } + + /** + * Returns true if the current installation is running in a test environment. + */ + public get isTest() { + return this.ENVIRONMENT === "test"; + } + private toOptionalString(value: string | undefined) { return value ? value : undefined; } @@ -724,7 +758,13 @@ export class Environment { * @returns A boolean */ private toBoolean(value: string) { - return value ? !!JSON.parse(value) : false; + try { + return value ? !!JSON.parse(value) : false; + } catch (err) { + throw new Error( + `"${value}" could not be parsed as a boolean, must be "true" or "false"` + ); + } } } diff --git a/server/errors.ts b/server/errors.ts index a772f6a2ef03e..3e5ef33aea771 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -87,6 +87,12 @@ export function InvalidRequestError(message = "Request invalid") { }); } +export function PaymentRequiredError(message = "Payment required") { + return httpErrors(402, message, { + id: "payment_required", + }); +} + export function NotFoundError(message = "Resource not found") { return httpErrors(404, message, { id: "not_found", diff --git a/server/index.ts b/server/index.ts index c4d34acfe4af1..4cbfa2ba55f83 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,7 +10,6 @@ import Koa from "koa"; import helmet from "koa-helmet"; import logger from "koa-logger"; import Router from "koa-router"; -import uniq from "lodash/uniq"; import { AddressInfo } from "net"; import stoppable from "stoppable"; import throng from "throng"; @@ -27,34 +26,28 @@ import { checkConnection, sequelize } from "./storage/database"; import RedisAdapter from "./storage/redis"; import Metrics from "./logging/Metrics"; -// The default is to run all services to make development and OSS installations -// easier to deal with. Separate services are only needed at scale. -const serviceNames = uniq( - env.SERVICES.split(",").map((service) => service.trim()) -); - // The number of processes to run, defaults to the number of CPU's available // for the web service, and 1 for collaboration during the beta period. -let processCount = env.WEB_CONCURRENCY; +let webProcessCount = env.WEB_CONCURRENCY; -if (serviceNames.includes("collaboration")) { - if (processCount !== 1) { +if (env.SERVICES.includes("collaboration")) { + if (webProcessCount !== 1) { Logger.info( "lifecycle", "Note: Restricting process count to 1 due to use of collaborative service" ); } - processCount = 1; + webProcessCount = 1; } // This function will only be called once in the original process async function master() { - await checkConnection(); + await checkConnection(sequelize); await checkEnv(); await checkPendingMigrations(); - if (env.TELEMETRY && env.ENVIRONMENT === "production") { + if (env.TELEMETRY && env.isProduction) { void checkUpdates(); setInterval(checkUpdates, 24 * 3600 * 1000); } @@ -114,14 +107,14 @@ async function start(id: number, disconnect: () => void) { app.use(router.routes()); // loop through requested services at startup - for (const name of serviceNames) { + for (const name of env.SERVICES) { if (!Object.keys(services).includes(name)) { throw new Error(`Unknown service ${name}`); } Logger.info("lifecycle", `Starting ${name} service`); const init = services[name]; - await init(app, server, serviceNames); + await init(app, server, env.SERVICES); } server.on("error", (err) => { @@ -164,13 +157,25 @@ async function start(id: number, disconnect: () => void) { ShutdownHelper.add("metrics", ShutdownOrder.last, () => Metrics.flush()); + // Handle uncaught promise rejections + process.on("unhandledRejection", (error: Error) => { + Logger.error("Unhandled promise rejection", error, { + stack: error.stack, + }); + }); + // Handle shutdown signals process.once("SIGTERM", () => ShutdownHelper.execute()); process.once("SIGINT", () => ShutdownHelper.execute()); } +const isWebProcess = + env.SERVICES.includes("web") || + env.SERVICES.includes("api") || + env.SERVICES.includes("collaboration"); + void throng({ master, worker: start, - count: processCount, + count: isWebProcess ? webProcessCount : undefined, }); diff --git a/server/logging/Logger.ts b/server/logging/Logger.ts index ce734fd951399..7e8989e2eebde 100644 --- a/server/logging/Logger.ts +++ b/server/logging/Logger.ts @@ -12,8 +12,6 @@ import Sentry from "@server/logging/sentry"; import ShutdownHelper from "@server/utils/ShutdownHelper"; import * as Tracing from "./tracer"; -const isProduction = env.ENVIRONMENT === "production"; - type LogCategory = | "lifecycle" | "authentication" @@ -53,7 +51,7 @@ class Logger { }); this.output.add( new winston.transports.Console({ - format: isProduction + format: env.isProduction ? winston.format.json() : winston.format.combine( winston.format.colorize(), @@ -82,7 +80,7 @@ class Logger { * Debug information * * @param category A log message category that will be prepended - * @param extra Arbitrary data to be logged that will appear in prod logs + * @param extra Arbitrary data to be logged that will appear in development logs */ public debug(label: LogCategory, message: string, extra?: Extra) { this.output.debug(message, { ...this.sanitize(extra), label }); @@ -109,7 +107,7 @@ class Logger { }); } - if (isProduction) { + if (env.isProduction) { this.output.warn(message, this.sanitize(extra)); } else if (extra) { console.warn(message, extra); @@ -155,7 +153,7 @@ class Logger { }); } - if (isProduction) { + if (env.isProduction) { this.output.error(message, { error: error.message, stack: error.stack, @@ -188,9 +186,9 @@ class Logger { * @param input The data to sanitize * @returns The sanitized data */ - private sanitize(input: T): T { + private sanitize = (input: T, level = 0): T => { // Short circuit if we're not in production to enable easier debugging - if (!isProduction) { + if (!env.isProduction) { return input; } @@ -202,6 +200,10 @@ class Logger { "content", ]; + if (level > 3) { + return "[…]" as any as T; + } + if (isString(input)) { if (sensitiveFields.some((field) => input.includes(field))) { return "[Filtered]" as any as T; @@ -217,20 +219,22 @@ class Logger { for (const key of Object.keys(output)) { if (isObject(output[key])) { - output[key] = this.sanitize(output[key]); + output[key] = this.sanitize(output[key], level + 1); } else if (isArray(output[key])) { - output[key] = output[key].map(this.sanitize); + output[key] = output[key].map((value: unknown) => + this.sanitize(value, level + 1) + ); } else if (sensitiveFields.includes(key)) { output[key] = "[Filtered]"; } else { - output[key] = this.sanitize(output[key]); + output[key] = this.sanitize(output[key], level + 1); } } return output; } return input; - } + }; } export default new Logger(); diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 1290a024bb6f7..1dcf442625545 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -14,14 +14,11 @@ import { } from "../errors"; type AuthenticationOptions = { - /** An admin user role is required to access the route */ + /** An admin user role is required to access the route. */ admin?: boolean; - /** A member or admin user role is required to access the route */ + /** A member or admin user role is required to access the route. */ member?: boolean; - /** - * Authentication is parsed, but optional. Note that if a token is provided - * in the request it must be valid or the requst will be rejected. - */ + /** Authentication is parsed, but optional. */ optional?: boolean; }; @@ -57,14 +54,14 @@ export default function auth(options: AuthenticationOptions = {}) { token = ctx.cookies.get("accessToken"); } - if (!token && options.optional !== true) { - throw AuthenticationError("Authentication required"); - } + try { + if (!token) { + throw AuthenticationError("Authentication required"); + } - let user: User | null; - let type: AuthenticationType; + let user: User | null; + let type: AuthenticationType; - if (token) { if (ApiKey.match(String(token))) { type = AuthenticationType.API; let apiKey; @@ -146,8 +143,12 @@ export default function auth(options: AuthenticationOptions = {}) { getRootSpanFromRequestContext(ctx) ); } - } else { - ctx.state.auth = {}; + } catch (err) { + if (options.optional) { + ctx.state.auth = {}; + } else { + throw err; + } } Object.defineProperty(ctx, "context", { diff --git a/server/middlewares/passport.ts b/server/middlewares/passport.ts index 0b207ffe374a3..dbf9e5d31d9cc 100644 --- a/server/middlewares/passport.ts +++ b/server/middlewares/passport.ts @@ -52,7 +52,7 @@ export default function createMiddleware(providerName: string) { ); } - if (env.ENVIRONMENT === "development") { + if (env.isDevelopment) { throw err; } @@ -86,7 +86,7 @@ export default function createMiddleware(providerName: string) { } if (result.user.isSuspended) { - return ctx.redirect("/?notice=suspended"); + return ctx.redirect("/?notice=user-suspended"); } await signIn(ctx, providerName, result); diff --git a/server/middlewares/shareDomains.ts b/server/middlewares/shareDomains.ts new file mode 100644 index 0000000000000..ffaab1f968319 --- /dev/null +++ b/server/middlewares/shareDomains.ts @@ -0,0 +1,26 @@ +import { Context, Next } from "koa"; +import { Op } from "sequelize"; +import { parseDomain } from "@shared/utils/domains"; +import env from "@server/env"; +import { Share } from "@server/models"; + +export default function shareDomains() { + return async function shareDomainsMiddleware(ctx: Context, next: Next) { + const isCustomDomain = parseDomain(ctx.host).custom; + + if (env.isDevelopment || (isCustomDomain && env.isCloudHosted)) { + const share = await Share.unscoped().findOne({ + where: { + domain: ctx.hostname, + published: true, + revokedAt: { + [Op.is]: null, + }, + }, + }); + ctx.state.rootShare = share; + } + + return next(); + }; +} diff --git a/server/migrations/20231101021239-share-domain.js b/server/migrations/20231101021239-share-domain.js new file mode 100644 index 0000000000000..4a8a1234144e5 --- /dev/null +++ b/server/migrations/20231101021239-share-domain.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("shares", "domain", { + type: Sequelize.STRING, + allowNull: true, + unique: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("shares", "domain"); + } +}; diff --git a/server/migrations/20231111023920-add-source-metadata.js b/server/migrations/20231111023920-add-source-metadata.js new file mode 100644 index 0000000000000..2e27d932f9fcc --- /dev/null +++ b/server/migrations/20231111023920-add-source-metadata.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("documents", "sourceMetadata", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("documents", "sourceMetadata"); + } +}; diff --git a/server/migrations/20231118195149-add-content-to-documents.js b/server/migrations/20231118195149-add-content-to-documents.js new file mode 100644 index 0000000000000..5b74caf24950b --- /dev/null +++ b/server/migrations/20231118195149-add-content-to-documents.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("documents", "content", { + type: Sequelize.JSONB, + allowNull: true, + }); + await queryInterface.addColumn("revisions", "content", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("revisions", "content"); + await queryInterface.removeColumn("documents", "content"); + } +}; diff --git a/server/migrations/20231120074257-add-column-id-to-user-permissions.js b/server/migrations/20231120074257-add-column-id-to-user-permissions.js new file mode 100644 index 0000000000000..34d29b151847f --- /dev/null +++ b/server/migrations/20231120074257-add-column-id-to-user-permissions.js @@ -0,0 +1,31 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query( + `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`, + { transaction } + ); + await queryInterface.addColumn( + "user_permissions", + "id", + { + type: Sequelize.UUID, + defaultValue: Sequelize.literal("uuid_generate_v4()"), + allowNull: false, + }, + { transaction } + ); + await queryInterface.addConstraint("user_permissions", { + type: "PRIMARY KEY", + fields: ["id"], + transaction, + }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("user_permissions", "id"); + }, +}; diff --git a/server/migrations/20231120142213-add-column-id-to-group-permissions.js b/server/migrations/20231120142213-add-column-id-to-group-permissions.js new file mode 100644 index 0000000000000..44d38603238ec --- /dev/null +++ b/server/migrations/20231120142213-add-column-id-to-group-permissions.js @@ -0,0 +1,31 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query( + `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`, + { transaction } + ); + await queryInterface.addColumn( + "group_permissions", + "id", + { + type: Sequelize.UUID, + defaultValue: Sequelize.literal("uuid_generate_v4()"), + allowNull: false, + }, + { transaction } + ); + await queryInterface.addConstraint("group_permissions", { + type: "PRIMARY KEY", + fields: ["id"], + transaction, + }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("group_permissions", "id"); + }, +}; diff --git a/server/migrations/20231123022323-add-suspended-at-teams.js b/server/migrations/20231123022323-add-suspended-at-teams.js new file mode 100644 index 0000000000000..d0b4e2e39de8b --- /dev/null +++ b/server/migrations/20231123022323-add-suspended-at-teams.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("teams", "suspendedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + }, + async down(queryInterface) { + await queryInterface.removeColumn("teams", "suspendedAt"); + }, +}; diff --git a/server/migrations/20231129011114-cascade-delete.js b/server/migrations/20231129011114-cascade-delete.js new file mode 100644 index 0000000000000..f4a802f360f66 --- /dev/null +++ b/server/migrations/20231129011114-cascade-delete.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.removeConstraint("webhook_subscriptions", "webhook_subscriptions_createdById_fkey") + await queryInterface.changeColumn("webhook_subscriptions", "createdById", { + type: Sequelize.UUID, + onDelete: "cascade", + references: { + model: "users", + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeConstraint("webhook_subscriptions", "webhook_subscriptions_createdById_fkey") + await queryInterface.changeColumn("webhook_subscriptions", "createdById", { + type: Sequelize.UUID, + references: { + model: "users", + }, + }); + } +}; diff --git a/server/migrations/20231206041706-search-query-score.js b/server/migrations/20231206041706-search-query-score.js new file mode 100644 index 0000000000000..f1a96292370e0 --- /dev/null +++ b/server/migrations/20231206041706-search-query-score.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("search_queries", "score", { + type: Sequelize.INTEGER, + allowNull: true, + }); + }, + async down(queryInterface) { + await queryInterface.removeColumn("search_queries", "score"); + }, +}; diff --git a/server/migrations/20231212011038-search-query-answer.js b/server/migrations/20231212011038-search-query-answer.js new file mode 100644 index 0000000000000..fddb62231f228 --- /dev/null +++ b/server/migrations/20231212011038-search-query-answer.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("search_queries", "answer", { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + async down(queryInterface) { + await queryInterface.removeColumn("search_queries", "answer"); + }, +}; diff --git a/server/migrations/20231227040129-update-tsvector-trigger.js b/server/migrations/20231227040129-update-tsvector-trigger.js new file mode 100644 index 0000000000000..461616070e8a5 --- /dev/null +++ b/server/migrations/20231227040129-update-tsvector-trigger.js @@ -0,0 +1,32 @@ +"use strict"; + +module.exports = { + up: async (queryInterface) => { + const searchDocument = ` + CREATE OR REPLACE FUNCTION documents_search_trigger() RETURNS trigger AS $$ + begin + new."searchVector" := + setweight(to_tsvector('english', coalesce(new.title, '')),'A') || + setweight(to_tsvector('english', coalesce(array_to_string(new."previousTitles", ' , '),'')),'C') || + setweight(to_tsvector('english', substring(coalesce(new.text, ''), 1, 1000000)), 'D'); + return new; + end + $$ LANGUAGE plpgsql; + `; + await queryInterface.sequelize.query(searchDocument); + }, + down: async (queryInterface) => { + const searchDocument = ` + CREATE OR REPLACE FUNCTION documents_search_trigger() RETURNS trigger AS $$ + begin + new."searchVector" := + setweight(to_tsvector('english', coalesce(new.title, '')),'A') || + setweight(to_tsvector('english', coalesce(array_to_string(new."previousTitles", ' , '),'')),'C') || + setweight(to_tsvector('english', substring(coalesce(new.text, ''), 1, 1000000)), 'B'); + return new; + end + $$ LANGUAGE plpgsql; + `; + await queryInterface.sequelize.query(searchDocument); + }, +}; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 9b884e9a8b39a..3b1543a8baa71 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -20,7 +20,6 @@ import { Default, BeforeValidate, BeforeSave, - AfterDestroy, AfterCreate, HasMany, BelongsToMany, @@ -279,18 +278,6 @@ class Collection extends ParanoidModel { } } - @AfterDestroy - static async onAfterDestroy(model: Collection) { - await Document.destroy({ - where: { - collectionId: model.id, - archivedAt: { - [Op.is]: null, - }, - }, - }); - } - @AfterCreate static async onAfterCreate( model: Collection, diff --git a/server/models/Document.test.ts b/server/models/Document.test.ts index 70edb539b5a71..b6842accec285 100644 --- a/server/models/Document.test.ts +++ b/server/models/Document.test.ts @@ -1,3 +1,4 @@ +import { EmptyResultError } from "sequelize"; import slugify from "@shared/utils/slugify"; import Document from "@server/models/Document"; import { @@ -176,6 +177,34 @@ describe("#findByPk", () => { const response = await Document.findByPk(id); expect(response?.id).toBe(document.id); }); + + it("should test with rejectOnEmpty flag", async () => { + const user = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + createdById: user.id, + }); + await expect( + Document.findByPk(document.id, { + userId: user.id, + rejectOnEmpty: true, + }) + ).resolves.not.toBeNull(); + + await expect( + Document.findByPk(document.urlId, { + userId: user.id, + rejectOnEmpty: true, + }) + ).resolves.not.toBeNull(); + + await expect( + Document.findByPk("0e8280ea-7b4c-40e5-98ba-ec8a2f00f5e8", { + userId: user.id, + rejectOnEmpty: true, + }) + ).rejects.toThrow(EmptyResultError); + }); }); describe("tasks", () => { diff --git a/server/models/Document.ts b/server/models/Document.ts index df664d7f9518d..67ee3092af11e 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -11,6 +11,7 @@ import { FindOptions, ScopeOptions, WhereOptions, + EmptyResultError, } from "sequelize"; import { ForeignKey, @@ -31,9 +32,14 @@ import { Length as SimpleLength, IsNumeric, IsDate, + AllowNull, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; -import type { NavigationNode } from "@shared/types"; +import type { + NavigationNode, + ProsemirrorData, + SourceMetadata, +} from "@shared/types"; import getTasks from "@shared/utils/getTasks"; import slugify from "@shared/utils/slugify"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; @@ -57,6 +63,7 @@ export const DOCUMENT_VERSION = 2; type AdditionalFindOptions = { userId?: string; includeState?: boolean; + rejectOnEmpty?: boolean | Error; }; @DefaultScope(() => ({ @@ -79,6 +86,11 @@ type AdditionalFindOptions = { publishedAt: { [Op.ne]: null, }, + sourceMetadata: { + trial: { + [Op.is]: null, + }, + }, }, })) @Scopes(() => ({ @@ -198,15 +210,18 @@ class Document extends ParanoidModel { @Column(DataType.SMALLINT) version: number; + @Default(false) @Column template: boolean; + @Default(false) @Column fullWidth: boolean; @Column insightsEnabled: boolean; + /** The version of the editor last used to edit this document. */ @SimpleLength({ max: 255, msg: `editorVersion must be 255 characters or less`, @@ -214,6 +229,7 @@ class Document extends ParanoidModel { @Column editorVersion: string; + /** An emoji to use as the document icon. */ @Length({ max: 1, msg: `Emoji must be a single character`, @@ -221,9 +237,25 @@ class Document extends ParanoidModel { @Column emoji: string | null; + /** + * The content of the document as Markdown. + * + * @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown. + * This column will be removed in a future migration. + */ @Column(DataType.TEXT) text: string; + /** + * The content of the document as JSON, this is a snapshot at the last time the state was saved. + */ + @Column(DataType.JSONB) + content: ProsemirrorData; + + /** + * The content of the document as YJS collaborative state, this column can be quite large and + * should only be selected from the DB when the `content` snapshot cannot be used. + */ @SimpleLength({ max: DocumentValidation.maxStateLength, msg: `Document collaborative state is too large, you must create a new document`, @@ -231,28 +263,38 @@ class Document extends ParanoidModel { @Column(DataType.BLOB) state: Uint8Array; + /** Whether this document is part of onboarding. */ @Default(false) @Column isWelcome: boolean; + /** How many versions there are in the history of this document. */ @IsNumeric @Default(0) @Column(DataType.INTEGER) revisionCount: number; + /** Whether the document is archvied, and if so when. */ @IsDate @Column archivedAt: Date | null; + /** Whether the document is published, and if so when. */ @IsDate @Column publishedAt: Date | null; + /** An array of user IDs that have edited this document. */ @Column(DataType.ARRAY(DataType.UUID)) collaboratorIds: string[] = []; // getters + /** + * The frontend path to this document. + * + * @deprecated Use `path` instead. + */ get url() { if (!this.title) { return `/doc/untitled-${this.urlId}`; @@ -261,6 +303,11 @@ class Document extends ParanoidModel { return `/doc/${slugifiedTitle}-${this.urlId}`; } + /** The frontend path to this document. */ + get path() { + return this.url; + } + get tasks() { return getTasks(this.text || ""); } @@ -355,6 +402,11 @@ class Document extends ParanoidModel { model.collaboratorIds = []; } + // backfill content if it's missing + if (!model.content) { + model.content = DocumentHelper.toJSON(model); + } + // ensure the last modifying user is a collaborator model.collaboratorIds = uniq( model.collaboratorIds.concat(model.lastModifiedById) @@ -399,6 +451,10 @@ class Document extends ParanoidModel { @Column(DataType.UUID) importId: string | null; + @AllowNull + @Column(DataType.JSONB) + sourceMetadata: SourceMetadata | null; + @BelongsTo(() => Document, "parentDocumentId") parentDocument: Document | null; @@ -503,22 +559,36 @@ class Document extends ParanoidModel { ]); if (isUUID(id)) { - return scope.findOne({ + const document = await scope.findOne({ where: { id, }, ...rest, + rejectOnEmpty: false, }); + + if (!document && rest.rejectOnEmpty) { + throw new EmptyResultError(`Document doesn't exist with id: ${id}`); + } + + return document; } const match = id.match(SLUG_URL_REGEX); if (match) { - return scope.findOne({ + const document = await scope.findOne({ where: { urlId: match[1], }, ...rest, + rejectOnEmpty: false, }); + + if (!document && rest.rejectOnEmpty) { + throw new EmptyResultError(`Document doesn't exist with id: ${id}`); + } + + return document; } return null; @@ -545,10 +615,40 @@ class Document extends ParanoidModel { return !this.publishedAt; } + /** + * Returns the title of the document or a default if the document is untitled. + * + * @returns boolean + */ get titleWithDefault(): string { return this.title || "Untitled"; } + /** + * Whether this document was imported during a trial period. + * + * @returns boolean + */ + get isTrialImport() { + return !!(this.importId && this.sourceMetadata?.trial); + } + + /** + * Revert the state of the document to match the passed revision. + * + * @param revision The revision to revert to. + */ + restoreFromRevision = (revision: Revision) => { + if (revision.documentId !== this.id) { + throw new Error("Revision does not belong to this document"); + } + + this.content = revision.content; + this.text = revision.text; + this.title = revision.title; + this.emoji = revision.emoji; + }; + /** * Get a list of users that have collaborated on this document * diff --git a/server/models/Event.ts b/server/models/Event.ts index a1227984b3ffe..53503ebb577e8 100644 --- a/server/models/Event.ts +++ b/server/models/Event.ts @@ -139,6 +139,7 @@ class Event extends IdModel { "documents.restore", "revisions.create", "users.create", + "users.demote", ]; static AUDIT_EVENTS: TEvent["name"][] = [ diff --git a/server/models/FileOperation.ts b/server/models/FileOperation.ts index d0c55c1ae1252..3d11f1d9f8816 100644 --- a/server/models/FileOperation.ts +++ b/server/models/FileOperation.ts @@ -87,6 +87,13 @@ class FileOperation extends ParanoidModel { return FileStorage.getFileStream(this.key); } + /** + * The file operation contents as a handle which contains a path and cleanup function. + */ + get handle() { + return FileStorage.getFileHandle(this.key); + } + // hooks @BeforeDestroy diff --git a/server/models/GroupPermission.ts b/server/models/GroupPermission.ts index 95ab3e9d2ff1e..441263531efba 100644 --- a/server/models/GroupPermission.ts +++ b/server/models/GroupPermission.ts @@ -14,7 +14,7 @@ import Collection from "./Collection"; import Document from "./Document"; import Group from "./Group"; import User from "./User"; -import Model from "./base/Model"; +import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; @Scopes(() => ({ @@ -40,7 +40,7 @@ import Fix from "./decorators/Fix"; })) @Table({ tableName: "group_permissions", modelName: "group_permission" }) @Fix -class GroupPermission extends Model { +class GroupPermission extends ParanoidModel { @Default(CollectionPermission.ReadWrite) @IsIn([Object.values(CollectionPermission)]) @Column(DataType.STRING) diff --git a/server/models/Revision.ts b/server/models/Revision.ts index ef5210d8ffa0b..ff01926d17bfc 100644 --- a/server/models/Revision.ts +++ b/server/models/Revision.ts @@ -9,6 +9,7 @@ import { IsNumeric, Length as SimpleLength, } from "sequelize-typescript"; +import type { ProsemirrorData } from "@shared/types"; import { DocumentValidation } from "@shared/validations"; import Document from "./Document"; import User from "./User"; @@ -46,9 +47,21 @@ class Revision extends IdModel { @Column title: string; + /** + * The content of the revision as Markdown. + * + * @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown. + * This column will be removed in a future migration. + */ @Column(DataType.TEXT) text: string; + /** + * The content of the revision as JSON. + */ + @Column(DataType.JSONB) + content: ProsemirrorData; + @Length({ max: 1, msg: `Emoji must be a single character`, @@ -100,6 +113,7 @@ class Revision extends IdModel { title: document.title, text: document.text, emoji: document.emoji, + content: document.content, userId: document.lastModifiedById, editorVersion: document.editorVersion, version: document.version, @@ -127,7 +141,12 @@ class Revision extends IdModel { // instance methods - previous(): Promise { + /** + * Find the revision for the document before this one. + * + * @returns A Promise that resolves to a Revision, or null if this is the first revision. + */ + before(): Promise { return (this.constructor as typeof Revision).findOne({ where: { documentId: this.documentId, diff --git a/server/models/SearchQuery.ts b/server/models/SearchQuery.ts index 5ada3547b54ec..2bff820f4c946 100644 --- a/server/models/SearchQuery.ts +++ b/server/models/SearchQuery.ts @@ -30,12 +30,33 @@ class SearchQuery extends Model { @CreatedAt createdAt: Date; + /** + * Where the query originated. + */ @Column(DataType.ENUM("slack", "app", "api")) source: string; + /** + * The number of results returned for this query. + */ @Column results: number; + /** + * User score for the results for this query, -1 for negative, 1 for positive, null for neutral. + */ + @Column + score: number; + + /** + * The generated answer to the query, if any. + */ + @Column + answer: string; + + /** + * The query string, automatically truncated to 255 characters. + */ @Column(DataType.STRING) set query(value: string) { this.setDataValue("query", value.substring(0, 255)); diff --git a/server/models/Share.ts b/server/models/Share.ts index 797b3e75c624a..1f13c2f647e8a 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -1,3 +1,4 @@ +import { type SaveOptions } from "sequelize"; import { ForeignKey, BelongsTo, @@ -9,14 +10,20 @@ import { Default, AllowNull, Is, + Unique, + BeforeUpdate, } from "sequelize-typescript"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; +import env from "@server/env"; +import { ValidationError } from "@server/errors"; import Collection from "./Collection"; import Document from "./Document"; import Team from "./Team"; import User from "./User"; import IdModel from "./base/IdModel"; import Fix from "./decorators/Fix"; +import IsFQDN from "./validators/IsFQDN"; +import Length from "./validators/Length"; @DefaultScope(() => ({ include: [ @@ -88,6 +95,36 @@ class Share extends IdModel { @Column urlId: string | null | undefined; + @Unique + @Length({ max: 255, msg: "domain must be 255 characters or less" }) + @IsFQDN + @Column + domain: string | null; + + // hooks + + @BeforeUpdate + static async checkDomain(model: Share, options: SaveOptions) { + if (!model.domain) { + return model; + } + + model.domain = model.domain.toLowerCase(); + + const count = await Team.count({ + ...options, + where: { + domain: model.domain, + }, + }); + + if (count > 0) { + throw ValidationError("Domain is already in use"); + } + + return model; + } + // getters get isRevoked() { @@ -95,6 +132,11 @@ class Share extends IdModel { } get canonicalUrl() { + if (this.domain) { + const url = new URL(env.URL); + return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`; + } + return this.urlId ? `${this.team.url}/s/${this.urlId}` : `${this.team.url}/s/${this.id}`; diff --git a/server/models/Team.ts b/server/models/Team.ts index 8f91ccb4ef360..77d05306b2a9f 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -3,6 +3,7 @@ import fs from "fs"; import path from "path"; import { URL } from "url"; import util from "util"; +import { type SaveOptions } from "sequelize"; import { Op } from "sequelize"; import { Column, @@ -12,6 +13,7 @@ import { Table, Unique, IsIn, + IsDate, HasMany, Scopes, Is, @@ -19,6 +21,7 @@ import { IsUUID, AllowNull, AfterUpdate, + BeforeUpdate, } from "sequelize-typescript"; import { TeamPreferenceDefaults } from "@shared/constants"; import { @@ -28,12 +31,14 @@ import { } from "@shared/types"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import env from "@server/env"; +import { ValidationError } from "@server/errors"; import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import Attachment from "./Attachment"; import AuthenticationProvider from "./AuthenticationProvider"; import Collection from "./Collection"; import Document from "./Document"; +import Share from "./Share"; import TeamDomain from "./TeamDomain"; import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; @@ -147,8 +152,19 @@ class Team extends ParanoidModel { @Column(DataType.JSONB) preferences: TeamPreferences | null; + @IsDate + @Column + suspendedAt: Date | null; + // getters + /** + * Returns whether the team has been suspended and is no longer accessible. + */ + get isSuspended(): boolean { + return !!this.suspendedAt; + } + /** * Returns whether the team has email login enabled. For self-hosted installs * this also considers whether SMTP connection details have been configured. @@ -156,9 +172,7 @@ class Team extends ParanoidModel { * @return {boolean} Whether to show email login options */ get emailSigninEnabled(): boolean { - return ( - this.guestSignin && (!!env.SMTP_HOST || env.ENVIRONMENT === "development") - ); + return this.guestSignin && (!!env.SMTP_HOST || env.isDevelopment); } get url() { @@ -328,6 +342,28 @@ class Team extends ParanoidModel { // hooks + @BeforeUpdate + static async checkDomain(model: Team, options: SaveOptions) { + if (!model.domain) { + return model; + } + + model.domain = model.domain.toLowerCase(); + + const count = await Share.count({ + ...options, + where: { + domain: model.domain, + }, + }); + + if (count > 0) { + throw ValidationError("Domain is already in use"); + } + + return model; + } + @AfterUpdate static deletePreviousAvatar = async (model: Team) => { if ( diff --git a/server/models/User.ts b/server/models/User.ts index 913ecb09be4cb..8388f17d9e032 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -2,7 +2,13 @@ import crypto from "crypto"; import { addHours, addMinutes, subMinutes } from "date-fns"; import JWT from "jsonwebtoken"; import { Context } from "koa"; -import { Transaction, QueryTypes, SaveOptions, Op } from "sequelize"; +import { + Transaction, + QueryTypes, + SaveOptions, + Op, + FindOptions, +} from "sequelize"; import { Table, Column, @@ -227,7 +233,7 @@ class User extends ParanoidModel { // getters get isSuspended(): boolean { - return !!this.suspendedAt; + return !!this.suspendedAt || !!this.team?.isSuspended; } get isInvited() { @@ -361,7 +367,7 @@ class User extends ParanoidModel { UserPreferenceDefaults[preference] ?? false; - collectionIds = async (options = {}) => { + collectionIds = async (options: FindOptions = {}) => { const collectionStubs = await Collection.scope({ method: ["withMembership", this.id], }).findAll({ diff --git a/server/models/UserPermission.ts b/server/models/UserPermission.ts index 9f545bdac6ca3..5afabe7caab88 100644 --- a/server/models/UserPermission.ts +++ b/server/models/UserPermission.ts @@ -13,7 +13,7 @@ import { CollectionPermission } from "@shared/types"; import Collection from "./Collection"; import Document from "./Document"; import User from "./User"; -import Model from "./base/Model"; +import IdModel from "./base/IdModel"; import Fix from "./decorators/Fix"; @Scopes(() => ({ @@ -39,7 +39,7 @@ import Fix from "./decorators/Fix"; })) @Table({ tableName: "user_permissions", modelName: "user_permission" }) @Fix -class UserPermission extends Model { +class UserPermission extends IdModel { @Default(CollectionPermission.ReadWrite) @IsIn([Object.values(CollectionPermission)]) @Column(DataType.STRING) diff --git a/server/models/helpers/DocumentHelper.test.ts b/server/models/helpers/DocumentHelper.test.ts index 0c36260b873cd..3a4dc64cdd167 100644 --- a/server/models/helpers/DocumentHelper.test.ts +++ b/server/models/helpers/DocumentHelper.test.ts @@ -1,5 +1,5 @@ import Revision from "@server/models/Revision"; -import { buildDocument, buildUser } from "@server/test/factories"; +import { buildDocument } from "@server/test/factories"; import DocumentHelper from "./DocumentHelper"; describe("DocumentHelper", () => { @@ -12,28 +12,6 @@ describe("DocumentHelper", () => { jest.useRealTimers(); }); - describe("replaceTemplateVariables", () => { - it("should replace {time} with current time", async () => { - const user = await buildUser(); - const result = DocumentHelper.replaceTemplateVariables( - "Hello {time}", - user - ); - - expect(result).toBe("Hello 12 00 AM"); - }); - - it("should replace {date} with current date", async () => { - const user = await buildUser(); - const result = DocumentHelper.replaceTemplateVariables( - "Hello {date}", - user - ); - - expect(result).toBe("Hello January 1 2021"); - }); - }); - describe("parseMentions", () => { it("should not parse normal links as mentions", async () => { const document = await buildDocument({ diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 19870dc424d1a..d58476ed25337 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -1,38 +1,30 @@ import { updateYFragment, + yDocToProsemirror, yDocToProsemirrorJSON, } from "@getoutline/y-prosemirror"; import { JSDOM } from "jsdom"; -import escapeRegExp from "lodash/escapeRegExp"; -import startCase from "lodash/startCase"; import { Node } from "prosemirror-model"; -import { Transaction } from "sequelize"; import * as Y from "yjs"; import textBetween from "@shared/editor/lib/textBetween"; -import { AttachmentPreset } from "@shared/types"; -import { - getCurrentDateAsString, - getCurrentDateTimeAsString, - getCurrentTimeAsString, - unicodeCLDRtoBCP47, -} from "@shared/utils/date"; -import attachmentCreator from "@server/commands/attachmentCreator"; +import MarkdownHelper from "@shared/utils/MarkdownHelper"; import { parser, schema } from "@server/editor"; +import { addTags } from "@server/logging/tracer"; import { trace } from "@server/logging/tracing"; -import { Document, Revision, User } from "@server/models"; +import { Document, Revision } from "@server/models"; import FileStorage from "@server/storage/files"; import { APIContext } from "@server/types"; import diff from "@server/utils/diff"; -import parseAttachmentIds from "@server/utils/parseAttachmentIds"; -import parseImages from "@server/utils/parseImages"; -import Attachment from "../Attachment"; import ProsemirrorHelper from "./ProsemirrorHelper"; +import TextHelper from "./TextHelper"; type HTMLOptions = { /** Whether to include the document title in the generated HTML (defaults to true) */ includeTitle?: boolean; /** Whether to include style tags in the generated HTML (defaults to true) */ includeStyles?: boolean; + /** Whether to include the Mermaid script in the generated HTML (defaults to false) */ + includeMermaid?: boolean; /** Whether to include styles to center diff (defaults to true) */ centered?: boolean; /** @@ -45,8 +37,25 @@ type HTMLOptions = { @trace() export default class DocumentHelper { /** - * Returns the document as a Prosemirror Node. This method uses the - * collaborative state if available, otherwise it falls back to Markdown. + * Returns the document as JSON content. This method uses the collaborative state if available, + * otherwise it falls back to Markdown. + * + * @param document The document or revision to convert + * @returns The document content as JSON + */ + static toJSON(document: Document | Revision) { + if ("state" in document && document.state) { + const ydoc = new Y.Doc(); + Y.applyUpdate(ydoc, document.state); + return yDocToProsemirrorJSON(ydoc, "default"); + } + const node = parser.parse(document.text) || Node.fromJSON(schema, {}); + return node.toJSON(); + } + + /** + * Returns the document as a Prosemirror Node. This method uses the collaborative state if + * available, otherwise it falls back to Markdown. * * @param document The document or revision to convert * @returns The document content as a Prosemirror Node @@ -86,13 +95,7 @@ export default class DocumentHelper { * @returns The document title and content as a Markdown string */ static toMarkdown(document: Document | Revision) { - const text = document.text.replace(/\n\\\n/g, "\n\n"); - - if (document.version) { - return `# ${document.title}\n\n${text}`; - } - - return text; + return MarkdownHelper.toMarkdown(document); } /** @@ -108,9 +111,15 @@ export default class DocumentHelper { let output = ProsemirrorHelper.toHTML(node, { title: options?.includeTitle !== false ? document.title : undefined, includeStyles: options?.includeStyles, + includeMermaid: options?.includeMermaid, centered: options?.centered, }); + addTags({ + documentId: document.id, + options, + }); + if (options?.signedUrls) { const teamId = document instanceof Document @@ -121,7 +130,7 @@ export default class DocumentHelper { return output; } - output = await DocumentHelper.attachmentsToSignedUrls( + output = await TextHelper.attachmentsToSignedUrls( output, teamId, typeof options.signedUrls === "number" ? options.signedUrls : undefined @@ -155,6 +164,12 @@ export default class DocumentHelper { after: Revision, { signedUrls, ...options }: HTMLOptions = {} ) { + addTags({ + beforeId: before?.id, + documentId: after.documentId, + options, + }); + if (!before) { return await DocumentHelper.toHTML(after, { ...options, signedUrls }); } @@ -179,7 +194,7 @@ export default class DocumentHelper { : (await before.$get("document"))?.teamId; if (teamId) { - diffedContentAsHTML = await DocumentHelper.attachmentsToSignedUrls( + diffedContentAsHTML = await TextHelper.attachmentsToSignedUrls( diffedContentAsHTML, teamId, typeof signedUrls === "number" ? signedUrls : undefined @@ -327,119 +342,6 @@ export default class DocumentHelper { return `${head?.innerHTML} ${body?.innerHTML}`; } - /** - * Converts attachment urls in documents to signed equivalents that allow - * direct access without a session cookie - * - * @param text The text either html or markdown which contains urls to be converted - * @param teamId The team context - * @param expiresIn The time that signed urls should expire (in seconds) - * @returns The replaced text - */ - static async attachmentsToSignedUrls( - text: string, - teamId: string, - expiresIn = 3000 - ) { - const attachmentIds = parseAttachmentIds(text); - - await Promise.all( - attachmentIds.map(async (id) => { - const attachment = await Attachment.findOne({ - where: { - id, - teamId, - }, - }); - - if (attachment) { - const signedUrl = await FileStorage.getSignedUrl( - attachment.key, - expiresIn - ); - - text = text.replace( - new RegExp(escapeRegExp(attachment.redirectUrl), "g"), - signedUrl - ); - } - }) - ); - return text; - } - - /** - * Replaces template variables in the given text with the current date and time. - * - * @param text The text to replace the variables in - * @param user The user to get the language/locale from - * @returns The text with the variables replaced - */ - static replaceTemplateVariables(text: string, user: User) { - const locales = user.language - ? unicodeCLDRtoBCP47(user.language) - : undefined; - - return text - .replace(/{date}/g, startCase(getCurrentDateAsString(locales))) - .replace(/{time}/g, startCase(getCurrentTimeAsString(locales))) - .replace(/{datetime}/g, startCase(getCurrentDateTimeAsString(locales))); - } - - /** - * Replaces remote and base64 encoded images in the given text with attachment - * urls and uploads the images to the storage provider. - * - * @param text The text to replace the images in - * @param user The user context - * @param ip The IP address of the user - * @param transaction The transaction to use for the database operations - * @returns The text with the images replaced - */ - static async replaceImagesWithAttachments( - text: string, - user: User, - ip?: string, - transaction?: Transaction - ) { - let output = text; - const images = parseImages(text); - - await Promise.all( - images.map(async (image) => { - // Skip attempting to fetch images that are not valid urls - try { - new URL(image.src); - } catch { - return; - } - - const attachment = await attachmentCreator({ - name: image.alt ?? "image", - url: image.src, - preset: AttachmentPreset.DocumentAttachment, - user, - ctx: { - context: { - ip, - transaction, - auth: { user }, - }, - } as APIContext, - }); - - if (attachment) { - output = output.replace( - new RegExp(escapeRegExp(image.src), "g"), - attachment.redirectUrl - ); - } - }) - ); - - return output; - } - /** * Applies the given Markdown to the document, this essentially creates a * single change in the collaborative state that makes all the edits to get @@ -457,12 +359,12 @@ export default class DocumentHelper { append = false ) { document.text = append ? document.text + text : text; + const doc = parser.parse(document.text); if (document.state) { const ydoc = new Y.Doc(); Y.applyUpdate(ydoc, document.state); const type = ydoc.get("default", Y.XmlFragment) as Y.XmlFragment; - const doc = parser.parse(document.text); if (!type.doc) { throw new Error("type.doc not found"); @@ -472,8 +374,13 @@ export default class DocumentHelper { updateYFragment(type.doc, type, doc, new Map()); const state = Y.encodeStateAsUpdate(ydoc); + const node = yDocToProsemirror(schema, ydoc); + + document.content = node.toJSON(); document.state = Buffer.from(state); document.changed("state", true); + } else if (doc) { + document.content = doc.toJSON(); } return document; diff --git a/server/models/helpers/HTMLHelper.ts b/server/models/helpers/HTMLHelper.ts new file mode 100644 index 0000000000000..eea4266345b86 --- /dev/null +++ b/server/models/helpers/HTMLHelper.ts @@ -0,0 +1,20 @@ +import { inline } from "css-inline"; +import env from "@server/env"; + +export default class HTMLHelper { + /** + * Move CSS styles from "; + const iframeHtml = `${styles}${snippetScript}`; + + return ( +