From 0da51bba671bc0a8c5bb054b4f7555880783829a Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Sat, 18 Jun 2022 18:40:28 +0200 Subject: [PATCH] Add basic realtime communication (#2118) Signed-off-by: Tilman Vatteroth --- docker/.gitignore | 5 + docker/Caddyfile | 16 +++ docker/README.md | 13 +++ docker/download-caddy.sh | 10 ++ locales/en.json | 2 +- next.config.js | 34 +++++- package.json | 11 +- .../application-loader/application-loader.tsx | 4 +- .../application-loader/initializers/index.ts | 16 +-- .../editor-page/editor-pane/editor-pane.tsx | 42 ++++--- .../codemirror.module.scss | 12 +- .../hooks/use-apply-scroll-state.ts | 19 ++- .../editor-pane/hooks/yjs/mock-connection.ts | 29 +++++ .../editor-pane/hooks/yjs/use-awareness.ts | 90 +++++++++++++++ .../hooks/yjs/use-bind-y-text-to-redux.ts | 22 ++++ .../yjs/use-code-mirror-yjs-extension.ts | 22 ++++ ...l-note-content-into-editor-in-mock-mode.ts | 40 +++++++ .../hooks/yjs/use-websocket-connection.ts | 40 +++++++ .../hooks/yjs/use-websocket-url.ts | 40 +++++++ .../editor-pane/hooks/yjs/use-y-doc.ts | 19 +++ .../hooks/yjs/websocket-connection.ts | 60 ++++++++++ src/utils/backend-url.ts | 23 +++- yarn.lock | 108 ++++++++++++++++-- 23 files changed, 624 insertions(+), 53 deletions(-) create mode 100644 docker/.gitignore create mode 100644 docker/Caddyfile create mode 100644 docker/README.md create mode 100755 docker/download-caddy.sh create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts create mode 100644 src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 000000000..8dd88d939 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) +# +# SPDX-License-Identifier: CC0-1.0 + +caddy diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 000000000..f1d27294b --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,16 @@ +# +# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) +# +# SPDX-License-Identifier: AGPL-3.0-only +# + +:8080 + +log { + output stdout +} + +reverse_proxy /realtime http://127.0.0.1:3000 +reverse_proxy /api/* http://127.0.0.1:3000 +reverse_proxy /public/* http://127.0.0.1:3000 +reverse_proxy /* http://127.0.0.1:3001 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..762400f5b --- /dev/null +++ b/docker/README.md @@ -0,0 +1,13 @@ + + +To use backend and frontend together you need a reverse proxy that combines both services under one origin. + +We provide a ready to use config for nginx and caddy. +Make sure that in the backend HD_DOMAIN is set to `http://localhost:8080`. + +If you have docker you can use our docker-compose file that starts a nginx using `docker-compose up`. +If you're on Windows or macOS you rather might want to download [caddy](https://caddyserver.com/) and start it using `caddy run` in this directory. diff --git a/docker/download-caddy.sh b/docker/download-caddy.sh new file mode 100755 index 000000000..aa4b3ae4e --- /dev/null +++ b/docker/download-caddy.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# +# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) +# +# SPDX-License-Identifier: AGPL-3.0-only +# + +set -e +curl -o caddy "https://caddyserver.com/api/download?os=linux&arch=amd64" +chmod +x ./caddy diff --git a/locales/en.json b/locales/en.json index 987ded698..1aae02e15 100644 --- a/locales/en.json +++ b/locales/en.json @@ -517,7 +517,7 @@ "delete": "Delete", "or": "or", "and": "and", - "avatarOf": "avatar of '{{name}}'", + "avatarOf": "Avatar of '{{name}}'", "why": "Why?", "loading": "Loading ...", "errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.", diff --git a/next.config.js b/next.config.js index 40a2376aa..e2ce337ba 100644 --- a/next.config.js +++ b/next.config.js @@ -1,12 +1,24 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ +//TODO: [mrdrogdrog] The following function and two constants already exist in typescript code. +// However, this file must be a js file and therefore can't access the ts function. +// I have no idea how to resolve this redundancy without extracting it into a node module. +const isPositiveAnswer = (value) => { + const lowerValue = value.toLowerCase() + return lowerValue === 'yes' || lowerValue === '1' || lowerValue === 'true' +} + +const isTestMode = !!process.env.NEXT_PUBLIC_TEST_MODE && isPositiveAnswer(process.env.NEXT_PUBLIC_TEST_MODE) + +const isMockMode = !!process.env.NEXT_PUBLIC_USE_MOCK_API && isPositiveAnswer(process.env.NEXT_PUBLIC_USE_MOCK_API) + console.log('Node env is', process.env.NODE_ENV) -if (process.env.NEXT_PUBLIC_USE_MOCK_API === 'true') { +if (isMockMode) { console.log('Uses mock API') } else if (!!process.env.NEXT_PUBLIC_BACKEND_BASE_URL) { console.log('Backend base url is', process.env.NEXT_PUBLIC_BACKEND_BASE_URL) @@ -20,11 +32,23 @@ If you want to create a build that uses the mock api then use build:mock instead } if (!!process.env.NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG) { - console.warn("You have set NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG. This flag is ONLY for testing purposes and will decrease the security of the editor if used in production!") + console.warn( + 'You have set NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG. This flag is ONLY for testing purposes and will decrease the security of the editor if used in production!' + ) } -if (!!process.env.NEXT_PUBLIC_TEST_MODE) { - console.warn('This build runs in test mode. This means:\n - no sandboxed iframe\n - Additional data-attributes for e2e tests added to DOM') +if (isTestMode) { + console.warn(`This build runs in test mode. This means: + - no sandboxed iframe + - Additional data-attributes for e2e tests added to DOM`) +} + +if (!!isMockMode) { + console.warn(`This build runs in mock mode. This means: + - No real data. All API responses are mocked + - No persistent data + - No realtime editing + `) } const path = require('path') diff --git a/package.json b/package.json index 6c917e852..707ed0932 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,17 @@ "build:netlify": "bash netlify/patch-files.sh && cross-env NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG=true yarn build:mock", "build:mock": "cross-env NEXT_PUBLIC_USE_MOCK_API=true next build", "build:test": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true next build", + "build:for-real-backend": "cross-env NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=/ next build", "analyze": "cross-env ANALYZE=true next build", "dev": "cross-env PORT=3001 next dev", "dev:test": "cross-env PORT=3001 NEXT_PUBLIC_TEST_MODE=true next dev", - "dev:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:3000/ next dev", + "dev:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8080/ next dev", "format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"", "format:fix": "prettier -w \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"", "lint": "eslint --max-warnings=0 --ext .ts,.tsx src", "lint:fix": "eslint --fix --ext .ts,.tsx src", "start": "cross-env PORT=3001 next start", + "start:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=/ next start", "start:mock": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=true next start", "start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start", "cy:open": "cypress open", @@ -39,11 +41,14 @@ "dependencies": { "@codemirror/lang-markdown": "6.0.0", "@codemirror/language-data": "6.1.0", + "@codemirror/state": "6.0.0", "@codemirror/theme-one-dark": "6.0.0", + "@codemirror/view": "6.0.0", "@fontsource/source-sans-pro": "4.5.10", "@hedgedoc/html-to-react": "1.4.1", "@hedgedoc/markdown-it-image-size": "1.0.3", "@hedgedoc/markdown-it-task-lists": "1.0.3", + "@hedgedoc/realtime": "0.0.3", "@matejmazur/react-katex": "3.1.3", "@react-hook/resize-observer": "1.2.5", "@redux-devtools/core": "3.13.1", @@ -111,7 +116,9 @@ "vega": "5.22.1", "vega-embed": "6.21.0", "vega-lite": "5.2.0", - "words-count": "2.0.2" + "words-count": "2.0.2", + "y-codemirror.next": "0.3.0", + "y-protocols": "1.0.5" }, "devDependencies": { "@next/bundle-analyzer": "12.1.6", diff --git a/src/components/application-loader/application-loader.tsx b/src/components/application-loader/application-loader.tsx index f76109756..711ea4f71 100644 --- a/src/components/application-loader/application-loader.tsx +++ b/src/components/application-loader/application-loader.tsx @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -19,7 +19,7 @@ export const ApplicationLoader: React.FC> = ({ childr const initTasks = createSetUpTaskList() for (const task of initTasks) { try { - await task.task + await task.task() } catch (reason: unknown) { log.error('Error while initialising application', reason) throw new ApplicationLoaderError(task.name) diff --git a/src/components/application-loader/initializers/index.ts b/src/components/application-loader/initializers/index.ts index 85e6b2059..327c9f1bd 100644 --- a/src/components/application-loader/initializers/index.ts +++ b/src/components/application-loader/initializers/index.ts @@ -27,38 +27,38 @@ const customDelay: () => Promise = async () => { export interface InitTask { name: string - task: Promise + task: () => Promise } export const createSetUpTaskList = (): InitTask[] => { return [ { name: 'Load dark mode', - task: loadDarkMode() + task: loadDarkMode }, { name: 'Load Translations', - task: setUpI18n() + task: setUpI18n }, { name: 'Load config', - task: fetchFrontendConfig() + task: fetchFrontendConfig }, { name: 'Fetch user information', - task: fetchAndSetUser() + task: fetchAndSetUser }, { name: 'Motd', - task: fetchMotd() + task: fetchMotd }, { name: 'Load history state', - task: refreshHistoryState() + task: refreshHistoryState }, { name: 'Add Delay', - task: customDelay() + task: customDelay } ] } diff --git a/src/components/editor-page/editor-pane/editor-pane.tsx b/src/components/editor-page/editor-pane/editor-pane.tsx index 186fedf49..c7362fa5a 100644 --- a/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/src/components/editor-page/editor-pane/editor-pane.tsx @@ -4,13 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useCallback, useMemo } from 'react' +import React, { useMemo } from 'react' import type { ScrollProps } from '../synced-scroll/scroll-props' import { StatusBar } from './status-bar/status-bar' import { ToolBar } from './tool-bar/tool-bar' import { useApplicationState } from '../../../hooks/common/use-application-state' -import { setNoteContent } from '../../../redux/note-details/methods' -import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content' import { MaxLengthWarning } from './max-length-warning/max-length-warning' import ReactCodeMirror from '@uiw/react-codemirror' import { useApplyScrollState } from './hooks/use-apply-scroll-state' @@ -29,10 +27,14 @@ import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback' import { useCodeMirrorReference, useSetCodeMirrorReference } from '../change-content-context/change-content-context' import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension' import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer' +import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension' +import { useYDoc } from './hooks/yjs/use-y-doc' +import { useAwareness } from './hooks/yjs/use-awareness' +import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection' +import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux' +import { useInsertInitialNoteContentIntoEditorInMockMode } from './hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode' export const EditorPane: React.FC = ({ scrollState, onScroll, onMakeScrollSource }) => { - const markdownContent = useNoteMarkdownContent() - const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) useApplyScrollState(scrollState) @@ -42,10 +44,6 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak const fileInsertExtension = useCodeMirrorFileInsertExtension() const cursorActivityExtension = useCursorActivityCallback() - const onBeforeChange = useCallback((value: string): void => { - setNoteContent(value) - }, []) - const codeMirrorRef = useCodeMirrorReference() const setCodeMirrorReference = useSetCodeMirrorReference() @@ -57,6 +55,16 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak }) }, [codeMirrorRef, setCodeMirrorReference]) + const yDoc = useYDoc() + const awareness = useAwareness(yDoc) + const yText = useMemo(() => yDoc.getText('markdownContent'), [yDoc]) + + useWebsocketConnection(yDoc, awareness) + useBindYTextToRedux(yText) + + const yjsExtension = useCodeMirrorYjsExtension(yText, awareness) + const mockContentExtension = useInsertInitialNoteContentIntoEditorInMockMode(yText) + const extensions = useMemo( () => [ markdown({ @@ -69,9 +77,19 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak fileInsertExtension, autocompletion(), cursorActivityExtension, - updateViewContext + updateViewContext, + yjsExtension, + ...(mockContentExtension ? [mockContentExtension] : []) ], - [cursorActivityExtension, fileInsertExtension, tablePasteExtensions, editorScrollExtension, updateViewContext] + [ + editorScrollExtension, + tablePasteExtensions, + fileInsertExtension, + cursorActivityExtension, + updateViewContext, + yjsExtension, + mockContentExtension + ] ) useOnImageUploadFromRenderer() @@ -102,8 +120,6 @@ export const EditorPane: React.FC = ({ scrollState, onScroll, onMak basicSetup={true} className={codeMirrorClassName} theme={oneDark} - value={markdownContent} - onChange={onBeforeChange} /> diff --git a/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss b/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss index d32d896a4..22db56292 100644 --- a/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss +++ b/src/components/editor-page/editor-pane/extended-codemirror/codemirror.module.scss @@ -1,5 +1,5 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) +/*! + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -15,4 +15,12 @@ font-variant-ligatures: none; } } + + :global { + .cm-widgetBuffer { + display: none; + } + + //workarounds for line break problem.. see https://github.com/yjs/y-codemirror.next/pull/12 + } } diff --git a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts index 71f5fd03f..c71c4239e 100644 --- a/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts +++ b/src/components/editor-page/editor-pane/hooks/use-apply-scroll-state.ts @@ -9,6 +9,9 @@ import type { ScrollState } from '../../synced-scroll/scroll-props' import { EditorView } from '@codemirror/view' import equal from 'fast-deep-equal' import { useCodeMirrorReference } from '../../change-content-context/change-content-context' +import { Logger } from '../../../../utils/logger' + +const logger = new Logger('useApplyScrollState') /** * Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}. @@ -16,12 +19,16 @@ import { useCodeMirrorReference } from '../../change-content-context/change-cont * @param view The {@link EditorView view} that should be scrolled * @param scrollState The {@link ScrollState scroll state} that should be applied */ -export const applyScrollState = (view: EditorView, scrollState: ScrollState): void => { - const line = view.state.doc.line(scrollState.firstLineInView) - const lineBlock = view.lineBlockAt(line.from) - const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100 - const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin }) - view.dispatch({ effects: [stateEffect] }) +const applyScrollState = (view: EditorView, scrollState: ScrollState): void => { + try { + const line = view.state.doc.line(scrollState.firstLineInView) + const lineBlock = view.lineBlockAt(line.from) + const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100 + const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin }) + view.dispatch({ effects: [stateEffect] }) + } catch (error) { + logger.error('Error while applying scroll status', error) + } } /** diff --git a/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts b/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts new file mode 100644 index 000000000..55e8e4408 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { YDocMessageTransporter } from '@hedgedoc/realtime' +import type { Doc } from 'yjs' +import type { Awareness } from 'y-protocols/awareness' + +/** + * A mocked connection that doesn't send or receive any data and is instantly ready. + */ +export class MockConnection extends YDocMessageTransporter { + constructor(doc: Doc, awareness: Awareness) { + super(doc, awareness) + this.onOpen() + this.emit('ready') + this.markAsSynced() + } + + disconnect(): void { + //Intentionally left empty because this is a mocked connection + } + + send(): void { + //Intentionally left empty because this is a mocked connection + } +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts new file mode 100644 index 000000000..25812ccc5 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Awareness } from 'y-protocols/awareness' +import { useEffect, useMemo } from 'react' +import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods' +import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types' +import { useApplicationState } from '../../../../../hooks/common/use-application-state' +import type { Doc } from 'yjs' +import { Logger } from '../../../../../utils/logger' + +const ownAwarenessClientId = -1 + +interface UserAwarenessState { + user: { + name: string + color: string + } +} + +// TODO: [mrdrogdrog] move this code to the server for the initial color setting. +const userColors = [ + { color: '#30bced', light: '#30bced33' }, + { color: '#6eeb83', light: '#6eeb8333' }, + { color: '#ffbc42', light: '#ffbc4233' }, + { color: '#ecd444', light: '#ecd44433' }, + { color: '#ee6352', light: '#ee635233' }, + { color: '#9ac2c9', light: '#9ac2c933' }, + { color: '#8acb88', light: '#8acb8833' }, + { color: '#1be7ff', light: '#1be7ff33' } +] + +const logger = new Logger('useAwareness') + +/** + * Creates an {@link Awareness awareness}, sets the own values (like name, color, etc.) for other clients and writes state changes into the global application state. + * + * @param yDoc The {@link Doc yjs document} that handles the communication. + * @return The created {@link Awareness awareness} + */ +export const useAwareness = (yDoc: Doc): Awareness => { + const ownUsername = useApplicationState((state) => state.user?.username) + const awareness = useMemo(() => new Awareness(yDoc), [yDoc]) + + useEffect(() => { + const userColor = userColors[Math.floor(Math.random() * 8)] + if (ownUsername !== undefined) { + awareness.setLocalStateField('user', { + name: ownUsername, + color: userColor.color, + colorLight: userColor.light + }) + addOnlineUser(ownAwarenessClientId, { + active: ActiveIndicatorStatus.ACTIVE, + color: userColor.color, + username: ownUsername + }) + } + + const awarenessCallback = ({ added, removed }: { added: number[]; removed: number[] }): void => { + added.forEach((addedId) => { + const state = awareness.getStates().get(addedId) as UserAwarenessState | undefined + if (!state) { + logger.debug('Could not find state for user') + return + } + logger.debug(`added awareness ${addedId}`, state.user) + addOnlineUser(addedId, { + active: ActiveIndicatorStatus.ACTIVE, + color: state.user.color, + username: state.user.name + }) + }) + removed.forEach((removedId) => { + logger.debug(`remove awareness ${removedId}`) + removeOnlineUser(removedId) + }) + } + awareness.on('change', awarenessCallback) + + return () => { + awareness.off('change', awarenessCallback) + removeOnlineUser(ownAwarenessClientId) + } + }, [awareness, ownUsername]) + return awareness +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts new file mode 100644 index 000000000..5fb22f3d0 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect } from 'react' +import { setNoteContent } from '../../../../../redux/note-details/methods' +import type { YText } from 'yjs/dist/src/types/YText' + +/** + * One-Way-synchronizes the text of the given {@link YText y-text} into the global application state. + *4 + * @param yText The source text + */ +export const useBindYTextToRedux = (yText: YText): void => { + useEffect(() => { + const yTextCallback = () => setNoteContent(yText.toString()) + yText.observe(yTextCallback) + return () => yText.unobserve(yTextCallback) + }, [yText]) +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts new file mode 100644 index 000000000..fe677c5de --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import type { Extension } from '@codemirror/state' +import { yCollab } from 'y-codemirror.next' +import type { Awareness } from 'y-protocols/awareness' +import type { YText } from 'yjs/dist/src/types/YText' + +/** + * Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}. + * + * @param yText The source and target for the editor content + * @param awareness Contains cursor positions and names from other clients that will be shown + * @return the created extension + */ +export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => { + return useMemo(() => yCollab(yText, awareness), [awareness, yText]) +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode.ts new file mode 100644 index 000000000..3eec0bef6 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useMemo, useState } from 'react' +import { isMockMode } from '../../../../../utils/test-modes' +import { getGlobalState } from '../../../../../redux' +import type { YText } from 'yjs/dist/src/types/YText' +import type { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +/** + * When in mock mode this hook inserts the current markdown content into the given yText to write it into the editor. + * This happens only one time because after that the editor writes it changes into the yText which writes it into the redux. + * + * Usually the CodeMirror gets its content from yjs sync via websocket. But in mock mode this connection isn't available. + * That's why this hook inserts the current markdown content, that is currently saved in the global application state + * and was saved there by the {@link NoteLoadingBoundary note loading boundary}, into the y-text to write it into the codemirror. + * This has to be done AFTER the CodeMirror sync extension (yCollab) has been loaded because the extension reacts only to updates of the yText + * and doesn't write the existing content into the editor when being loaded. + * + * @param yText The yText in which the content should be inserted + */ +export const useInsertInitialNoteContentIntoEditorInMockMode = (yText: YText): Extension | undefined => { + const [firstUpdateHappened, setFirstUpdateHappened] = useState(false) + + useEffect(() => { + if (firstUpdateHappened) { + yText.insert(0, getGlobalState().noteDetails.markdownContent.plain) + } + }, [firstUpdateHappened, yText]) + + return useMemo(() => { + return isMockMode && !firstUpdateHappened + ? EditorView.updateListener.of(() => setFirstUpdateHappened(true)) + : undefined + }, [firstUpdateHappened]) +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts new file mode 100644 index 000000000..cd85269b5 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { WebsocketConnection } from './websocket-connection' +import { useEffect, useMemo } from 'react' +import { useWebsocketUrl } from './use-websocket-url' +import type { Doc } from 'yjs' +import type { Awareness } from 'y-protocols/awareness' +import { isMockMode } from '../../../../../utils/test-modes' +import { MockConnection } from './mock-connection' +import type { YDocMessageTransporter } from '@hedgedoc/realtime' + +/** + * Creates a {@link WebsocketConnection websocket connection handler } that handles the realtime communication with the backend. + * + * @param yDoc The {@link Doc y-doc} that should be synchronized with the backend + * @param awareness The {@link Awareness awareness} that should be synchronized with the backend. + * @return the created connection handler + */ +export const useWebsocketConnection = (yDoc: Doc, awareness: Awareness): YDocMessageTransporter => { + const websocketUrl = useWebsocketUrl() + + const websocketConnection: YDocMessageTransporter = useMemo(() => { + return isMockMode ? new MockConnection(yDoc, awareness) : new WebsocketConnection(websocketUrl, yDoc, awareness) + }, [awareness, websocketUrl, yDoc]) + + useEffect(() => { + const disconnectCallback = () => websocketConnection.disconnect() + window.addEventListener('beforeunload', disconnectCallback) + return () => { + window.removeEventListener('beforeunload', disconnectCallback) + disconnectCallback() + } + }, [websocketConnection]) + + return websocketConnection +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts new file mode 100644 index 000000000..86b855afc --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import { backendUrl } from '../../../../../utils/backend-url' +import { isMockMode } from '../../../../../utils/test-modes' +import { useApplicationState } from '../../../../../hooks/common/use-application-state' + +const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/' + +/** + * Provides the URL for the realtime endpoint. + */ +export const useWebsocketUrl = (): URL => { + const noteId = useApplicationState((state) => state.noteDetails.id) + + const baseUrl = useMemo(() => { + if (isMockMode) { + return process.env.NEXT_PUBLIC_REALTIME_URL ?? LOCAL_FALLBACK_URL + } + try { + const backendBaseUrlParsed = new URL(backendUrl) + backendBaseUrlParsed.protocol = backendBaseUrlParsed.protocol === 'https:' ? 'wss:' : 'ws:' + backendBaseUrlParsed.pathname += 'realtime' + return backendBaseUrlParsed.toString() + } catch (e) { + console.error(e) + return LOCAL_FALLBACK_URL + } + }, []) + + return useMemo(() => { + const url = new URL(baseUrl) + url.search = `?noteId=${noteId}` + return url + }, [baseUrl, noteId]) +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts b/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts new file mode 100644 index 000000000..6dd6756c5 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Doc } from 'yjs' +import { useEffect, useMemo } from 'react' + +/** + * Creates a new {@link Doc y-doc}. + * + * @return The created {@link Doc y-doc} + */ +export const useYDoc = (): Doc => { + const yDoc = useMemo(() => new Doc(), []) + useEffect(() => () => yDoc.destroy(), [yDoc]) + return yDoc +} diff --git a/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts b/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts new file mode 100644 index 000000000..8961977e8 --- /dev/null +++ b/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + encodeAwarenessUpdateMessage, + encodeCompleteAwarenessStateRequestMessage, + encodeDocumentUpdateMessage +} from '@hedgedoc/realtime' +import { WebsocketTransporter } from '@hedgedoc/realtime' +import type { Doc } from 'yjs' +import type { Awareness } from 'y-protocols/awareness' + +/** + * Handles the communication with the realtime endpoint of the backend and synchronizes the given y-doc and awareness with other clients.. + */ +export class WebsocketConnection extends WebsocketTransporter { + constructor(url: URL, doc: Doc, awareness: Awareness) { + super(doc, awareness) + this.bindYDocEvents(doc) + this.bindAwarenessMessageEvents(awareness) + const websocket = new WebSocket(url) + this.setupWebsocket(websocket) + websocket.addEventListener('open', this.onOpen.bind(this)) + } + + private bindAwarenessMessageEvents(awareness: Awareness) { + const updateCallback = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown + ) => { + if (origin !== this) { + this.send(encodeAwarenessUpdateMessage(awareness, [...added, ...updated, ...removed])) + } + } + this.on('disconnected', () => { + awareness.destroy() + awareness.off('update', updateCallback) + }) + + this.on('ready', () => { + awareness.on('update', updateCallback) + }) + this.on('synced', () => { + this.send(encodeCompleteAwarenessStateRequestMessage()) + this.send(encodeAwarenessUpdateMessage(awareness, [awareness.doc.clientID])) + }) + } + + private bindYDocEvents(doc: Doc): void { + doc.on('destroy', () => this.disconnect()) + doc.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== this && this.isSynced()) { + this.send(encodeDocumentUpdateMessage(update)) + } + }) + } +} diff --git a/src/utils/backend-url.ts b/src/utils/backend-url.ts index 64c3aea48..376f38533 100644 --- a/src/utils/backend-url.ts +++ b/src/utils/backend-url.ts @@ -6,8 +6,25 @@ import { isMockMode } from './test-modes' -if (!isMockMode && process.env.NEXT_PUBLIC_BACKEND_BASE_URL === undefined) { - throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled') +/** + * Generates the backend URL from the environment variable `NEXT_PUBLIC_BACKEND_BASE_URL` or the mock default if mock mode is activated. + * + * @throws Error if the environment variable is unset or doesn't end with "/" + * @return the backend url that should be used in the app + */ +const generateBackendUrl = (): string => { + if (!isMockMode) { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_BASE_URL + if (backendUrl === undefined) { + throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled') + } else if (!backendUrl.endsWith('/')) { + throw new Error("NEXT_PUBLIC_BACKEND_BASE_URL must end with an '/'") + } else { + return backendUrl + } + } else { + return '/' + } } -export const backendUrl = isMockMode ? '/' : (process.env.NEXT_PUBLIC_BACKEND_BASE_URL as string) +export const backendUrl = generateBackendUrl() diff --git a/yarn.lock b/yarn.lock index db8f3563e..d25f11cf6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1739,7 +1739,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/state@npm:^6.0.0": +"@codemirror/state@npm:6.0.0, @codemirror/state@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/state@npm:6.0.0" checksum: 7f6286d8e8b8c5e7018f9ee81943b35324150fd15ccc77ae220b904a73e10fa480ab51a89663d956a9c2fae70b13d754da0bf535092759158cc3707743aa236f @@ -1758,7 +1758,7 @@ __metadata: languageName: node linkType: hard -"@codemirror/view@npm:^6.0.0": +"@codemirror/view@npm:6.0.0, @codemirror/view@npm:^6.0.0": version: 6.0.0 resolution: "@codemirror/view@npm:6.0.0" dependencies: @@ -1984,11 +1984,14 @@ __metadata: dependencies: "@codemirror/lang-markdown": 6.0.0 "@codemirror/language-data": 6.1.0 + "@codemirror/state": 6.0.0 "@codemirror/theme-one-dark": 6.0.0 + "@codemirror/view": 6.0.0 "@fontsource/source-sans-pro": 4.5.10 "@hedgedoc/html-to-react": 1.4.1 "@hedgedoc/markdown-it-image-size": 1.0.3 "@hedgedoc/markdown-it-task-lists": 1.0.3 + "@hedgedoc/realtime": 0.0.3 "@matejmazur/react-katex": 3.1.3 "@next/bundle-analyzer": 12.1.6 "@react-hook/resize-observer": 1.2.5 @@ -2101,9 +2104,24 @@ __metadata: vega-embed: 6.21.0 vega-lite: 5.2.0 words-count: 2.0.2 + y-codemirror.next: 0.3.0 + y-protocols: 1.0.5 languageName: unknown linkType: soft +"@hedgedoc/realtime@npm:0.0.3": + version: 0.0.3 + resolution: "@hedgedoc/realtime@npm:0.0.3" + dependencies: + isomorphic-ws: 4.0.1 + lib0: 0.2.51 + typed-emitter: 2.1.0 + y-protocols: 1.0.5 + yjs: 13.5.38 + checksum: cfb1dc7459b1699841a9ed29147ffe5397206b9b87bb5410d6c4000d1a3a4db6f4d7862930bbff8a0b1aaf7ba8bdcac8904ed0fd626df684c486121afab6f089 + languageName: node + linkType: hard + "@hpcc-js/wasm@npm:1.12.8": version: 1.12.8 resolution: "@hpcc-js/wasm@npm:1.12.8" @@ -12954,6 +12972,22 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:4.0.1": + version: 4.0.1 + resolution: "isomorphic-ws@npm:4.0.1" + peerDependencies: + ws: "*" + checksum: d7190eadefdc28bdb93d67b5f0c603385aaf87724fa2974abb382ac1ec9756ed2cfb27065cbe76122879c2d452e2982bc4314317f3d6c737ddda6c047328771a + languageName: node + linkType: hard + +"isomorphic.js@npm:^0.2.4": + version: 0.2.5 + resolution: "isomorphic.js@npm:0.2.5" + checksum: d8d1b083f05f3c337a06628b982ac3ce6db953bbef14a9de8ad49131250c3592f864b73c12030fdc9ef138ce97b76ef55c7d96a849561ac215b1b4b9d301c8e9 + languageName: node + linkType: hard + "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -14024,6 +14058,15 @@ __metadata: languageName: node linkType: hard +"lib0@npm:0.2.51, lib0@npm:^0.2.42, lib0@npm:^0.2.49": + version: 0.2.51 + resolution: "lib0@npm:0.2.51" + dependencies: + isomorphic.js: ^0.2.4 + checksum: bdd00ba42b66d27d048fc169e7d472b9dfe9140e067daeb92db82f40209365d9399aaed679078cc440c496c43d429427b0e231dbaaf171793d98ea6f5476aa3a + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -18028,6 +18071,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:*, rxjs@npm:^7.5.1": + version: 7.5.5 + resolution: "rxjs@npm:7.5.5" + dependencies: + tslib: ^2.1.0 + checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6 + languageName: node + linkType: hard + "rxjs@npm:^6.3.3, rxjs@npm:^6.4.0, rxjs@npm:^6.6.2": version: 6.6.7 resolution: "rxjs@npm:6.6.7" @@ -18037,15 +18089,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.5.1": - version: 7.5.5 - resolution: "rxjs@npm:7.5.5" - dependencies: - tslib: ^2.1.0 - checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6 - languageName: node - linkType: hard - "safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -19906,6 +19949,18 @@ __metadata: languageName: node linkType: hard +"typed-emitter@npm:2.1.0": + version: 2.1.0 + resolution: "typed-emitter@npm:2.1.0" + dependencies: + rxjs: "*" + dependenciesMeta: + rxjs: + optional: true + checksum: 95821a9e05784b972cc9d152891fd12a56cb4b1a7c57e768c02bea6a8984da7aff8f19404a7b69eea11fae2a3b6c0c510a4c510f575f50162c759ae9059f2520 + languageName: node + linkType: hard + "typed-styles@npm:^0.0.7": version: 0.0.7 resolution: "typed-styles@npm:0.0.7" @@ -21149,6 +21204,28 @@ __metadata: languageName: node linkType: hard +"y-codemirror.next@npm:0.3.0": + version: 0.3.0 + resolution: "y-codemirror.next@npm:0.3.0" + dependencies: + lib0: ^0.2.42 + peerDependencies: + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + yjs: ^13.5.6 + checksum: f9aa8fade4cfa632df83e0eb87d736b8c3c5bbbab835affaaaf9c0a98c115329332586d58d5a8279c5502a19cbad77b83ca22f74f5d5dd7ccc4ce8bca85f6885 + languageName: node + linkType: hard + +"y-protocols@npm:1.0.5": + version: 1.0.5 + resolution: "y-protocols@npm:1.0.5" + dependencies: + lib0: ^0.2.42 + checksum: d19404a4ebafcf3761c28b881abe8c32ab6e457db0e5ffc7dbb749cbc2c3bb98e003a43f3e8eba7f245b2698c76f2c4cdd1c2db869f8ec0c6ef94736d9a88652 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -21246,6 +21323,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:13.5.38": + version: 13.5.38 + resolution: "yjs@npm:13.5.38" + dependencies: + lib0: ^0.2.49 + checksum: 48be9cfe514b5f9f7c64cddf38f7fd59142fe56553200ba88543c6c2c5df6a6be6876440d40f8b1a0f34e5a92ba8efe6b8da5da657278393ceb5171c4fc3a4e0 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"