Add basic realtime communication (#2118)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-06-18 18:40:28 +02:00 committed by GitHub
parent 3b86afc17c
commit 0da51bba67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 624 additions and 53 deletions

5
docker/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
#
# SPDX-License-Identifier: CC0-1.0
caddy

16
docker/Caddyfile Normal file
View file

@ -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

13
docker/README.md Normal file
View file

@ -0,0 +1,13 @@
<!--
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC-BY-SA-4.0
-->
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.

10
docker/download-caddy.sh Executable file
View file

@ -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

View file

@ -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.",

View file

@ -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')

View file

@ -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",

View file

@ -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<PropsWithChildren<unknown>> = ({ 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)

View file

@ -27,38 +27,38 @@ const customDelay: () => Promise<void> = async () => {
export interface InitTask {
name: string
task: Promise<void>
task: () => Promise<void>
}
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
}
]
}

View file

@ -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<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const markdownContent = useNoteMarkdownContent()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
useApplyScrollState(scrollState)
@ -42,10 +44,6 @@ export const EditorPane: React.FC<ScrollProps> = ({ 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<ScrollProps> = ({ 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<ScrollProps> = ({ 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<ScrollProps> = ({ scrollState, onScroll, onMak
basicSetup={true}
className={codeMirrorClassName}
theme={oneDark}
value={markdownContent}
onChange={onBeforeChange}
/>
<StatusBar />
</div>

View file

@ -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
}
}

View file

@ -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)
}
}
/**

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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])
}

View file

@ -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])
}

View file

@ -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<boolean>(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])
}

View file

@ -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
}

View file

@ -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])
}

View file

@ -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
}

View file

@ -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))
}
})
}
}

View file

@ -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()

108
yarn.lock
View file

@ -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"