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

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