refactor: reimplement realtime-communication

This commit refactors a lot of things that are not easy to separate.
It replaces the binary protocol of y-protocols with json.
It introduces event based message processing.
It implements our own code mirror plugins for synchronisation of content and remote cursors

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-03-22 20:21:40 +01:00
parent 67cf1432b2
commit 3a06f84af1
110 changed files with 3920 additions and 2201 deletions

View file

@ -20,6 +20,7 @@ import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-ent
import { Sidebar } from './sidebar/sidebar'
import { Splitter } from './splitter/splitter'
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
import { RealtimeConnectionModal } from './websocket-connection-modal/realtime-connection-modal'
import equal from 'fast-deep-equal'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -79,7 +80,6 @@ export const EditorPageContent: React.FC = () => {
)
useApplyDarkMode()
useUpdateLocalHistoryEntry()
const setRendererToScrollSource = useCallback(() => {
@ -129,6 +129,7 @@ export const EditorPageContent: React.FC = () => {
<CommunicatorImageLightbox />
<HeadMetaProperties />
<MotdModal />
<RealtimeConnectionModal />
<div className={'d-flex flex-column vh-100'}>
<AppBar mode={AppBarMode.EDITOR} />
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeSpec, Transaction } from '@codemirror/state'
import { Annotation } from '@codemirror/state'
import type { EditorView, PluginValue } from '@codemirror/view'
import type { ViewUpdate } from '@codemirror/view'
import type { Text as YText } from 'yjs'
import type { Transaction as YTransaction, YTextEvent } from 'yjs'
const syncAnnotation = Annotation.define()
/**
* Synchronizes the content of a codemirror with a {@link YText y.js text channel}.
*/
export class YTextSyncViewPlugin implements PluginValue {
private readonly observer: YTextSyncViewPlugin['onYTextUpdate']
private firstUpdate = true
constructor(private view: EditorView, private readonly yText: YText, pluginLoaded: () => void) {
this.observer = this.onYTextUpdate.bind(this)
this.yText.observe(this.observer)
pluginLoaded()
}
private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void {
if (transaction.origin === this) {
return
}
this.view.dispatch({ changes: this.calculateChanges(event), annotations: [syncAnnotation.of(this)] })
}
private calculateChanges(event: YTextEvent): ChangeSpec[] {
const [changes] = event.delta.reduce(
([changes, position], delta) => {
if (delta.insert !== undefined && typeof delta.insert === 'string') {
return [[...changes, { from: position, to: position, insert: delta.insert }], position]
} else if (delta.delete !== undefined) {
return [[...changes, { from: position, to: position + delta.delete, insert: '' }], position + delta.delete]
} else if (delta.retain !== undefined) {
return [changes, position + delta.retain]
} else {
return [changes, position]
}
},
[[], 0] as [ChangeSpec[], number]
)
return this.addDeleteAllChanges(changes)
}
private addDeleteAllChanges(changes: ChangeSpec[]): ChangeSpec[] {
if (this.firstUpdate) {
this.firstUpdate = false
return [{ from: 0, to: this.view.state.doc.length, insert: '' }, ...changes]
} else {
return changes
}
}
public update(update: ViewUpdate): void {
if (!update.docChanged) {
return
}
update.transactions
.filter((transaction) => transaction.annotation(syncAnnotation) !== this)
.forEach((transaction) => this.applyTransaction(transaction))
}
private applyTransaction(transaction: Transaction): void {
this.yText.doc?.transact(() => {
let positionAdjustment = 0
transaction.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
const insertText = insert.sliceString(0, insert.length, '\n')
if (fromA !== toA) {
this.yText.delete(fromA + positionAdjustment, toA - fromA)
}
if (insertText.length > 0) {
this.yText.insert(fromA + positionAdjustment, insertText)
}
positionAdjustment += insertText.length - (toA - fromA)
})
}, this)
}
public destroy(): void {
this.yText.unobserve(this.observer)
}
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import styles from './cursor-colors.module.scss'
export const createCursorCssClass = (styleIndex: number): string => {
return styles[`cursor-${Math.max(Math.min(styleIndex, 7), 0)}`]
}

View file

@ -0,0 +1,37 @@
/*!
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.cursor-0 {
--color: #780c0c;
}
.cursor-1 {
--color: #ff1111;
}
.cursor-2 {
--color: #1149ff;
}
.cursor-3 {
--color: #11ff39;
}
.cursor-4 {
--color: #cb11ff;
}
.cursor-5 {
--color: #ffff00;
}
.cursor-6 {
--color: #00fff2;
}
.cursor-7 {
--color: #ff8000;
}

View file

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createCursorCssClass } from './create-cursor-css-class'
import { RemoteCursorMarker } from './remote-cursor-marker'
import styles from './style.module.scss'
import type { Extension, Transaction } from '@codemirror/state'
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import type { ViewUpdate } from '@codemirror/view'
import { layer, RectangleMarker } from '@codemirror/view'
import { Optional } from '@mrdrogdrog/optional'
import equal from 'fast-deep-equal'
export interface RemoteCursor {
displayName: string
from: number
to?: number
styleIndex: number
}
/**
* Used to provide a new set of {@link RemoteCursor remote cursors} to a codemirror state.
*/
export const remoteCursorUpdateEffect = StateEffect.define<RemoteCursor[]>()
/**
* Saves the currently visible {@link RemoteCursor remote cursors}
* and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched.
*/
export const remoteCursorStateField = StateField.define<RemoteCursor[]>({
compare(a: RemoteCursor[], b: RemoteCursor[]): boolean {
return equal(a, b)
},
create(): RemoteCursor[] {
return []
},
update(currentValue: RemoteCursor[], transaction: Transaction): RemoteCursor[] {
return Optional.ofNullable(transaction.effects.find((effect) => effect.is(remoteCursorUpdateEffect)))
.map((remoteCursor) => remoteCursor.value as RemoteCursor[])
.orElse(currentValue)
}
})
/**
* Checks if the given {@link ViewUpdate view update} should trigger a rerender of remote cursor components.
* @param update The update to check
*/
const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
const effect = update.transactions
.flatMap((transaction) => transaction.effects)
.filter((effect) => effect.is(remoteCursorUpdateEffect))
return update.docChanged || update.viewportChanged || effect.length > 0
}
/**
* Creates the codemirror extension that renders the remote cursor selection layer.
* @return The created codemirror extension
*/
export const createCursorLayer = (): Extension =>
layer({
above: true,
class: styles.cursorLayer,
update: isRemoteCursorUpdate,
markers: (view) => {
return view.state.field(remoteCursorStateField).flatMap((remoteCursor) => {
const selectionRange = EditorSelection.cursor(remoteCursor.from)
return RemoteCursorMarker.createCursor(view, selectionRange, remoteCursor.displayName, remoteCursor.styleIndex)
})
}
})
/**
* Creates the codemirror extension that renders the blinking remote cursor layer.
* @return The created codemirror extension
*/
export const createSelectionLayer = (): Extension =>
layer({
above: false,
class: styles.selectionLayer,
update: isRemoteCursorUpdate,
markers: (view) => {
return view.state
.field(remoteCursorStateField)
.filter((remoteCursor) => remoteCursor.to !== undefined && remoteCursor.from !== remoteCursor.to)
.flatMap((remoteCursor) => {
const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
return RectangleMarker.forRange(
view,
`${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`,
selectionRange
)
})
}
})

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RemoteCursor } from './cursor-layers-extensions'
import { remoteCursorUpdateEffect } from './cursor-layers-extensions'
import type { EditorView, PluginValue } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
/**
* Listens for remote cursor state messages from the backend and dispatches them into the codemirror.
*/
export class ReceiveRemoteCursorViewPlugin implements PluginValue {
private readonly listener: Listener
constructor(view: EditorView, messageTransporter: MessageTransporter) {
this.listener = messageTransporter.on(
MessageType.REALTIME_USER_STATE_SET,
({ payload }) => {
const cursors: RemoteCursor[] = payload.map((user) => ({
from: user.cursor.from,
to: user.cursor.to,
displayName: user.displayName,
styleIndex: user.styleIndex
}))
view.dispatch({
effects: [remoteCursorUpdateEffect.of(cursors)]
})
},
{ objectify: true }
) as Listener
}
destroy() {
this.listener.off()
}
}

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createCursorCssClass } from './create-cursor-css-class'
import styles from './style.module.scss'
import type { SelectionRange } from '@codemirror/state'
import type { LayerMarker, EditorView, Rect } from '@codemirror/view'
import { Direction } from '@codemirror/view'
/**
* Renders a blinking cursor to indicate the cursor of another user.
*/
export class RemoteCursorMarker implements LayerMarker {
constructor(
private left: number,
private top: number,
private height: number,
private name: string,
private styleIndex: number
) {}
draw(): HTMLElement {
const elt = document.createElement('div')
this.adjust(elt)
return elt
}
update(elt: HTMLElement): boolean {
this.adjust(elt)
return true
}
adjust(element: HTMLElement) {
element.style.left = `${this.left}px`
element.style.top = `${this.top}px`
element.style.height = `${this.height}px`
element.style.setProperty('--name', `"${this.name}"`)
element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)}`
}
eq(other: RemoteCursorMarker): boolean {
return (
this.left === other.left && this.top === other.top && this.height === other.height && this.name === other.name
)
}
public static createCursor(
view: EditorView,
position: SelectionRange,
displayName: string,
styleIndex: number
): RemoteCursorMarker[] {
const absolutePosition = this.calculateAbsoluteCursorPosition(position, view)
if (!absolutePosition || styleIndex < 0) {
return []
}
const rect = view.scrollDOM.getBoundingClientRect()
const left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth
const baseLeft = left - view.scrollDOM.scrollLeft
const baseTop = rect.top - view.scrollDOM.scrollTop
return [
new RemoteCursorMarker(
absolutePosition.left - baseLeft,
absolutePosition.top - baseTop,
absolutePosition.bottom - absolutePosition.top,
displayName,
styleIndex
)
]
}
private static calculateAbsoluteCursorPosition(position: SelectionRange, view: EditorView): Rect | null {
const cappedPositionHead = Math.max(0, Math.min(view.state.doc.length, position.head))
return view.coordsAtPos(cappedPositionHead, position.assoc || 1)
}
}

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { SelectionRange } from '@codemirror/state'
import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
/**
* Sends the main cursor of a codemirror to the backend using a given {@link MessageTransporter}.
*/
export class SendCursorViewPlugin implements PluginValue {
private lastCursor: SelectionRange | undefined
private listener: Listener
constructor(private view: EditorView, private messageTransporter: MessageTransporter) {
this.listener = messageTransporter.doAsSoonAsReady(() => {
this.sendCursor(this.lastCursor)
})
}
destroy() {
this.listener.off()
}
update(update: ViewUpdate) {
if (!update.selectionSet && !update.focusChanged && !update.docChanged) {
return
}
this.sendCursor(update.state.selection.main)
}
private sendCursor(currentCursor: SelectionRange | undefined) {
if (
!this.messageTransporter.isReady() ||
currentCursor === undefined ||
(this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from)
) {
return
}
this.lastCursor = currentCursor
this.messageTransporter.sendMessage({
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
payload: {
from: currentCursor.from ?? 0,
to: currentCursor?.to
}
})
}
}

View file

@ -0,0 +1,40 @@
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.cursorLayer {
--color: #868686;
.cursor {
border-left: 2px solid var(--color);
box-sizing: content-box;
&:hover {
&:before {
opacity: 1
}
}
&:before {
content: var(--name);
font-size: 0.8em;
background: var(--color);
position: absolute;
top: -1.2em;
right: 2px;
color: white;
padding: 2px 5px;
height: 20px;
opacity: 0;
transition: opacity 0.1s;
white-space: nowrap;
}
}
}
.selectionLayer {
--color: #868686;
.cursor {
background-color: var(--color);
opacity: 0.5;
}
}

View file

@ -4,31 +4,30 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useBaseUrl, ORIGIN } from '../../../hooks/common/use-base-url'
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
import type { ScrollProps } from '../synced-scroll/scroll-props'
import styles from './extended-codemirror/codemirror.module.scss'
import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension'
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
import { useCodeMirrorSpellCheckExtension } from './hooks/code-mirror-extensions/use-code-mirror-spell-check-extension'
import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension'
import { useCodeMirrorRemoteCursorsExtension } from './hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions'
import { useCodeMirrorScrollWatchExtension } from './hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension'
import { useCodeMirrorSpellCheckExtension } from './hooks/codemirror-extensions/use-code-mirror-spell-check-extension'
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference'
import { useAwareness } from './hooks/yjs/use-awareness'
import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated'
import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection'
import { useRealtimeConnection } from './hooks/yjs/use-realtime-connection'
import { useReceiveRealtimeUsers } from './hooks/yjs/use-receive-realtime-users'
import { useYDoc } from './hooks/yjs/use-y-doc'
import { useYDocSyncClientAdapter } from './hooks/yjs/use-y-doc-sync-client-adapter'
import { useLinter } from './linter/linter'
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
import { StatusBar } from './status-bar/status-bar'
@ -40,9 +39,11 @@ import { lintGutter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import ReactCodeMirror from '@uiw/react-codemirror'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export type EditorPaneProps = ScrollProps
/**
* Renders the text editor pane of the editor.
* The used editor is {@link ReactCodeMirror code mirror}.
@ -52,41 +53,41 @@ import { useTranslation } from 'react-i18next'
* @param onMakeScrollSource The callback to request to become the scroll source.
* @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror
*/
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
useApplyScrollState(scrollState)
const messageTransporter = useRealtimeConnection()
const yDoc = useYDoc(messageTransporter)
const yText = useMarkdownContentYText(yDoc)
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
const tablePasteExtensions = useCodeMirrorTablePasteExtension()
const fileInsertExtension = useCodeMirrorFileInsertExtension()
const spellCheckExtension = useCodeMirrorSpellCheckExtension()
const cursorActivityExtension = useCursorActivityCallback()
const updateViewContextExtension = useUpdateCodeMirrorReference()
const yDoc = useYDoc()
const awareness = useAwareness(yDoc)
const yText = useMarkdownContentYText(yDoc)
const websocketConnection = useWebsocketConnection(yDoc, awareness)
const connectionSynced = useIsConnectionSynced(websocketConnection)
useBindYTextToRedux(yText)
useOnMetadataUpdated(websocketConnection)
useOnNoteDeleted(websocketConnection)
const remoteCursorsExtension = useCodeMirrorRemoteCursorsExtension(messageTransporter)
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection)
const linter = useLinter()
const linterExtension = useLinter()
const syncAdapter = useYDocSyncClientAdapter(messageTransporter, yDoc)
const yjsExtension = useCodeMirrorYjsExtension(yText, syncAdapter)
useOnMetadataUpdated(messageTransporter)
useOnNoteDeleted(messageTransporter)
useBindYTextToRedux(yText)
useReceiveRealtimeUsers(messageTransporter)
const extensions = useMemo(
() => [
linter,
linterExtension,
lintGutter(),
markdown({
base: markdownLanguage,
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
}),
remoteCursorsExtension,
EditorView.lineWrapping,
editorScrollExtension,
tablePasteExtensions,
@ -95,34 +96,40 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
cursorActivityExtension,
updateViewContextExtension,
yjsExtension,
firstEditorUpdateExtension,
spellCheckExtension
],
[
linter,
linterExtension,
remoteCursorsExtension,
editorScrollExtension,
tablePasteExtensions,
fileInsertExtension,
cursorActivityExtension,
updateViewContextExtension,
yjsExtension,
firstEditorUpdateExtension,
spellCheckExtension
]
)
useOnImageUploadFromRenderer()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const codeMirrorClassName = useMemo(
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
[ligaturesEnabled]
)
const { t } = useTranslation()
const darkModeActivated = useDarkModeState()
const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
useEffect(() => {
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
return () => {
listener.off()
}
}, [messageTransporter])
return (
<div
@ -130,11 +137,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
onTouchStart={onMakeScrollSource}
onMouseEnter={onMakeScrollSource}
{...cypressId('editor-pane')}
{...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}>
{...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
<MaxLengthWarning />
<ToolBar />
<ReactCodeMirror
editable={firstUpdateHappened && connectionSynced}
editable={updateViewContextExtension !== null && isSynced}
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
extensions={extensions}
width={'100%'}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
createCursorLayer,
createSelectionLayer,
remoteCursorStateField
} from '../../codemirror-extensions/remote-cursors/cursor-layers-extensions'
import { ReceiveRemoteCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin'
import { SendCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/send-cursor-view-plugin'
import type { Extension } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { useMemo } from 'react'
/**
* Bundles all extensions that are needed for the remote cursor display.
* @return The created codemirror extensions
*/
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
useMemo(
() => [
remoteCursorStateField.extension,
createCursorLayer(),
createSelectionLayer(),
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
],
[messageTransporter]
)

View file

@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MARKDOWN_CONTENT_CHANNEL_NAME, YDocMessageTransporter } from '@hedgedoc/commons'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* 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')
}
/**
* Simulates a complete sync from the server by inserting the given content at position 0 of the editor yText channel.
*
* @param content The content to insert
*/
public simulateFirstSync(content: string): void {
const yText = this.doc.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
yText.insert(0, content)
super.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

@ -1,89 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods'
import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types'
import { Logger } from '../../../../../utils/logger'
import { useEffect, useMemo } from 'react'
import { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
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

@ -12,8 +12,11 @@ import type { YText } from 'yjs/dist/src/types/YText'
*
* @param yText The source text
*/
export const useBindYTextToRedux = (yText: YText): void => {
export const useBindYTextToRedux = (yText: YText | undefined): void => {
useEffect(() => {
if (!yText) {
return
}
const yTextCallback = () => setNoteContent(yText.toString())
yText.observe(yTextCallback)
return () => yText.unobserve(yTextCallback)

View file

@ -3,19 +3,35 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin'
import type { Extension } from '@codemirror/state'
import { useMemo } from 'react'
import { yCollab } from 'y-codemirror.next'
import type { Awareness } from 'y-protocols/awareness'
import type { YText } from 'yjs/dist/src/types/YText'
import { ViewPlugin } from '@codemirror/view'
import type { YDocSyncClientAdapter } from '@hedgedoc/commons'
import { useEffect, useMemo, useState } from 'react'
import type { Text as YText } from 'yjs'
/**
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}.
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext}.
*
* @param yText The source and target for the editor content
* @param awareness Contains cursor positions and names from other clients that will be shown
* @param syncAdapter The sync adapter that processes the communication for content synchronisation.
* @return the created extension
*/
export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => {
return useMemo(() => yCollab(yText, awareness), [awareness, yText])
export const useCodeMirrorYjsExtension = (yText: YText | undefined, syncAdapter: YDocSyncClientAdapter): Extension => {
const [editorReady, setEditorReady] = useState(false)
const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced)
const connected = useApplicationState((state) => state.realtimeStatus.isConnected)
useEffect(() => {
if (editorReady && connected && !synchronized && yText) {
syncAdapter.requestDocumentState()
}
}, [connected, editorReady, syncAdapter, synchronized, yText])
return useMemo(
() =>
yText ? [ViewPlugin.define((view) => new YTextSyncViewPlugin(view, yText, () => setEditorReady(true)))] : [],
[yText]
)
}

View file

@ -1,34 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getGlobalState } from '../../../../../redux'
import { isMockMode } from '../../../../../utils/test-modes'
import { MockConnection } from './mock-connection'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect } from 'react'
/**
* When in mock mode this effect inserts the current markdown content into the yDoc of the given connection to simulate a sync from the server.
* This should happen only one time because after that the editor writes its 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 connection The connection into whose yDoc the content should be written
* @param firstUpdateHappened Defines if the first update already happened
*/
export const useInsertNoteContentIntoYTextInMockModeEffect = (
firstUpdateHappened: boolean,
connection: YDocMessageTransporter
): void => {
useEffect(() => {
if (firstUpdateHappened && isMockMode && connection instanceof MockConnection) {
connection.simulateFirstSync(getGlobalState().noteDetails.markdownContent.plain)
}
}, [firstUpdateHappened, connection])
}

View file

@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect, useState } from 'react'
/**
* Checks if the given message transporter has received at least one full synchronisation.
*
* @param connection The connection whose sync status should be checked
* @return If at least one full synchronisation is occurred.
*/
export const useIsConnectionSynced = (connection: YDocMessageTransporter): boolean => {
const [editorEnabled, setEditorEnabled] = useState<boolean>(false)
useEffect(() => {
const enableEditor = () => setEditorEnabled(true)
const disableEditor = () => setEditorEnabled(false)
connection.on('synced', enableEditor)
connection.on('disconnected', disableEditor)
return () => {
connection.off('synced', enableEditor)
connection.off('disconnected', disableEditor)
}
}, [connection])
return editorEnabled
}

View file

@ -3,9 +3,8 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MARKDOWN_CONTENT_CHANNEL_NAME } from '@hedgedoc/commons'
import type { RealtimeDoc } from '@hedgedoc/commons'
import { useMemo } from 'react'
import type { Doc } from 'yjs'
import type { Text as YText } from 'yjs'
/**
@ -14,6 +13,6 @@ import type { Text as YText } from 'yjs'
* @param yDoc The yjs document from which the yText should be extracted
* @return the extracted yText channel
*/
export const useMarkdownContentYText = (yDoc: Doc): YText => {
return useMemo(() => yDoc.getText(MARKDOWN_CONTENT_CHANNEL_NAME), [yDoc])
export const useMarkdownContentYText = (yDoc: RealtimeDoc | undefined): YText | undefined => {
return useMemo(() => yDoc?.getMarkdownContentChannel(), [yDoc])
}

View file

@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { useMemo, useState } from 'react'
/**
* Provides an extension that checks when the code mirror, that loads the extension, has its first update.
*
* @return The extension that listens for editor updates and a boolean that defines if the first update already happened
*/
export const useOnFirstEditorUpdateExtension = (): [Extension, boolean] => {
const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), [])
return [extension, firstUpdateHappened]
}

View file

@ -4,24 +4,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { updateMetadata } from '../../../../../redux/note-details/methods'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import { useCallback, useEffect } from 'react'
import type { Listener } from 'eventemitter2'
import { useEffect } from 'react'
/**
* Hook that updates the metadata if the server announced an update of the metadata.
*
* @param websocketConnection The websocket connection that emits the metadata changed event
*/
export const useOnMetadataUpdated = (websocketConnection: YDocMessageTransporter): void => {
const updateMetadataHandler = useCallback(async () => {
await updateMetadata()
}, [])
export const useOnMetadataUpdated = (websocketConnection: MessageTransporter): void => {
useEffect(() => {
websocketConnection.on(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
const listener = websocketConnection.on(MessageType.METADATA_UPDATED, () => void updateMetadata(), {
objectify: true
}) as Listener
return () => {
websocketConnection.off(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
listener.off()
}
})
}, [websocketConnection])
}

View file

@ -6,8 +6,9 @@
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { Logger } from '../../../../../utils/logger'
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
@ -18,7 +19,7 @@ const logger = new Logger('UseOnNoteDeleted')
*
* @param websocketConnection The websocket connection that emits the deletion event
*/
export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): void => {
export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void => {
const router = useRouter()
const noteTitle = useApplicationState((state) => state.noteDetails.title)
const { dispatchUiNotification } = useUiNotifications()
@ -35,9 +36,11 @@ export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): v
}, [router, noteTitle, dispatchUiNotification])
useEffect(() => {
websocketConnection.on(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
const listener = websocketConnection.on(MessageType.DOCUMENT_DELETED, noteDeletedHandler, {
objectify: true
}) as Listener
return () => {
websocketConnection.off(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
listener.off()
}
})
}, [noteDeletedHandler, websocketConnection])
}

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { getGlobalState } from '../../../../../redux'
import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
import { Logger } from '../../../../../utils/logger'
import { isMockMode } from '../../../../../utils/test-modes'
import { useWebsocketUrl } from './use-websocket-url'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import WebSocket from 'isomorphic-ws'
import { useCallback, useEffect, useMemo, useRef } from 'react'
const logger = new Logger('websocket connection')
const WEBSOCKET_RECONNECT_INTERVAL = 3000
/**
* Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend.
*
* @return the created connection handler
*/
export const useRealtimeConnection = (): MessageTransporter => {
const websocketUrl = useWebsocketUrl()
const messageTransporter = useMemo(() => {
if (isMockMode) {
logger.debug('Creating Loopback connection...')
return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
} else {
logger.debug('Creating Websocket connection...')
return new WebsocketTransporter()
}
}, [])
const establishWebsocketConnection = useCallback(() => {
if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
logger.debug(`Connecting to ${websocketUrl.toString()}`)
const socket = new WebSocket(websocketUrl)
socket.addEventListener('error', () => {
setTimeout(() => {
establishWebsocketConnection()
}, WEBSOCKET_RECONNECT_INTERVAL)
})
socket.addEventListener('open', () => {
messageTransporter.setWebsocket(socket)
})
}
}, [messageTransporter, websocketUrl])
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
const firstConnect = useRef(true)
const reconnectTimeout = useRef<number | undefined>(undefined)
useEffect(() => {
if (isConnected) {
return
}
if (firstConnect.current) {
establishWebsocketConnection()
firstConnect.current = false
} else {
reconnectTimeout.current = window.setTimeout(() => {
establishWebsocketConnection()
}, WEBSOCKET_RECONNECT_INTERVAL)
}
}, [establishWebsocketConnection, isConnected, messageTransporter])
useEffect(() => {
const readyListener = messageTransporter.doAsSoonAsReady(() => {
const timerId = reconnectTimeout.current
if (timerId !== undefined) {
window.clearTimeout(timerId)
}
reconnectTimeout.current = undefined
})
messageTransporter.on('connected', () => logger.debug(`Connected`))
messageTransporter.on('disconnected', () => logger.debug(`Disconnected`))
return () => {
const interval = reconnectTimeout.current
interval && window.clearTimeout(interval)
readyListener.off()
}
}, [messageTransporter])
useEffect(() => {
const disconnectCallback = () => messageTransporter.disconnect()
window.addEventListener('beforeunload', disconnectCallback)
return () => window.removeEventListener('beforeunload', disconnectCallback)
}, [messageTransporter])
useEffect(() => {
const connectedListener = messageTransporter.doAsSoonAsConnected(() => setRealtimeConnectionState(true))
const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), {
objectify: true
}) as Listener
return () => {
connectedListener.off()
disconnectedListener.off()
}
}, [messageTransporter])
return messageTransporter
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useEffect } from 'react'
/**
* Waits for remote cursor updates that are sent from the backend and saves them in the global application state.
*
* @param messageTransporter the {@link MessageTransporter} that should be used to receive the remote cursor updates
*/
export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter): void => {
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
useEffect(() => {
const listener = messageTransporter.on(
MessageType.REALTIME_USER_STATE_SET,
(payload) => setRealtimeUsers(payload.payload),
{ objectify: true }
) as Listener
return () => {
listener.off()
}
}, [messageTransporter])
useEffect(() => {
if (isConnected) {
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
}
}, [isConnected, messageTransporter])
}

View file

@ -1,39 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isMockMode } from '../../../../../utils/test-modes'
import { MockConnection } from './mock-connection'
import { useWebsocketUrl } from './use-websocket-url'
import { WebsocketConnection } from './websocket-connection'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect, useMemo } from 'react'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* 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

@ -13,7 +13,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
/**
* Provides the URL for the realtime endpoint.
*/
export const useWebsocketUrl = (): URL => {
export const useWebsocketUrl = (): URL | undefined => {
const noteId = useApplicationState((state) => state.noteDetails.id)
const baseUrl = useBaseUrl()
@ -33,6 +33,9 @@ export const useWebsocketUrl = (): URL => {
}, [baseUrl])
return useMemo(() => {
if (noteId === '') {
return
}
const url = new URL(websocketUrl)
url.search = `?noteId=${noteId}`
return url

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setRealtimeSyncedState } from '../../../../../redux/realtime/methods'
import { Logger } from '../../../../../utils/logger'
import type { MessageTransporter } from '@hedgedoc/commons'
import { YDocSyncClientAdapter } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useEffect, useMemo } from 'react'
import type { Doc } from 'yjs'
const logger = new Logger('useYDocSyncClient')
/**
* Creates a {@link YDocSyncClientAdapter} and mirrors its sync state to the global application state.
*
* @param messageTransporter The {@link MessageTransporter message transporter} that sends and receives messages for the synchronisation
* @param yDoc The {@link Doc y-doc} that should be synchronized
* @return the created adapter
*/
export const useYDocSyncClientAdapter = (
messageTransporter: MessageTransporter,
yDoc: Doc | undefined
): YDocSyncClientAdapter => {
const syncAdapter = useMemo(() => new YDocSyncClientAdapter(messageTransporter), [messageTransporter])
useEffect(() => {
syncAdapter.setYDoc(yDoc)
}, [syncAdapter, yDoc])
useEffect(() => {
const onceSyncedListener = syncAdapter.doAsSoonAsSynced(() => {
logger.debug('YDoc synced')
setRealtimeSyncedState(true)
})
const desyncedListener = syncAdapter.eventEmitter.on(
'desynced',
() => {
logger.debug('YDoc de-synced')
setRealtimeSyncedState(false)
},
{
objectify: true
}
) as Listener
return () => {
onceSyncedListener.off()
desyncedListener.off()
}
}, [messageTransporter, syncAdapter])
return syncAdapter
}

View file

@ -3,16 +3,28 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useMemo } from 'react'
import { Doc } from 'yjs'
import type { MessageTransporter } from '@hedgedoc/commons'
import { RealtimeDoc } from '@hedgedoc/commons'
import { useEffect, useState } from 'react'
/**
* Creates a new {@link Doc y-doc}.
* Creates a new {@link RealtimeDoc y-doc}.
*
* @return The created {@link Doc y-doc}
* @return The created {@link RealtimeDoc y-doc}
*/
export const useYDoc = (): Doc => {
const yDoc = useMemo(() => new Doc(), [])
useEffect(() => () => yDoc.destroy(), [yDoc])
export const useYDoc = (messageTransporter: MessageTransporter): RealtimeDoc | undefined => {
const [yDoc, setYDoc] = useState<RealtimeDoc>()
useEffect(() => {
messageTransporter.doAsSoonAsConnected(() => {
setYDoc(new RealtimeDoc())
})
messageTransporter.on('disconnected', () => {
setYDoc(undefined)
})
}, [messageTransporter])
useEffect(() => () => yDoc?.destroy(), [yDoc])
return yDoc
}

View file

@ -1,59 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeAwarenessUpdateMessage,
encodeCompleteAwarenessStateRequestMessage,
encodeDocumentUpdateMessage,
WebsocketTransporter
} from '@hedgedoc/commons'
import WebSocket from 'isomorphic-ws'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* 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)
}
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.off('update', updateCallback)
awareness.destroy()
})
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.isWebSocketOpen()) {
this.send(encodeDocumentUpdateMessage(update))
}
})
}
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resetRealtimeStatus } from '../../redux/realtime/methods'
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
import type { PropsWithChildren } from 'react'
import React, { Fragment, useEffect, useState } from 'react'
/**
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
*
* @param children The children to load after the reset
*/
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
useEffect(() => {
resetRealtimeStatus()
setGlobalStateInitialized(true)
}, [])
if (!globalStateInitialized) {
return <LoadingScreen />
} else {
return <Fragment>{children}</Fragment>
}
}

View file

@ -5,7 +5,7 @@
*/
.user-line-color-indicator {
border-left: 3px solid;
border-left: 3px solid var(--color);
min-height: 30px;
height: 100%;
flex: 0 0 3px;

View file

@ -3,16 +3,16 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
import styles from './user-line.module.scss'
import React from 'react'
export interface UserLineProps {
username: string | null
color: string
status: ActiveIndicatorStatus
active: boolean
color: number
}
/**
@ -22,19 +22,20 @@ export interface UserLineProps {
* @param color The color of the user's edits.
* @param status The user's current online status.
*/
export const UserLine: React.FC<UserLineProps> = ({ username, color, status }) => {
export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => {
return (
<div className={'d-flex align-items-center h-100 w-100'}>
<div
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']}`}
style={{ borderLeftColor: color }}
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']} ${createCursorCssClass(
color
)}`}
/>
<UserAvatarForUsername
username={username}
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
/>
<div className={styles['active-indicator-container']}>
<ActiveIndicator status={status} />
<ActiveIndicator active={active} />
</div>
</div>
)

View file

@ -3,12 +3,11 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
import styles from './active-indicator.module.scss'
import React from 'react'
export interface ActiveIndicatorProps {
status: ActiveIndicatorStatus
active: boolean
}
/**
@ -16,6 +15,6 @@ export interface ActiveIndicatorProps {
*
* @param status The state of the indicator to render
*/
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ status }) => {
return <span className={`${styles['activeIndicator']} ${status}`} />
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ active }) => {
return <span className={`${styles['activeIndicator']} ${active ? styles.active : styles.inactive}`} />
}

View file

@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
selectedMenuId
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const onlineUsers = useApplicationState((state) => state.realtime.users)
const realtimeUsers = useApplicationState((state) => state.realtimeStatus.onlineUsers)
useTranslation()
useEffect(() => {
const value = `${Object.keys(onlineUsers).length}`
buttonRef.current?.style.setProperty('--users-online', `"${value}"`)
}, [onlineUsers])
buttonRef.current?.style.setProperty('--users-online', `"${realtimeUsers.length}"`)
}, [realtimeUsers])
const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
const expand = selectedMenuId === menuId
const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick])
const onlineUserElements = useMemo(() => {
const entries = Object.entries(onlineUsers)
if (entries.length === 0) {
if (realtimeUsers.length === 0) {
return (
<SidebarButton>
<span className={'ms-3'}>
@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
</SidebarButton>
)
} else {
return entries.map(([clientId, onlineUser]) => {
return realtimeUsers.map((realtimeUser) => {
return (
<SidebarButton key={clientId}>
<UserLine username={onlineUser.username} color={onlineUser.color} status={onlineUser.active} />
<SidebarButton key={realtimeUser.styleIndex}>
<UserLine
username={realtimeUser.displayName}
color={realtimeUser.styleIndex}
active={realtimeUser.active}
/>
</SidebarButton>
)
})
}
}, [onlineUsers])
}, [realtimeUsers])
return (
<Fragment>

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { WaitSpinner } from '../../common/wait-spinner/wait-spinner'
import React from 'react'
import { Col, Container, Modal, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
/**
* Modal with a spinner that is only shown while reconnecting to the realtime backend
*/
export const RealtimeConnectionModal: React.FC = () => {
const isConnected = useApplicationState((state) => state.realtimeStatus.isSynced)
useTranslation()
return (
<Modal show={!isConnected}>
<Modal.Body>
<Container className={'text-center'}>
<Row className={'mb-4'}>
<Col xs={12}>
<WaitSpinner size={5}></WaitSpinner>
</Col>
</Row>
<Row>
<Col xs={12}>
<span>
<Trans i18nKey={'realtime.reconnect'}></Trans>
</span>
</Col>
</Row>
</Container>
</Modal.Body>
</Modal>
)
}