From 3a06f84af1329d70a3ac84a3cc1c55a5f0bf3e77 Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Wed, 22 Mar 2023 20:21:40 +0100 Subject: [PATCH] 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 --- .../y-protocols-npm-1.0.5-af6f64b4df.patch | 26 - backend/package.json | 2 - backend/src/notes/notes.service.ts | 5 +- .../random-word-lists/name-randomizer.spec.ts | 15 + .../random-word-lists/name-randomizer.ts | 23 + .../random-word-lists/random-words.json | 1329 +++++++++++++++++ .../random-words.json.license | 3 + .../realtime-note/realtime-connection.spec.ts | 156 ++ .../realtime-note/realtime-connection.ts | 76 + .../realtime-note/realtime-note-store.spec.ts | 18 +- .../realtime-note.service.spec.ts | 74 +- .../realtime-note/realtime-note.service.ts | 2 +- .../realtime-note/realtime-note.spec.ts | 92 +- .../realtime/realtime-note/realtime-note.ts | 78 +- .../realtime-user-status-adapter.spec.ts | 229 +++ .../realtime-user-status-adapter.ts | 147 ++ .../test-utils/mock-awareness.ts | 22 - .../test-utils/mock-connection.ts | 66 +- .../test-utils/mock-realtime-note.ts | 63 - .../test-utils/mock-websocket-doc.ts | 19 - .../test-utils/mock-websocket-transporter.ts | 33 - .../realtime-note/websocket-awareness.spec.ts | 62 - .../realtime-note/websocket-awareness.ts | 49 - .../websocket-connection.spec.ts | 219 --- .../realtime-note/websocket-connection.ts | 104 -- .../realtime-note/websocket-doc.spec.ts | 58 - .../realtime/realtime-note/websocket-doc.ts | 72 - .../websocket/websocket.gateway.spec.ts | 16 +- .../realtime/websocket/websocket.gateway.ts | 11 +- backend/tsconfig.json | 3 +- commons/package.json | 5 +- commons/src/connection-keep-alive-handler.ts | 77 - .../markdown-content-channel-name.ts | 7 - commons/src/index.ts | 32 +- .../message-transporter.ts | 102 ++ commons/src/message-transporters/message.ts | 36 + .../mocked-backend-message-transporter.ts | 57 + .../src/message-transporters/realtime-user.ts | 18 + .../websocket-transporter.ts | 97 ++ .../src/messages/awareness-update-message.ts | 40 - ...omplete-awareness-state-request-message.ts | 11 - .../complete-document-state-answer-message.ts | 29 - ...complete-document-state-request-message.ts | 23 - .../src/messages/document-deleted-message.ts | 11 - .../src/messages/document-update-message.ts | 33 - commons/src/messages/generic-message.ts | 16 - commons/src/messages/message-type.enum.ts | 20 - .../src/messages/metadata-updated-message.ts | 11 - commons/src/messages/ready-answer-message.ts | 11 - commons/src/messages/ready-request-message.ts | 11 - .../server-version-updated-message.ts | 11 - commons/src/websocket-transporter.ts | 61 - commons/src/y-doc-message-transporter.test.ts | 207 --- commons/src/y-doc-message-transporter.ts | 113 -- ...n-memory-connection-message.transporter.ts | 52 + commons/src/y-doc-sync/realtime-doc.test.ts | 16 + commons/src/y-doc-sync/realtime-doc.ts | 48 + .../src/y-doc-sync/y-doc-sync-adapter.test.ts | 162 ++ commons/src/y-doc-sync/y-doc-sync-adapter.ts | 148 ++ .../y-doc-sync/y-doc-sync-client-adapter.ts | 17 + .../y-doc-sync/y-doc-sync-server-adapter.ts | 14 + frontend/locales/en.json | 3 + frontend/package.json | 2 - .../editor-page/editor-page-content.tsx | 3 +- .../document-sync/y-text-sync-view-plugin.ts | 90 ++ .../remote-cursors/create-cursor-css-class.ts | 10 + .../remote-cursors/cursor-colors.module.scss | 37 + .../cursor-layers-extensions.ts | 97 ++ .../receive-remote-cursor-view-plugin.ts | 40 + .../remote-cursors/remote-cursor-marker.ts | 78 + .../remote-cursors/send-cursor-view-plugin.ts | 53 + .../remote-cursors/style.module.scss | 40 + .../editor-page/editor-pane/editor-pane.tsx | 75 +- .../use-code-mirror-file-insert-extension.ts | 0 ...se-code-mirror-remote-cursor-extensions.ts | 32 + .../use-code-mirror-scroll-watch-extension.ts | 0 .../use-code-mirror-spell-check-extension.ts | 0 .../editor-pane/hooks/yjs/mock-connection.ts | 38 - .../editor-pane/hooks/yjs/use-awareness.ts | 89 -- .../hooks/yjs/use-bind-y-text-to-redux.ts | 5 +- .../yjs/use-code-mirror-yjs-extension.ts | 32 +- ...content-into-y-text-in-mock-mode-effect.ts | 34 - .../hooks/yjs/use-is-connection-synced.ts | 30 - .../hooks/yjs/use-markdown-content-y-text.ts | 7 +- .../use-on-first-editor-update-extension.ts | 19 - .../hooks/yjs/use-on-metadata-updated.ts | 19 +- .../hooks/yjs/use-on-note-deleted.ts | 13 +- .../hooks/yjs/use-realtime-connection.ts | 110 ++ .../hooks/yjs/use-receive-realtime-users.ts | 38 + .../hooks/yjs/use-websocket-connection.ts | 39 - .../hooks/yjs/use-websocket-url.ts | 5 +- .../yjs/use-y-doc-sync-client-adapter.ts | 56 + .../editor-pane/hooks/yjs/use-y-doc.ts | 26 +- .../hooks/yjs/websocket-connection.ts | 59 - .../reset-realtime-state-boundary.tsx | 27 + .../sidebar/user-line/user-line.module.scss | 2 +- .../sidebar/user-line/user-line.tsx | 15 +- .../active-indicator.tsx | 7 +- .../users-online-sidebar-menu.tsx | 22 +- .../realtime-connection-modal.tsx | 39 + frontend/src/pages/n/[noteId].tsx | 13 +- frontend/src/redux/application-state.d.ts | 4 +- frontend/src/redux/realtime/methods.ts | 46 +- frontend/src/redux/realtime/reducers.ts | 39 +- .../reducers/build-state-from-add-user.ts | 23 - .../reducers/build-state-from-remove-user.ts | 21 - frontend/src/redux/realtime/types.ts | 47 +- frontend/src/redux/reducers.ts | 4 +- package.json | 2 - yarn.lock | 63 +- 110 files changed, 3920 insertions(+), 2201 deletions(-) delete mode 100644 .yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch create mode 100644 backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts create mode 100644 backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts create mode 100644 backend/src/realtime/realtime-note/random-word-lists/random-words.json create mode 100644 backend/src/realtime/realtime-note/random-word-lists/random-words.json.license create mode 100644 backend/src/realtime/realtime-note/realtime-connection.spec.ts create mode 100644 backend/src/realtime/realtime-note/realtime-connection.ts create mode 100644 backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts create mode 100644 backend/src/realtime/realtime-note/realtime-user-status-adapter.ts delete mode 100644 backend/src/realtime/realtime-note/test-utils/mock-awareness.ts delete mode 100644 backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts delete mode 100644 backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts delete mode 100644 backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-awareness.spec.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-awareness.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-connection.spec.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-connection.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-doc.spec.ts delete mode 100644 backend/src/realtime/realtime-note/websocket-doc.ts delete mode 100644 commons/src/connection-keep-alive-handler.ts delete mode 100644 commons/src/constants/markdown-content-channel-name.ts create mode 100644 commons/src/message-transporters/message-transporter.ts create mode 100644 commons/src/message-transporters/message.ts create mode 100644 commons/src/message-transporters/mocked-backend-message-transporter.ts create mode 100644 commons/src/message-transporters/realtime-user.ts create mode 100644 commons/src/message-transporters/websocket-transporter.ts delete mode 100644 commons/src/messages/awareness-update-message.ts delete mode 100644 commons/src/messages/complete-awareness-state-request-message.ts delete mode 100644 commons/src/messages/complete-document-state-answer-message.ts delete mode 100644 commons/src/messages/complete-document-state-request-message.ts delete mode 100644 commons/src/messages/document-deleted-message.ts delete mode 100644 commons/src/messages/document-update-message.ts delete mode 100644 commons/src/messages/generic-message.ts delete mode 100644 commons/src/messages/message-type.enum.ts delete mode 100644 commons/src/messages/metadata-updated-message.ts delete mode 100644 commons/src/messages/ready-answer-message.ts delete mode 100644 commons/src/messages/ready-request-message.ts delete mode 100644 commons/src/messages/server-version-updated-message.ts delete mode 100644 commons/src/websocket-transporter.ts delete mode 100644 commons/src/y-doc-message-transporter.test.ts delete mode 100644 commons/src/y-doc-message-transporter.ts create mode 100644 commons/src/y-doc-sync/in-memory-connection-message.transporter.ts create mode 100644 commons/src/y-doc-sync/realtime-doc.test.ts create mode 100644 commons/src/y-doc-sync/realtime-doc.ts create mode 100644 commons/src/y-doc-sync/y-doc-sync-adapter.test.ts create mode 100644 commons/src/y-doc-sync/y-doc-sync-adapter.ts create mode 100644 commons/src/y-doc-sync/y-doc-sync-client-adapter.ts create mode 100644 commons/src/y-doc-sync/y-doc-sync-server-adapter.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts create mode 100644 frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss rename frontend/src/components/editor-page/editor-pane/hooks/{code-mirror-extensions => codemirror-extensions}/use-code-mirror-file-insert-extension.ts (100%) create mode 100644 frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts rename frontend/src/components/editor-page/editor-pane/hooks/{code-mirror-extensions => codemirror-extensions}/use-code-mirror-scroll-watch-extension.ts (100%) rename frontend/src/components/editor-page/editor-pane/hooks/{code-mirror-extensions => codemirror-extensions}/use-code-mirror-spell-check-extension.ts (100%) delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts create mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts create mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts create mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts delete mode 100644 frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts create mode 100644 frontend/src/components/editor-page/reset-realtime-state-boundary.tsx create mode 100644 frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx delete mode 100644 frontend/src/redux/realtime/reducers/build-state-from-add-user.ts delete mode 100644 frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts diff --git a/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch b/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch deleted file mode 100644 index aac702d36..000000000 --- a/.yarn/patches/y-protocols-npm-1.0.5-af6f64b4df.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/package.json b/package.json -index 5f953f00544710a638dc502b30841d39193f6d3f..6c31784d1b1f32ee8f21106011c4e6ef526f1560 100644 ---- a/package.json -+++ b/package.json -@@ -47,18 +47,21 @@ - "./sync.js": "./sync.js", - "./dist/sync.cjs": "./dist/sync.cjs", - "./sync": { -+ "types": "./sync.d.ts", - "import": "./sync.js", - "require": "./dist/sync.cjs" - }, - "./awareness.js": "./awareness.js", - "./dist/awareness.cjs": "./dist/awareness.cjs", - "./awareness": { -+ "types": "./awareness.d.ts", - "import": "./awareness.js", - "require": "./dist/awareness.cjs" - }, - "./auth.js": "./auth.js", - "./dist/auth.cjs": "./dist/auth.cjs", - "./auth": { -+ "types": "./auth.d.ts", - "import": "./auth.js", - "require": "./dist/auth.cjs" - } diff --git a/backend/package.json b/backend/package.json index b9900d256..dfd5e51b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -58,7 +58,6 @@ "file-type": "16.5.4", "joi": "17.9.1", "ldapauth-fork": "5.0.5", - "lib0": "0.2.73", "minio": "7.0.33", "mysql": "2.18.1", "nest-router": "1.0.9", @@ -75,7 +74,6 @@ "sqlite3": "5.1.6", "typeorm": "0.3.7", "ws": "8.13.0", - "y-protocols": "1.0.5", "yjs": "13.5.51" }, "devDependencies": { diff --git a/backend/src/notes/notes.service.ts b/backend/src/notes/notes.service.ts index de8e366f9..c1febee44 100644 --- a/backend/src/notes/notes.service.ts +++ b/backend/src/notes/notes.service.ts @@ -182,7 +182,10 @@ export class NotesService { */ async getNoteContent(note: Note): Promise { return ( - this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ?? + this.realtimeNoteStore + .find(note.id) + ?.getRealtimeDoc() + .getCurrentContent() ?? (await this.revisionsService.getLatestRevision(note)).content ); } diff --git a/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts new file mode 100644 index 000000000..eccef8dbc --- /dev/null +++ b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.spec.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { generateRandomName } from './name-randomizer'; + +describe('name randomizer', () => { + it('generates random names', () => { + const firstName = generateRandomName(); + const secondName = generateRandomName(); + expect(firstName).not.toBe(''); + expect(firstName).not.toBe(secondName); + }); +}); diff --git a/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts new file mode 100644 index 000000000..bb6347e97 --- /dev/null +++ b/backend/src/realtime/realtime-note/random-word-lists/name-randomizer.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import lists from './random-words.json'; + +/** + * Generates a random names based on an adjective and a noun. + * + * @return the generated name + */ +export function generateRandomName(): string { + const adjective = generateRandomWord(lists.adjectives); + const things = generateRandomWord(lists.items); + return `${adjective} ${things}`; +} + +function generateRandomWord(list: string[]): string { + const index = Math.floor(Math.random() * list.length); + const word = list[index]; + return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase(); +} diff --git a/backend/src/realtime/realtime-note/random-word-lists/random-words.json b/backend/src/realtime/realtime-note/random-word-lists/random-words.json new file mode 100644 index 000000000..433973b81 --- /dev/null +++ b/backend/src/realtime/realtime-note/random-word-lists/random-words.json @@ -0,0 +1,1329 @@ +{ + "items": [ + "apple", + "bag", + "balloon", + "bananas", + "bed", + "beef", + "blouse", + "book", + "bookmark", + "boom box", + "bottle", + "bottle cap", + "bow", + "bowl", + "box", + "bracelet", + "bread", + "brocolli", + "hair brush", + "buckle", + "button", + "camera", + "candle", + "candy wrapper", + "canvas", + "car", + "greeting card", + "playing card", + "carrots", + "cat", + "CD", + "cell phone", + "packing peanuts", + "cinder block", + "chair", + "chalk", + "newspaper", + "soy sauce packet", + "chapter book", + "checkbook", + "chocolate", + "clay pot", + "clock", + "clothes", + "computer", + "conditioner", + "cookie jar", + "cork", + "couch", + "credit card", + "cup", + "deodorant ", + "desk", + "door", + "drawer", + "drill press", + "eraser", + "eye liner", + "face wash", + "fake flowers", + "flag", + "floor", + "flowers", + "food", + "fork", + "fridge", + "glass", + "glasses", + "glow stick", + "grid paper", + "hair tie", + "hanger", + "helmet", + "house", + "ipod", + "charger", + "key chain", + "keyboard", + "keys", + "knife", + "lace", + "lamp", + "lamp shade", + "leg warmers", + "lip gloss", + "lotion", + "milk", + "mirror", + "model car", + "money", + "monitor", + "mop", + "mouse pad", + "mp3 player", + "nail clippers", + "nail file", + "needle", + "outlet", + "paint brush", + "pants", + "paper", + "pen", + "pencil", + "perfume", + "phone", + "photo album", + "picture frame", + "pillow", + "plastic fork", + "plate", + "pool stick", + "soda can", + "puddle", + "purse", + "blanket", + "radio", + "remote", + "ring", + "rubber band", + "rubber duck", + "rug", + "rusty nail", + "sailboat", + "sand paper", + "sandal", + "scotch tape", + "screw", + "seat belt", + "shampoo", + "sharpie", + "shawl", + "shirt", + "shoe lace", + "shoes", + "shovel", + "sidewalk", + "sketch pad", + "slipper", + "soap", + "socks", + "sofa", + "speakers", + "sponge", + "spoon", + "spring", + "sticky note", + "stockings", + "stop sign", + "street lights", + "sun glasses", + "table", + "teddies", + "television", + "thermometer", + "thread", + "tire swing", + "tissue box", + "toe ring", + "toilet", + "tomato", + "tooth picks", + "toothbrush", + "toothpaste", + "towel", + "tree", + "truck", + "tv", + "twezzers", + "twister", + "vase", + "video games", + "wallet", + "washing machine", + "watch", + "water bottle", + "doll", + "magnet", + "wagon", + "headphones", + "clamp", + "USB drive", + "air freshener", + "piano", + "ice cube tray", + "white out", + "window", + "controller", + "coasters", + "thermostat", + "zipper" + ], + "adjectives": [ + "aback", + "abaft", + "abandoned", + "abashed", + "aberrant", + "abhorrent", + "abiding", + "abject", + "ablaze", + "able", + "abnormal", + "aboard", + "aboriginal", + "abortive", + "abounding", + "abrasive", + "abrupt", + "absent", + "absorbed", + "absorbing", + "abstracted", + "absurd", + "abundant", + "abusive", + "acceptable", + "accessible", + "accidental", + "accurate", + "acid", + "acidic", + "acoustic", + "acrid", + "actually", + "ad hoc", + "adamant", + "adaptable", + "addicted", + "adhesive", + "adjoining", + "adorable", + "adventurous", + "afraid", + "aggressive", + "agonizing", + "agreeable", + "ahead", + "ajar", + "alcoholic", + "alert", + "alike", + "alive", + "alleged", + "alluring", + "aloof", + "amazing", + "ambiguous", + "ambitious", + "amuck", + "amused", + "amusing", + "ancient", + "angry", + "animated", + "annoyed", + "annoying", + "anxious", + "apathetic", + "aquatic", + "aromatic", + "arrogant", + "ashamed", + "aspiring", + "assorted", + "astonishing", + "attractive", + "auspicious", + "automatic", + "available", + "average", + "awake", + "aware", + "awesome", + "awful", + "axiomatic", + "bad", + "barbarous", + "bashful", + "bawdy", + "beautiful", + "befitting", + "belligerent", + "beneficial", + "bent", + "berserk", + "best", + "better", + "bewildered", + "big", + "billowy", + "bite-sized", + "bitter", + "bizarre", + "black", + "black-and-white", + "bloody", + "blue", + "blue-eyed", + "blushing", + "boiling", + "boorish", + "bored", + "boring", + "bouncy", + "boundless", + "brainy", + "brash", + "brave", + "brawny", + "breakable", + "breezy", + "brief", + "bright", + "bright", + "broad", + "broken", + "brown", + "bumpy", + "burly", + "bustling", + "busy", + "cagey", + "calculating", + "callous", + "calm", + "capable", + "capricious", + "careful", + "careless", + "caring", + "cautious", + "ceaseless", + "certain", + "changeable", + "charming", + "cheap", + "cheerful", + "chemical", + "chief", + "childlike", + "chilly", + "chivalrous", + "chubby", + "chunky", + "clammy", + "classy", + "clean", + "clear", + "clever", + "cloistered", + "cloudy", + "closed", + "clumsy", + "cluttered", + "coherent", + "cold", + "colorful", + "colossal", + "combative", + "comfortable", + "common", + "complete", + "complex", + "concerned", + "condemned", + "confused", + "conscious", + "cooing", + "cool", + "cooperative", + "coordinated", + "courageous", + "cowardly", + "crabby", + "craven", + "crazy", + "creepy", + "crooked", + "crowded", + "cruel", + "cuddly", + "cultured", + "cumbersome", + "curious", + "curly", + "curved", + "curvy", + "cut", + "cute", + "cute", + "cynical", + "daffy", + "daily", + "damaged", + "damaging", + "damp", + "dangerous", + "dapper", + "dark", + "dashing", + "dazzling", + "dead", + "deadpan", + "deafening", + "dear", + "debonair", + "decisive", + "decorous", + "deep", + "deeply", + "defeated", + "defective", + "defiant", + "delicate", + "delicious", + "delightful", + "demonic", + "delirious", + "dependent", + "depressed", + "deranged", + "descriptive", + "deserted", + "detailed", + "determined", + "devilish", + "didactic", + "different", + "difficult", + "diligent", + "direful", + "dirty", + "disagreeable", + "disastrous", + "discreet", + "disgusted", + "disgusting", + "disillusioned", + "dispensable", + "distinct", + "disturbed", + "divergent", + "dizzy", + "domineering", + "doubtful", + "drab", + "draconian", + "dramatic", + "dreary", + "drunk", + "dry", + "dull", + "dusty", + "dusty", + "dynamic", + "dysfunctional", + "eager", + "early", + "earsplitting", + "earthy", + "easy", + "eatable", + "economic", + "educated", + "efficacious", + "efficient", + "eight", + "elastic", + "elated", + "elderly", + "electric", + "elegant", + "elfin", + "elite", + "embarrassed", + "eminent", + "empty", + "enchanted", + "enchanting", + "encouraging", + "endurable", + "energetic", + "enormous", + "entertaining", + "enthusiastic", + "envious", + "equable", + "equal", + "erect", + "erratic", + "ethereal", + "evanescent", + "evasive", + "even", + "excellent", + "excited", + "exciting", + "exclusive", + "exotic", + "expensive", + "extra-large", + "extra-small", + "exuberant", + "exultant", + "fabulous", + "faded", + "faint", + "fair", + "faithful", + "fallacious", + "false", + "familiar", + "famous", + "fanatical", + "fancy", + "fantastic", + "far", + "far-flung", + "fascinated", + "fast", + "fat", + "faulty", + "fearful", + "fearless", + "feeble", + "feigned", + "female", + "fertile", + "festive", + "few", + "fierce", + "filthy", + "fine", + "finicky", + "first", + "five", + "fixed", + "flagrant", + "flaky", + "flashy", + "flat", + "flawless", + "flimsy", + "flippant", + "flowery", + "fluffy", + "fluttering", + "foamy", + "foolish", + "foregoing", + "forgetful", + "fortunate", + "four", + "frail", + "fragile", + "frantic", + "free", + "freezing", + "frequent", + "fresh", + "fretful", + "friendly", + "frightened", + "frightening", + "full", + "fumbling", + "functional", + "funny", + "furry", + "furtive", + "future", + "futuristic", + "fuzzy", + "gabby", + "gainful", + "gamy", + "gaping", + "garrulous", + "gaudy", + "general", + "gentle", + "giant", + "giddy", + "gifted", + "gigantic", + "glamorous", + "gleaming", + "glib", + "glistening", + "glorious", + "glossy", + "godly", + "good", + "goofy", + "gorgeous", + "graceful", + "grandiose", + "grateful", + "gratis", + "gray", + "greasy", + "great", + "greedy", + "green", + "grey", + "grieving", + "groovy", + "grotesque", + "grouchy", + "grubby", + "gruesome", + "grumpy", + "guarded", + "guiltless", + "gullible", + "gusty", + "guttural", + "habitual", + "half", + "hallowed", + "halting", + "handsome", + "handsomely", + "handy", + "hanging", + "hapless", + "happy", + "hard", + "hard-to-find", + "harmonious", + "harsh", + "hateful", + "heady", + "healthy", + "heartbreaking", + "heavenly", + "heavy", + "hellish", + "helpful", + "helpless", + "hesitant", + "hideous", + "high", + "highfalutin", + "high-pitched", + "hilarious", + "hissing", + "historical", + "holistic", + "hollow", + "homeless", + "homely", + "honorable", + "horrible", + "hospitable", + "hot", + "huge", + "hulking", + "humdrum", + "humorous", + "hungry", + "hurried", + "hurt", + "hushed", + "husky", + "hypnotic", + "hysterical", + "icky", + "icy", + "idiotic", + "ignorant", + "ill", + "illegal", + "ill-fated", + "ill-informed", + "illustrious", + "imaginary", + "immense", + "imminent", + "impartial", + "imperfect", + "impolite", + "important", + "imported", + "impossible", + "incandescent", + "incompetent", + "inconclusive", + "industrious", + "incredible", + "inexpensive", + "infamous", + "innate", + "innocent", + "inquisitive", + "insidious", + "instinctive", + "intelligent", + "interesting", + "internal", + "invincible", + "irate", + "irritating", + "itchy", + "jaded", + "jagged", + "jazzy", + "jealous", + "jittery", + "jobless", + "jolly", + "joyous", + "judicious", + "juicy", + "jumbled", + "jumpy", + "juvenile", + "kaput", + "keen", + "kind", + "kindhearted", + "kindly", + "knotty", + "knowing", + "knowledgeable", + "known", + "labored", + "lackadaisical", + "lacking", + "lame", + "lamentable", + "languid", + "large", + "last", + "late", + "laughable", + "lavish", + "lazy", + "lean", + "learned", + "left", + "legal", + "lethal", + "level", + "lewd", + "light", + "like", + "likeable", + "limping", + "literate", + "little", + "lively", + "lively", + "living", + "lonely", + "long", + "longing", + "long-term", + "loose", + "lopsided", + "loud", + "loutish", + "lovely", + "loving", + "low", + "lowly", + "lucky", + "ludicrous", + "lumpy", + "lush", + "luxuriant", + "lying", + "lyrical", + "macabre", + "macho", + "maddening", + "madly", + "magenta", + "magical", + "magnificent", + "majestic", + "makeshift", + "male", + "malicious", + "mammoth", + "maniacal", + "many", + "marked", + "massive", + "married", + "marvelous", + "material", + "materialistic", + "mature", + "mean", + "measly", + "meaty", + "medical", + "meek", + "mellow", + "melodic", + "melted", + "merciful", + "mere", + "messy", + "mighty", + "military", + "milky", + "mindless", + "miniature", + "minor", + "miscreant", + "misty", + "mixed", + "moaning", + "modern", + "moldy", + "momentous", + "motionless", + "mountainous", + "muddled", + "mundane", + "murky", + "mushy", + "mute", + "mysterious", + "naive", + "nappy", + "narrow", + "nasty", + "natural", + "naughty", + "nauseating", + "near", + "neat", + "nebulous", + "necessary", + "needless", + "needy", + "neighborly", + "nervous", + "new", + "next", + "nice", + "nifty", + "nimble", + "nine", + "nippy", + "noiseless", + "noisy", + "nonchalant", + "nondescript", + "nonstop", + "normal", + "nostalgic", + "nosy", + "noxious", + "null", + "numberless", + "numerous", + "nutritious", + "nutty", + "oafish", + "obedient", + "obeisant", + "obese", + "obnoxious", + "obscene", + "obsequious", + "observant", + "obsolete", + "obtainable", + "oceanic", + "odd", + "offbeat", + "old", + "old-fashioned", + "omniscient", + "one", + "onerous", + "open", + "opposite", + "optimal", + "orange", + "ordinary", + "organic", + "ossified", + "outgoing", + "outrageous", + "outstanding", + "oval", + "overconfident", + "overjoyed", + "overrated", + "overt", + "overwrought", + "painful", + "painstaking", + "pale", + "paltry", + "panicky", + "panoramic", + "parallel", + "parched", + "parsimonious", + "past", + "pastoral", + "pathetic", + "peaceful", + "penitent", + "perfect", + "periodic", + "permissible", + "perpetual", + "petite", + "petite", + "phobic", + "physical", + "picayune", + "pink", + "piquant", + "placid", + "plain", + "plant", + "plastic", + "plausible", + "pleasant", + "plucky", + "pointless", + "poised", + "polite", + "political", + "poor", + "possessive", + "possible", + "powerful", + "precious", + "premium", + "present", + "pretty", + "previous", + "pricey", + "prickly", + "private", + "probable", + "productive", + "profuse", + "protective", + "proud", + "psychedelic", + "psychotic", + "public", + "puffy", + "pumped", + "puny", + "purple", + "purring", + "pushy", + "puzzled", + "puzzling", + "quack", + "quaint", + "quarrelsome", + "questionable", + "quick", + "quickest", + "quiet", + "quirky", + "quixotic", + "quizzical", + "rabid", + "racial", + "ragged", + "rainy", + "rambunctious", + "rampant", + "rapid", + "rare", + "raspy", + "ratty", + "ready", + "real", + "rebel", + "receptive", + "recondite", + "red", + "redundant", + "reflective", + "regular", + "relieved", + "remarkable", + "reminiscent", + "repulsive", + "resolute", + "resonant", + "responsible", + "rhetorical", + "rich", + "right", + "righteous", + "rightful", + "rigid", + "ripe", + "ritzy", + "roasted", + "robust", + "romantic", + "roomy", + "rotten", + "rough", + "round", + "royal", + "ruddy", + "rude", + "rural", + "rustic", + "ruthless", + "sable", + "sad", + "safe", + "salty", + "same", + "sassy", + "satisfying", + "savory", + "scandalous", + "scarce", + "scared", + "scary", + "scattered", + "scientific", + "scintillating", + "scrawny", + "screeching", + "second", + "second-hand", + "secret", + "secretive", + "sedate", + "seemly", + "selective", + "selfish", + "separate", + "serious", + "shaggy", + "shaky", + "shallow", + "sharp", + "shiny", + "shivering", + "shocking", + "short", + "shrill", + "shut", + "shy", + "sick", + "silent", + "silent", + "silky", + "silly", + "simple", + "simplistic", + "sincere", + "six", + "skillful", + "skinny", + "sleepy", + "slim", + "slimy", + "slippery", + "sloppy", + "slow", + "small", + "smart", + "smelly", + "smiling", + "smoggy", + "smooth", + "sneaky", + "snobbish", + "snotty", + "soft", + "soggy", + "solid", + "somber", + "sophisticated", + "sordid", + "sore", + "sore", + "sour", + "sparkling", + "special", + "spectacular", + "spicy", + "spiffy", + "spiky", + "spiritual", + "spiteful", + "splendid", + "spooky", + "spotless", + "spotted", + "spotty", + "spurious", + "squalid", + "square", + "squealing", + "squeamish", + "staking", + "stale", + "standing", + "statuesque", + "steadfast", + "steady", + "steep", + "stereotyped", + "sticky", + "stiff", + "stimulating", + "stingy", + "stormy", + "straight", + "strange", + "striped", + "strong", + "stupendous", + "stupid", + "sturdy", + "subdued", + "subsequent", + "substantial", + "successful", + "succinct", + "sudden", + "sulky", + "super", + "superb", + "superficial", + "supreme", + "swanky", + "sweet", + "sweltering", + "swift", + "symptomatic", + "synonymous", + "taboo", + "tacit", + "tacky", + "talented", + "tall", + "tame", + "tan", + "tangible", + "tangy", + "tart", + "tasteful", + "tasteless", + "tasty", + "tawdry", + "tearful", + "tedious", + "teeny", + "teeny-tiny", + "telling", + "temporary", + "ten", + "tender", + "tense", + "tense", + "tenuous", + "terrible", + "terrific", + "tested", + "testy", + "thankful", + "therapeutic", + "thick", + "thin", + "thinkable", + "third", + "thirsty", + "thirsty", + "thoughtful", + "thoughtless", + "threatening", + "three", + "thundering", + "tidy", + "tight", + "tightfisted", + "tiny", + "tired", + "tiresome", + "toothsome", + "torpid", + "tough", + "towering", + "tranquil", + "trashy", + "tremendous", + "tricky", + "trite", + "troubled", + "truculent", + "true", + "truthful", + "two", + "typical", + "ubiquitous", + "ugliest", + "ugly", + "ultra", + "unable", + "unaccountable", + "unadvised", + "unarmed", + "unbecoming", + "unbiased", + "uncovered", + "understood", + "undesirable", + "unequal", + "unequaled", + "uneven", + "unhealthy", + "uninterested", + "unique", + "unkempt", + "unknown", + "unnatural", + "unruly", + "unsightly", + "unsuitable", + "untidy", + "unused", + "unusual", + "unwieldy", + "unwritten", + "upbeat", + "uppity", + "upset", + "uptight", + "used", + "useful", + "useless", + "utopian", + "utter", + "uttermost", + "vacuous", + "vagabond", + "vague", + "valuable", + "various", + "vast", + "vengeful", + "venomous", + "verdant", + "versed", + "victorious", + "vigorous", + "violent", + "violet", + "vivacious", + "voiceless", + "volatile", + "voracious", + "vulgar", + "wacky", + "waggish", + "waiting", + "wakeful", + "wandering", + "wanting", + "warlike", + "warm", + "wary", + "wasteful", + "watery", + "weak", + "wealthy", + "weary", + "well-groomed", + "well-made", + "well-off", + "well-to-do", + "wet", + "whimsical", + "whispering", + "white", + "whole", + "wholesale", + "wicked", + "wide", + "wide-eyed", + "wiggly", + "wild", + "willing", + "windy", + "wiry", + "wise", + "wistful", + "witty", + "woebegone", + "womanly", + "wonderful", + "wooden", + "woozy", + "workable", + "worried", + "worthless", + "wrathful", + "wretched", + "wrong", + "wry", + "yellow", + "yielding", + "young", + "youthful", + "yummy", + "zany", + "zealous", + "zesty", + "zippy", + "zonked" + ] +} diff --git a/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license b/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license new file mode 100644 index 000000000..8f24bb228 --- /dev/null +++ b/backend/src/realtime/realtime-note/random-word-lists/random-words.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: The author of https://www.randomlists.com/ + +SPDX-License-Identifier: CC0-1.0 diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts new file mode 100644 index 000000000..8cbd7954d --- /dev/null +++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + MessageTransporter, + MockedBackendMessageTransporter, + YDocSyncServerAdapter, +} from '@hedgedoc/commons'; +import * as HedgeDocCommonsModule from '@hedgedoc/commons'; +import { Mock } from 'ts-mockery'; + +import { Note } from '../../notes/note.entity'; +import { User } from '../../users/user.entity'; +import * as NameRandomizerModule from './random-word-lists/name-randomizer'; +import { RealtimeConnection } from './realtime-connection'; +import { RealtimeNote } from './realtime-note'; +import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter'; +import * as RealtimeUserStatusModule from './realtime-user-status-adapter'; + +jest.mock('./random-word-lists/name-randomizer'); +jest.mock('./realtime-user-status-adapter'); +jest.mock( + '@hedgedoc/commons', + () => + ({ + ...jest.requireActual('@hedgedoc/commons'), + // eslint-disable-next-line @typescript-eslint/naming-convention + YDocSyncServerAdapter: jest.fn(() => + Mock.of({ + setYDoc: jest.fn(), + }), + ), + } as Record), +); + +describe('websocket connection', () => { + let mockedRealtimeNote: RealtimeNote; + let mockedUser: User; + let mockedMessageTransporter: MessageTransporter; + + beforeEach(() => { + mockedRealtimeNote = new RealtimeNote(Mock.of({}), ''); + mockedUser = Mock.of({}); + + mockedMessageTransporter = new MockedBackendMessageTransporter(''); + }); + + afterAll(() => { + jest.resetAllMocks(); + jest.resetModules(); + }); + + it('returns the correct transporter', () => { + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + expect(sut.getTransporter()).toBe(mockedMessageTransporter); + }); + + it('returns the correct realtime note', () => { + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote); + }); + + it('returns the correct realtime user status', () => { + const realtimeUserStatus = Mock.of(); + jest + .spyOn(RealtimeUserStatusModule, 'RealtimeUserStatusAdapter') + .mockImplementation(() => realtimeUserStatus); + + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + + expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus); + }); + + it('returns the correct sync adapter', () => { + const yDocSyncServerAdapter = Mock.of({ + setYDoc: jest.fn(), + }); + jest + .spyOn(HedgeDocCommonsModule, 'YDocSyncServerAdapter') + .mockImplementation(() => yDocSyncServerAdapter); + + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + + expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter); + }); + + it('removes the client from the note on transporter disconnect', () => { + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + + const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient'); + + mockedMessageTransporter.disconnect(); + + expect(removeClientSpy).toHaveBeenCalledWith(sut); + }); + + it('saves the correct user', () => { + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + + expect(sut.getUser()).toBe(mockedUser); + }); + + it('returns the correct username', () => { + const mockedUserWithUsername = Mock.of({ displayName: 'MockUser' }); + + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUserWithUsername, + mockedRealtimeNote, + ); + + expect(sut.getDisplayName()).toBe('MockUser'); + }); + + it('returns a fallback if no username has been set', () => { + const randomName = 'I am a random name'; + + jest + .spyOn(NameRandomizerModule, 'generateRandomName') + .mockReturnValue(randomName); + + const sut = new RealtimeConnection( + mockedMessageTransporter, + mockedUser, + mockedRealtimeNote, + ); + + expect(sut.getDisplayName()).toBe(randomName); + }); +}); diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts new file mode 100644 index 000000000..f43765f51 --- /dev/null +++ b/backend/src/realtime/realtime-note/realtime-connection.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MessageTransporter, YDocSyncServerAdapter } from '@hedgedoc/commons'; +import { Logger } from '@nestjs/common'; + +import { User } from '../../users/user.entity'; +import { generateRandomName } from './random-word-lists/name-randomizer'; +import { RealtimeNote } from './realtime-note'; +import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter'; + +/** + * Manages the connection to a specific client. + */ +export class RealtimeConnection { + protected readonly logger = new Logger(RealtimeConnection.name); + private readonly transporter: MessageTransporter; + private readonly yDocSyncAdapter: YDocSyncServerAdapter; + private readonly realtimeUserStateAdapter: RealtimeUserStatusAdapter; + + private displayName: string; + + /** + * Instantiates the connection wrapper. + * + * @param messageTransporter The message transporter that handles the communication with the client. + * @param user The user of the client + * @param realtimeNote The {@link RealtimeNote} that the client connected to. + * @throws Error if the socket is not open + */ + constructor( + messageTransporter: MessageTransporter, + private user: User | null, + private realtimeNote: RealtimeNote, + ) { + this.displayName = user?.displayName ?? generateRandomName(); + this.transporter = messageTransporter; + + this.transporter.on('disconnected', () => { + realtimeNote.removeClient(this); + }); + this.yDocSyncAdapter = new YDocSyncServerAdapter(this.transporter); + this.yDocSyncAdapter.setYDoc(realtimeNote.getRealtimeDoc()); + this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter( + this.user?.username ?? null, + this.getDisplayName(), + this, + ); + } + + public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter { + return this.realtimeUserStateAdapter; + } + + public getTransporter(): MessageTransporter { + return this.transporter; + } + + public getUser(): User | null { + return this.user; + } + + public getSyncAdapter(): YDocSyncServerAdapter { + return this.yDocSyncAdapter; + } + + public getDisplayName(): string { + return this.displayName; + } + + public getRealtimeNote(): RealtimeNote { + return this.realtimeNote; + } +} diff --git a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts index 40fdd17ef..89071bf0f 100644 --- a/backend/src/realtime/realtime-note/realtime-note-store.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note-store.spec.ts @@ -9,9 +9,6 @@ import { Note } from '../../notes/note.entity'; import * as realtimeNoteModule from './realtime-note'; import { RealtimeNote } from './realtime-note'; import { RealtimeNoteStore } from './realtime-note-store'; -import { mockRealtimeNote } from './test-utils/mock-realtime-note'; -import { WebsocketAwareness } from './websocket-awareness'; -import { WebsocketDoc } from './websocket-doc'; describe('RealtimeNoteStore', () => { let realtimeNoteStore: RealtimeNoteStore; @@ -22,22 +19,21 @@ describe('RealtimeNoteStore', () => { const mockedNoteId = 4711; beforeEach(async () => { - jest.resetAllMocks(); - jest.resetModules(); - realtimeNoteStore = new RealtimeNoteStore(); mockedNote = Mock.of({ id: mockedNoteId }); - mockedRealtimeNote = mockRealtimeNote( - mockedNote, - Mock.of(), - Mock.of(), - ); + mockedRealtimeNote = new RealtimeNote(mockedNote, ''); realtimeNoteConstructorSpy = jest .spyOn(realtimeNoteModule, 'RealtimeNote') .mockReturnValue(mockedRealtimeNote); }); + afterEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + jest.resetModules(); + }); + it("can create a new realtime note if it doesn't exist yet", () => { expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe( mockedRealtimeNote, diff --git a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts index 29dcfdda5..9a399af0d 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.spec.ts @@ -14,17 +14,12 @@ import { RevisionsService } from '../../revisions/revisions.service'; import { RealtimeNote } from './realtime-note'; import { RealtimeNoteStore } from './realtime-note-store'; import { RealtimeNoteService } from './realtime-note.service'; -import { mockAwareness } from './test-utils/mock-awareness'; -import { mockRealtimeNote } from './test-utils/mock-realtime-note'; -import { mockWebsocketDoc } from './test-utils/mock-websocket-doc'; -import { WebsocketDoc } from './websocket-doc'; describe('RealtimeNoteService', () => { const mockedContent = 'mockedContent'; const mockedNoteId = 4711; - let websocketDoc: WebsocketDoc; - let mockedNote: Note; - let mockedRealtimeNote: RealtimeNote; + let note: Note; + let realtimeNote: RealtimeNote; let realtimeNoteService: RealtimeNoteService; let revisionsService: RevisionsService; let realtimeNoteStore: RealtimeNoteStore; @@ -46,7 +41,7 @@ describe('RealtimeNoteService', () => { jest .spyOn(revisionsService, 'getLatestRevision') .mockImplementation((note: Note) => - note === mockedNote && latestRevisionExists + note.id === mockedNoteId && latestRevisionExists ? Promise.resolve( Mock.of({ content: mockedContent, @@ -60,13 +55,8 @@ describe('RealtimeNoteService', () => { jest.resetAllMocks(); jest.resetModules(); - websocketDoc = mockWebsocketDoc(); - mockedNote = Mock.of({ id: mockedNoteId }); - mockedRealtimeNote = mockRealtimeNote( - mockedNote, - websocketDoc, - mockAwareness(), - ); + note = Mock.of({ id: mockedNoteId }); + realtimeNote = new RealtimeNote(note, mockedContent); revisionsService = Mock.of({ getLatestRevision: jest.fn(), @@ -108,18 +98,15 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') - .mockImplementation(() => mockedRealtimeNote); + .mockImplementation(() => realtimeNote); mockedAppConfig.persistInterval = 0; await expect( - realtimeNoteService.getOrCreateRealtimeNote(mockedNote), - ).resolves.toBe(mockedRealtimeNote); + realtimeNoteService.getOrCreateRealtimeNote(note), + ).resolves.toBe(realtimeNote); expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId); - expect(realtimeNoteStore.create).toHaveBeenCalledWith( - mockedNote, - mockedContent, - ); + expect(realtimeNoteStore.create).toHaveBeenCalledWith(note, mockedContent); expect(setIntervalSpy).not.toHaveBeenCalled(); }); @@ -129,10 +116,10 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') - .mockImplementation(() => mockedRealtimeNote); + .mockImplementation(() => realtimeNote); mockedAppConfig.persistInterval = 10; - await realtimeNoteService.getOrCreateRealtimeNote(mockedNote); + await realtimeNoteService.getOrCreateRealtimeNote(note); expect(setIntervalSpy).toHaveBeenCalledWith( expect.any(Function), @@ -146,11 +133,11 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') - .mockImplementation(() => mockedRealtimeNote); + .mockImplementation(() => realtimeNote); mockedAppConfig.persistInterval = 10; - await realtimeNoteService.getOrCreateRealtimeNote(mockedNote); - mockedRealtimeNote.emit('destroy'); + await realtimeNoteService.getOrCreateRealtimeNote(note); + realtimeNote.emit('destroy'); expect(deleteIntervalSpy).toHaveBeenCalled(); expect(clearIntervalSpy).toHaveBeenCalled(); }); @@ -162,7 +149,7 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); await expect( - realtimeNoteService.getOrCreateRealtimeNote(mockedNote), + realtimeNoteService.getOrCreateRealtimeNote(note), ).rejects.toBe(`Revision for note mockedNoteId not found.`); expect(realtimeNoteStore.create).not.toHaveBeenCalled(); expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId); @@ -174,53 +161,46 @@ describe('RealtimeNoteService', () => { jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') - .mockImplementation(() => mockedRealtimeNote); + .mockImplementation(() => realtimeNote); await expect( - realtimeNoteService.getOrCreateRealtimeNote(mockedNote), - ).resolves.toBe(mockedRealtimeNote); + realtimeNoteService.getOrCreateRealtimeNote(note), + ).resolves.toBe(realtimeNote); jest .spyOn(realtimeNoteStore, 'find') - .mockImplementation(() => mockedRealtimeNote); + .mockImplementation(() => realtimeNote); await expect( - realtimeNoteService.getOrCreateRealtimeNote(mockedNote), - ).resolves.toBe(mockedRealtimeNote); + realtimeNoteService.getOrCreateRealtimeNote(note), + ).resolves.toBe(realtimeNote); expect(realtimeNoteStore.create).toHaveBeenCalledTimes(1); }); it('saves a realtime note if it gets destroyed', async () => { mockGetLatestRevision(true); - const mockedCurrentContent = 'mockedCurrentContent'; jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined); jest .spyOn(realtimeNoteStore, 'create') - .mockImplementation(() => mockedRealtimeNote); - jest - .spyOn(websocketDoc, 'getCurrentContent') - .mockReturnValue(mockedCurrentContent); + .mockImplementation(() => realtimeNote); - await realtimeNoteService.getOrCreateRealtimeNote(mockedNote); + await realtimeNoteService.getOrCreateRealtimeNote(note); const createRevisionSpy = jest .spyOn(revisionsService, 'createRevision') .mockImplementation(() => Promise.resolve(Mock.of())); - mockedRealtimeNote.emit('beforeDestroy'); - expect(createRevisionSpy).toHaveBeenCalledWith( - mockedNote, - mockedCurrentContent, - ); + realtimeNote.emit('beforeDestroy'); + expect(createRevisionSpy).toHaveBeenCalledWith(note, mockedContent); }); it('destroys every realtime note on application shutdown', () => { jest .spyOn(realtimeNoteStore, 'getAllRealtimeNotes') - .mockReturnValue([mockedRealtimeNote]); + .mockReturnValue([realtimeNote]); - const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy'); + const destroySpy = jest.spyOn(realtimeNote, 'destroy'); realtimeNoteService.beforeApplicationShutdown(); diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts index 6836814cc..a4d0f982a 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.ts @@ -42,7 +42,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { this.revisionsService .createRevision( realtimeNote.getNote(), - realtimeNote.getYDoc().getCurrentContent(), + realtimeNote.getRealtimeDoc().getCurrentContent(), ) .catch((reason) => this.logger.error(reason)); } diff --git a/backend/src/realtime/realtime-note/realtime-note.spec.ts b/backend/src/realtime/realtime-note/realtime-note.spec.ts index e4099dfc3..2ad20e5b7 100644 --- a/backend/src/realtime/realtime-note/realtime-note.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-note.spec.ts @@ -3,39 +3,20 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { - encodeDocumentDeletedMessage, - encodeMetadataUpdatedMessage, -} from '@hedgedoc/commons'; +import { MessageType, RealtimeDoc } from '@hedgedoc/commons'; +import * as hedgedocCommonsModule from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; import { Note } from '../../notes/note.entity'; import { RealtimeNote } from './realtime-note'; -import { mockAwareness } from './test-utils/mock-awareness'; -import { mockConnection } from './test-utils/mock-connection'; -import { mockWebsocketDoc } from './test-utils/mock-websocket-doc'; -import * as websocketAwarenessModule from './websocket-awareness'; -import { WebsocketAwareness } from './websocket-awareness'; -import * as websocketDocModule from './websocket-doc'; -import { WebsocketDoc } from './websocket-doc'; +import { MockConnectionBuilder } from './test-utils/mock-connection'; + +jest.mock('@hedgedoc/commons'); describe('realtime note', () => { - let mockedDoc: WebsocketDoc; - let mockedAwareness: WebsocketAwareness; let mockedNote: Note; beforeEach(() => { - jest.resetAllMocks(); - jest.resetModules(); - mockedDoc = mockWebsocketDoc(); - mockedAwareness = mockAwareness(); - jest - .spyOn(websocketDocModule, 'WebsocketDoc') - .mockImplementation(() => mockedDoc); - jest - .spyOn(websocketAwarenessModule, 'WebsocketAwareness') - .mockImplementation(() => mockedAwareness); - mockedNote = Mock.of({ id: 4711 }); }); @@ -51,8 +32,7 @@ describe('realtime note', () => { it('can connect and disconnect clients', () => { const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = mockConnection(true); - sut.addClient(client1); + const client1 = new MockConnectionBuilder(sut).build(); expect(sut.getConnections()).toStrictEqual([client1]); expect(sut.hasConnections()).toBeTruthy(); sut.removeClient(client1); @@ -60,19 +40,22 @@ describe('realtime note', () => { expect(sut.hasConnections()).toBeFalsy(); }); - it('creates a y-doc and y-awareness', () => { - const sut = new RealtimeNote(mockedNote, 'nothing'); - expect(sut.getYDoc()).toBe(mockedDoc); - expect(sut.getAwareness()).toBe(mockedAwareness); + it('creates a y-doc', () => { + const initialContent = 'nothing'; + const mockedDoc = new RealtimeDoc(initialContent); + const docSpy = jest + .spyOn(hedgedocCommonsModule, 'RealtimeDoc') + .mockReturnValue(mockedDoc); + const sut = new RealtimeNote(mockedNote, initialContent); + expect(docSpy).toHaveBeenCalledWith(initialContent); + expect(sut.getRealtimeDoc()).toBe(mockedDoc); }); - it('destroys y-doc and y-awareness on self-destruction', () => { + it('destroys y-doc on self-destruction', () => { const sut = new RealtimeNote(mockedNote, 'nothing'); - const docDestroy = jest.spyOn(mockedDoc, 'destroy'); - const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy'); + const docDestroy = jest.spyOn(sut.getRealtimeDoc(), 'destroy'); sut.destroy(); expect(docDestroy).toHaveBeenCalled(); - expect(awarenessDestroy).toHaveBeenCalled(); }); it('emits destroy event on destruction', async () => { @@ -94,33 +77,38 @@ describe('realtime note', () => { it('announcePermissionChange to all clients', () => { const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = mockConnection(true); - sut.addClient(client1); - const client2 = mockConnection(true); - sut.addClient(client2); - const metadataMessage = encodeMetadataUpdatedMessage(); + + const client1 = new MockConnectionBuilder(sut).build(); + const client2 = new MockConnectionBuilder(sut).build(); + + const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage'); + const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage'); + + const metadataMessage = { type: MessageType.METADATA_UPDATED }; sut.announcePermissionChange(); - expect(client1.send).toHaveBeenCalledWith(metadataMessage); - expect(client2.send).toHaveBeenCalledWith(metadataMessage); + expect(sendMessage1Spy).toHaveBeenCalledWith(metadataMessage); + expect(sendMessage2Spy).toHaveBeenCalledWith(metadataMessage); sut.removeClient(client2); sut.announcePermissionChange(); - expect(client1.send).toHaveBeenCalledTimes(2); - expect(client2.send).toHaveBeenCalledTimes(1); + expect(sendMessage1Spy).toHaveBeenCalledTimes(2); + expect(sendMessage2Spy).toHaveBeenCalledTimes(1); }); it('announceNoteDeletion to all clients', () => { const sut = new RealtimeNote(mockedNote, 'nothing'); - const client1 = mockConnection(true); - sut.addClient(client1); - const client2 = mockConnection(true); - sut.addClient(client2); - const deletedMessage = encodeDocumentDeletedMessage(); + const client1 = new MockConnectionBuilder(sut).build(); + const client2 = new MockConnectionBuilder(sut).build(); + + const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage'); + const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage'); + + const deletedMessage = { type: MessageType.DOCUMENT_DELETED }; sut.announceNoteDeletion(); - expect(client1.send).toHaveBeenCalledWith(deletedMessage); - expect(client2.send).toHaveBeenCalledWith(deletedMessage); + expect(sendMessage1Spy).toHaveBeenCalledWith(deletedMessage); + expect(sendMessage2Spy).toHaveBeenCalledWith(deletedMessage); sut.removeClient(client2); sut.announceNoteDeletion(); - expect(client1.send).toHaveBeenCalledTimes(2); - expect(client2.send).toHaveBeenCalledTimes(1); + expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage); + expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage); }); }); diff --git a/backend/src/realtime/realtime-note/realtime-note.ts b/backend/src/realtime/realtime-note/realtime-note.ts index fb535368b..f6a1d8912 100644 --- a/backend/src/realtime/realtime-note/realtime-note.ts +++ b/backend/src/realtime/realtime-note/realtime-note.ts @@ -3,52 +3,51 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { - encodeDocumentDeletedMessage, - encodeMetadataUpdatedMessage, -} from '@hedgedoc/commons'; +import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons'; import { Logger } from '@nestjs/common'; import { EventEmitter2, EventMap } from 'eventemitter2'; -import { Awareness } from 'y-protocols/awareness'; import { Note } from '../../notes/note.entity'; -import { WebsocketAwareness } from './websocket-awareness'; -import { WebsocketConnection } from './websocket-connection'; -import { WebsocketDoc } from './websocket-doc'; +import { RealtimeConnection } from './realtime-connection'; -export interface MapType extends EventMap { +export interface RealtimeNoteEventMap extends EventMap { destroy: () => void; beforeDestroy: () => void; + clientAdded: (client: RealtimeConnection) => void; + clientRemoved: (client: RealtimeConnection) => void; + + yDocUpdate: (update: number[], origin: unknown) => void; } /** * Represents a note currently being edited by a number of clients. */ -export class RealtimeNote extends EventEmitter2 { +export class RealtimeNote extends EventEmitter2 { protected logger: Logger; - private readonly websocketDoc: WebsocketDoc; - private readonly websocketAwareness: WebsocketAwareness; - private readonly clients = new Set(); + private readonly doc: RealtimeDoc; + private readonly clients = new Set(); private isClosing = false; constructor(private readonly note: Note, initialContent: string) { super(); this.logger = new Logger(`${RealtimeNote.name} ${note.id}`); - this.websocketDoc = new WebsocketDoc(this, initialContent); - this.websocketAwareness = new WebsocketAwareness(this); - this.logger.debug(`New realtime session for note ${note.id} created.`); + this.doc = new RealtimeDoc(initialContent); + this.logger.debug( + `New realtime session for note ${note.id} created. Length of initial content: ${initialContent.length} characters`, + ); } /** * Connects a new client to the note. * - * For this purpose a {@link WebsocketConnection} is created and added to the client map. + * For this purpose a {@link RealtimeConnection} is created and added to the client map. * * @param client the websocket connection to the client */ - public addClient(client: WebsocketConnection): void { + public addClient(client: RealtimeConnection): void { this.clients.add(client); - this.logger.debug(`User '${client.getUsername()}' connected`); + this.logger.debug(`User '${client.getDisplayName()}' connected`); + this.emit('clientAdded', client); } /** @@ -56,13 +55,14 @@ export class RealtimeNote extends EventEmitter2 { * * @param {WebSocket} client The websocket client that disconnects. */ - public removeClient(client: WebsocketConnection): void { + public removeClient(client: RealtimeConnection): void { this.clients.delete(client); this.logger.debug( - `User '${client.getUsername()}' disconnected. ${ + `User '${client.getDisplayName()}' disconnected. ${ this.clients.size } clients left.`, ); + this.emit('clientRemoved', client); if (!this.hasConnections() && !this.isClosing) { this.destroy(); } @@ -80,9 +80,8 @@ export class RealtimeNote extends EventEmitter2 { this.logger.debug('Destroying realtime note.'); this.emit('beforeDestroy'); this.isClosing = true; - this.websocketDoc.destroy(); - this.websocketAwareness.destroy(); - this.clients.forEach((value) => value.disconnect()); + this.doc.destroy(); + this.clients.forEach((value) => value.getTransporter().disconnect()); this.emit('destroy'); } @@ -96,30 +95,21 @@ export class RealtimeNote extends EventEmitter2 { } /** - * Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note. + * Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note. * - * @return an array of {@link WebsocketConnection WebsocketConnections} + * @return an array of {@link RealtimeConnection WebsocketConnections} */ - public getConnections(): WebsocketConnection[] { + public getConnections(): RealtimeConnection[] { return [...this.clients]; } /** - * Get the {@link Doc YDoc} of the note. + * Get the {@link RealtimeDoc realtime note} of the note. * - * @return the {@link Doc YDoc} of the note + * @return the {@link RealtimeDoc realtime note} of the note */ - public getYDoc(): WebsocketDoc { - return this.websocketDoc; - } - - /** - * Get the {@link Awareness YAwareness} of the note. - * - * @return the {@link Awareness YAwareness} of the note - */ - public getAwareness(): Awareness { - return this.websocketAwareness; + public getRealtimeDoc(): RealtimeDoc { + return this.doc; } /** @@ -135,14 +125,14 @@ export class RealtimeNote extends EventEmitter2 { * Announce to all clients that the permissions of the note have been changed. */ public announcePermissionChange(): void { - this.sendToAllClients(encodeMetadataUpdatedMessage()); + this.sendToAllClients({ type: MessageType.METADATA_UPDATED }); } /** * Announce to all clients that the note has been deleted. */ public announceNoteDeletion(): void { - this.sendToAllClients(encodeDocumentDeletedMessage()); + this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED }); } /** @@ -150,9 +140,9 @@ export class RealtimeNote extends EventEmitter2 { * * @param {Uint8Array} content The binary message to broadcast */ - private sendToAllClients(content: Uint8Array): void { + private sendToAllClients(content: Message): void { this.getConnections().forEach((connection) => { - connection.send(content); + connection.getTransporter().sendMessage(content); }); } } diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts new file mode 100644 index 000000000..634e0476b --- /dev/null +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessageTransporter, MessageType } from '@hedgedoc/commons'; +import { Mock } from 'ts-mockery'; + +import { Note } from '../../notes/note.entity'; +import { RealtimeConnection } from './realtime-connection'; +import { RealtimeNote } from './realtime-note'; +import { MockConnectionBuilder } from './test-utils/mock-connection'; + +type SendMessageSpy = jest.SpyInstance< + void, + [Required] +>; + +describe('realtime user status adapter', () => { + let client1: RealtimeConnection; + let client2: RealtimeConnection; + let client3: RealtimeConnection; + let client4: RealtimeConnection; + + let sendMessage1Spy: SendMessageSpy; + let sendMessage2Spy: SendMessageSpy; + let sendMessage3Spy: SendMessageSpy; + let sendMessage4Spy: SendMessageSpy; + + let realtimeNote: RealtimeNote; + + const username1 = 'mock1'; + const username2 = 'mock2'; + const username3 = 'mock3'; + const username4 = 'mock4'; + + beforeEach(() => { + realtimeNote = new RealtimeNote( + Mock.of({ id: 9876 }), + 'mockedContent', + ); + client1 = new MockConnectionBuilder(realtimeNote) + .withRealtimeUserState() + .withUsername(username1) + .build(); + client2 = new MockConnectionBuilder(realtimeNote) + .withRealtimeUserState() + .withUsername(username2) + .build(); + client3 = new MockConnectionBuilder(realtimeNote) + .withRealtimeUserState() + .withUsername(username3) + .build(); + client4 = new MockConnectionBuilder(realtimeNote) + .withRealtimeUserState() + .withUsername(username4) + .build(); + + sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage'); + sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage'); + sendMessage3Spy = jest.spyOn(client3.getTransporter(), 'sendMessage'); + sendMessage4Spy = jest.spyOn(client4.getTransporter(), 'sendMessage'); + + client1.getTransporter().sendReady(); + client2.getTransporter().sendReady(); + client3.getTransporter().sendReady(); + //client 4 shouldn't be ready on purpose + }); + + it('can answer a state request', () => { + expect(sendMessage1Spy).toHaveBeenCalledTimes(0); + expect(sendMessage2Spy).toHaveBeenCalledTimes(0); + expect(sendMessage3Spy).toHaveBeenCalledTimes(0); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + + client1.getTransporter().emit(MessageType.REALTIME_USER_STATE_REQUEST); + + const expectedMessage1: Message = { + type: MessageType.REALTIME_USER_STATE_SET, + payload: [ + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 1, + username: username2, + displayName: username2, + }, + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 2, + username: username3, + displayName: username3, + }, + ], + }; + expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1); + expect(sendMessage2Spy).toHaveBeenCalledTimes(0); + expect(sendMessage3Spy).toHaveBeenCalledTimes(0); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + }); + + it('can save an cursor update', () => { + expect(sendMessage1Spy).toHaveBeenCalledTimes(0); + expect(sendMessage2Spy).toHaveBeenCalledTimes(0); + expect(sendMessage3Spy).toHaveBeenCalledTimes(0); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + + const newFrom = Math.floor(Math.random() * 100); + const newTo = Math.floor(Math.random() * 100); + + client1.getTransporter().emit(MessageType.REALTIME_USER_SINGLE_UPDATE, { + type: MessageType.REALTIME_USER_SINGLE_UPDATE, + payload: { + from: newFrom, + to: newTo, + }, + }); + + const expectedMessage2: Message = { + type: MessageType.REALTIME_USER_STATE_SET, + payload: [ + { + active: true, + cursor: { + from: newFrom, + to: newTo, + }, + styleIndex: 0, + username: username1, + displayName: username1, + }, + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 2, + username: username3, + displayName: username3, + }, + ], + }; + + const expectedMessage3: Message = { + type: MessageType.REALTIME_USER_STATE_SET, + payload: [ + { + active: true, + cursor: { + from: newFrom, + to: newTo, + }, + styleIndex: 0, + username: username1, + displayName: username1, + }, + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 1, + username: username2, + displayName: username2, + }, + ], + }; + + expect(sendMessage1Spy).toHaveBeenCalledTimes(0); + expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, expectedMessage2); + expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + }); + + it('will inform other clients about removed client', () => { + expect(sendMessage1Spy).toHaveBeenCalledTimes(0); + expect(sendMessage2Spy).toHaveBeenCalledTimes(0); + expect(sendMessage3Spy).toHaveBeenCalledTimes(0); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + + client2.getTransporter().disconnect(); + + const expectedMessage1: Message = { + type: MessageType.REALTIME_USER_STATE_SET, + payload: [ + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 2, + username: username3, + displayName: username3, + }, + ], + }; + + const expectedMessage3: Message = { + type: MessageType.REALTIME_USER_STATE_SET, + payload: [ + { + active: true, + cursor: { + from: 0, + to: 0, + }, + styleIndex: 0, + username: username1, + displayName: username1, + }, + ], + }; + + expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1); + expect(sendMessage2Spy).toHaveBeenCalledTimes(0); + expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3); + expect(sendMessage4Spy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts new file mode 100644 index 000000000..12ab29d53 --- /dev/null +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MessageType, RealtimeUser } from '@hedgedoc/commons'; +import { Listener } from 'eventemitter2'; + +import { RealtimeConnection } from './realtime-connection'; +import { RealtimeNote } from './realtime-note'; + +/** + * Saves the current realtime status of a specific client and sends updates of changes to other clients. + */ +export class RealtimeUserStatusAdapter { + private readonly realtimeUser: RealtimeUser; + + constructor( + username: string | null, + displayName: string, + private connection: RealtimeConnection, + ) { + this.realtimeUser = this.createInitialRealtimeUserState( + username, + displayName, + connection.getRealtimeNote(), + ); + this.bindRealtimeUserStateEvents(connection); + } + + private createInitialRealtimeUserState( + username: string | null, + displayName: string, + realtimeNote: RealtimeNote, + ): RealtimeUser { + return { + username: username, + displayName: displayName, + active: true, + styleIndex: this.findLeastUsedStyleIndex( + this.createStyleIndexToCountMap(realtimeNote), + ), + cursor: { + from: 0, + to: 0, + }, + }; + } + + private bindRealtimeUserStateEvents(connection: RealtimeConnection): void { + const realtimeNote = connection.getRealtimeNote(); + const transporterMessagesListener = connection.getTransporter().on( + MessageType.REALTIME_USER_SINGLE_UPDATE, + (message) => { + this.realtimeUser.cursor = message.payload; + this.sendRealtimeUserStatusUpdateEvent(connection); + }, + { objectify: true }, + ) as Listener; + + const transporterRequestMessageListener = connection.getTransporter().on( + MessageType.REALTIME_USER_STATE_REQUEST, + () => { + this.sendCompleteStateToClient(connection); + }, + { objectify: true }, + ) as Listener; + + const clientRemoveListener = realtimeNote.on( + 'clientRemoved', + (client: RealtimeConnection) => { + if (client === connection) { + this.sendRealtimeUserStatusUpdateEvent(connection); + } + }, + { + objectify: true, + }, + ) as Listener; + + connection.getTransporter().on('disconnected', () => { + transporterMessagesListener.off(); + transporterRequestMessageListener.off(); + clientRemoveListener.off(); + }); + } + + private sendRealtimeUserStatusUpdateEvent( + exceptClient: RealtimeConnection, + ): void { + this.collectAllConnectionsExcept(exceptClient).forEach( + this.sendCompleteStateToClient.bind(this), + ); + } + + private sendCompleteStateToClient(client: RealtimeConnection): void { + const payload = this.collectAllConnectionsExcept(client).map( + (client) => client.getRealtimeUserStateAdapter().realtimeUser, + ); + + client.getTransporter().sendMessage({ + type: MessageType.REALTIME_USER_STATE_SET, + payload, + }); + } + + private collectAllConnectionsExcept( + exceptClient: RealtimeConnection, + ): RealtimeConnection[] { + return this.connection + .getRealtimeNote() + .getConnections() + .filter( + (client) => + client !== exceptClient && client.getTransporter().isReady(), + ); + } + + private findLeastUsedStyleIndex(map: Map): number { + let leastUsedStyleIndex = 0; + let leastUsedStyleIndexCount = map.get(0) ?? 0; + for (let styleIndex = 0; styleIndex < 8; styleIndex++) { + const count = map.get(styleIndex) ?? 0; + if (count < leastUsedStyleIndexCount) { + leastUsedStyleIndexCount = count; + leastUsedStyleIndex = styleIndex; + } + } + return leastUsedStyleIndex; + } + + private createStyleIndexToCountMap( + realtimeNote: RealtimeNote, + ): Map { + return realtimeNote + .getConnections() + .map( + (connection) => + connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex, + ) + .reduce((map, styleIndex) => { + const count = (map.get(styleIndex) ?? 0) + 1; + map.set(styleIndex, count); + return map; + }, new Map()); + } +} diff --git a/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts b/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts deleted file mode 100644 index d67b36e76..000000000 --- a/backend/src/realtime/realtime-note/test-utils/mock-awareness.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Observable } from 'lib0/observable'; -import { Mock } from 'ts-mockery'; - -import { WebsocketAwareness } from '../websocket-awareness'; - -class MockAwareness extends Observable { - destroy(): void { - //intentionally left blank - } -} - -/** - * Provides a partial mock for {@link WebsocketAwareness}. - */ -export function mockAwareness(): WebsocketAwareness { - return Mock.from(new MockAwareness()); -} diff --git a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts index 86810f56e..b64758af0 100644 --- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts +++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -3,21 +3,61 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { + MockedBackendMessageTransporter, + YDocSyncServerAdapter, +} from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; import { User } from '../../../users/user.entity'; -import { WebsocketConnection } from '../websocket-connection'; +import { RealtimeConnection } from '../realtime-connection'; +import { RealtimeNote } from '../realtime-note'; +import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter'; -/** - * Provides a partial mock for {@link WebsocketConnection}. - * - * @param synced Defines the return value for the `isSynced` function. - */ -export function mockConnection(synced: boolean): WebsocketConnection { - return Mock.of({ - isSynced: jest.fn(() => synced), - send: jest.fn(), - getUser: jest.fn(() => Mock.of({ username: 'mockedUser' })), - getUsername: jest.fn(() => 'mocked user'), - }); +export class MockConnectionBuilder { + private username = 'mock'; + private includeRealtimeUserState = false; + + constructor(private readonly realtimeNote: RealtimeNote) {} + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withRealtimeUserState(): this { + this.includeRealtimeUserState = true; + return this; + } + + public build(): RealtimeConnection { + const transporter = new MockedBackendMessageTransporter(''); + let realtimeUserStateAdapter: RealtimeUserStatusAdapter = + Mock.of(); + + const connection = Mock.of({ + getUser: jest.fn(() => Mock.of({ username: this.username })), + getDisplayName: jest.fn(() => this.username), + getSyncAdapter: jest.fn(() => Mock.of({})), + getTransporter: jest.fn(() => transporter), + getRealtimeUserStateAdapter: () => realtimeUserStateAdapter, + getRealtimeNote: () => this.realtimeNote, + }); + + transporter.on('disconnected', () => + this.realtimeNote.removeClient(connection), + ); + + if (this.includeRealtimeUserState) { + realtimeUserStateAdapter = new RealtimeUserStatusAdapter( + this.username, + this.username, + connection, + ); + } + + this.realtimeNote.addClient(connection); + + return connection; + } } diff --git a/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts b/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts deleted file mode 100644 index ef95809b0..000000000 --- a/backend/src/realtime/realtime-note/test-utils/mock-realtime-note.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { EventEmitter2 } from 'eventemitter2'; -import { Mock } from 'ts-mockery'; - -import { Note } from '../../../notes/note.entity'; -import { MapType, RealtimeNote } from '../realtime-note'; -import { WebsocketAwareness } from '../websocket-awareness'; -import { WebsocketDoc } from '../websocket-doc'; -import { mockAwareness } from './mock-awareness'; -import { mockWebsocketDoc } from './mock-websocket-doc'; - -class MockRealtimeNote extends EventEmitter2 { - constructor( - private note: Note, - private doc: WebsocketDoc, - private awareness: WebsocketAwareness, - ) { - super(); - } - - public getNote(): Note { - return this.note; - } - - public getYDoc(): WebsocketDoc { - return this.doc; - } - - public getAwareness(): WebsocketAwareness { - return this.awareness; - } - - public removeClient(): void { - //left blank for mock - } - - public destroy(): void { - //left blank for mock - } -} - -/** - * Provides a partial mock for {@link RealtimeNote} - * @param doc Defines the return value for `getYDoc` - * @param awareness Defines the return value for `getAwareness` - */ -export function mockRealtimeNote( - note?: Note, - doc?: WebsocketDoc, - awareness?: WebsocketAwareness, -): RealtimeNote { - return Mock.from( - new MockRealtimeNote( - note ?? Mock.of(), - doc ?? mockWebsocketDoc(), - awareness ?? mockAwareness(), - ), - ); -} diff --git a/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts b/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts deleted file mode 100644 index 1122c56f8..000000000 --- a/backend/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Mock } from 'ts-mockery'; - -import { WebsocketDoc } from '../websocket-doc'; - -/** - * Provides a partial mock for {@link WebsocketDoc}. - */ -export function mockWebsocketDoc(): WebsocketDoc { - return Mock.of({ - on: jest.fn(), - destroy: jest.fn(), - getCurrentContent: jest.fn(), - }); -} diff --git a/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts b/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts deleted file mode 100644 index b5ea303f6..000000000 --- a/backend/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { WebsocketTransporter } from '@hedgedoc/commons'; -import { EventEmitter2 } from 'eventemitter2'; -import { Mock } from 'ts-mockery'; - -class MockMessageTransporter extends EventEmitter2 { - setupWebsocket(): void { - //intentionally left blank - } - - send(): void { - //intentionally left blank - } - - isSynced(): boolean { - return false; - } - - disconnect(): void { - //intentionally left blank - } -} - -/** - * Provides a partial mock for {@link WebsocketTransporter}. - */ -export function mockWebsocketTransporter(): WebsocketTransporter { - return Mock.from(new MockMessageTransporter()); -} diff --git a/backend/src/realtime/realtime-note/websocket-awareness.spec.ts b/backend/src/realtime/realtime-note/websocket-awareness.spec.ts deleted file mode 100644 index e27c7f917..000000000 --- a/backend/src/realtime/realtime-note/websocket-awareness.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import * as hedgedocRealtimeModule from '@hedgedoc/commons'; -import { Mock } from 'ts-mockery'; - -import { RealtimeNote } from './realtime-note'; -import { mockConnection } from './test-utils/mock-connection'; -import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness'; -import { WebsocketConnection } from './websocket-connection'; -import { WebsocketDoc } from './websocket-doc'; - -jest.mock('@hedgedoc/commons'); - -describe('websocket-awareness', () => { - it('distributes content updates to other synced clients', () => { - const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]); - const mockedEncodeUpdateFunction = jest.spyOn( - hedgedocRealtimeModule, - 'encodeAwarenessUpdateMessage', - ); - mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate); - - const mockConnection1 = mockConnection(true); - const mockConnection2 = mockConnection(false); - const mockConnection3 = mockConnection(true); - const send1 = jest.spyOn(mockConnection1, 'send'); - const send2 = jest.spyOn(mockConnection2, 'send'); - const send3 = jest.spyOn(mockConnection3, 'send'); - - const realtimeNote = Mock.of({ - getYDoc(): WebsocketDoc { - return Mock.of({ - on() { - //mocked - }, - }); - }, - getConnections(): WebsocketConnection[] { - return [mockConnection1, mockConnection2, mockConnection3]; - }, - }); - - const websocketAwareness = new WebsocketAwareness(realtimeNote); - const mockUpdate: ClientIdUpdate = { - added: [1], - updated: [2], - removed: [3], - }; - websocketAwareness.emit('update', [mockUpdate, mockConnection1]); - expect(send1).not.toHaveBeenCalled(); - expect(send2).not.toHaveBeenCalled(); - expect(send3).toHaveBeenCalledWith(mockEncodedUpdate); - expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith( - websocketAwareness, - [1, 2, 3], - ); - websocketAwareness.destroy(); - }); -}); diff --git a/backend/src/realtime/realtime-note/websocket-awareness.ts b/backend/src/realtime/realtime-note/websocket-awareness.ts deleted file mode 100644 index a9d0a963c..000000000 --- a/backend/src/realtime/realtime-note/websocket-awareness.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeAwarenessUpdateMessage } from '@hedgedoc/commons'; -import { Awareness } from 'y-protocols/awareness'; - -import { RealtimeNote } from './realtime-note'; - -export interface ClientIdUpdate { - added: number[]; - updated: number[]; - removed: number[]; -} - -/** - * This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving. - */ -export class WebsocketAwareness extends Awareness { - constructor(private realtimeNote: RealtimeNote) { - super(realtimeNote.getYDoc()); - this.setLocalState(null); - this.on('update', this.distributeAwarenessUpdate.bind(this)); - } - - /** - * Distributes the given awareness changes to all clients. - * - * @param added Properties that were added to the awareness state - * @param updated Properties that were updated in the awareness state - * @param removed Properties that were removed from the awareness state - * @param origin An object that is used as reference for the origin of the update - */ - private distributeAwarenessUpdate( - { added, updated, removed }: ClientIdUpdate, - origin: unknown, - ): void { - const binaryUpdate = encodeAwarenessUpdateMessage(this, [ - ...added, - ...updated, - ...removed, - ]); - this.realtimeNote - .getConnections() - .filter((client) => client !== origin && client.isSynced()) - .forEach((client) => client.send(binaryUpdate)); - } -} diff --git a/backend/src/realtime/realtime-note/websocket-connection.spec.ts b/backend/src/realtime/realtime-note/websocket-connection.spec.ts deleted file mode 100644 index eac4155f4..000000000 --- a/backend/src/realtime/realtime-note/websocket-connection.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import * as hedgedocRealtimeModule from '@hedgedoc/commons'; -import { WebsocketTransporter } from '@hedgedoc/commons'; -import { Mock } from 'ts-mockery'; -import WebSocket from 'ws'; -import * as yProtocolsAwarenessModule from 'y-protocols/awareness'; - -import { Note } from '../../notes/note.entity'; -import { User } from '../../users/user.entity'; -import * as realtimeNoteModule from './realtime-note'; -import { RealtimeNote } from './realtime-note'; -import { mockAwareness } from './test-utils/mock-awareness'; -import { mockRealtimeNote } from './test-utils/mock-realtime-note'; -import { mockWebsocketDoc } from './test-utils/mock-websocket-doc'; -import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter'; -import * as websocketAwarenessModule from './websocket-awareness'; -import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness'; -import { WebsocketConnection } from './websocket-connection'; -import * as websocketDocModule from './websocket-doc'; -import { WebsocketDoc } from './websocket-doc'; - -import SpyInstance = jest.SpyInstance; - -jest.mock('@hedgedoc/commons'); - -describe('websocket connection', () => { - let mockedDoc: WebsocketDoc; - let mockedAwareness: WebsocketAwareness; - let mockedRealtimeNote: RealtimeNote; - let mockedWebsocket: WebSocket; - let mockedUser: User; - let mockedWebsocketTransporter: WebsocketTransporter; - let removeAwarenessSpy: SpyInstance; - - beforeEach(() => { - jest.resetAllMocks(); - jest.resetModules(); - mockedDoc = mockWebsocketDoc(); - mockedAwareness = mockAwareness(); - mockedRealtimeNote = mockRealtimeNote( - Mock.of(), - mockedDoc, - mockedAwareness, - ); - mockedWebsocket = Mock.of({}); - mockedUser = Mock.of({}); - mockedWebsocketTransporter = mockWebsocketTransporter(); - - jest - .spyOn(realtimeNoteModule, 'RealtimeNote') - .mockImplementation(() => mockedRealtimeNote); - jest - .spyOn(websocketDocModule, 'WebsocketDoc') - .mockImplementation(() => mockedDoc); - jest - .spyOn(websocketAwarenessModule, 'WebsocketAwareness') - .mockImplementation(() => mockedAwareness); - jest - .spyOn(hedgedocRealtimeModule, 'WebsocketTransporter') - .mockImplementation(() => mockedWebsocketTransporter); - - removeAwarenessSpy = jest - .spyOn(yProtocolsAwarenessModule, 'removeAwarenessStates') - .mockImplementation(); - }); - - afterAll(() => { - jest.resetAllMocks(); - jest.resetModules(); - }); - - it('sets up the websocket in the constructor', () => { - const setupWebsocketSpy = jest.spyOn( - mockedWebsocketTransporter, - 'setupWebsocket', - ); - - new WebsocketConnection(mockedWebsocket, mockedUser, mockedRealtimeNote); - - expect(setupWebsocketSpy).toHaveBeenCalledWith(mockedWebsocket); - }); - - it('forwards sent messages to the transporter', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const sendFunctionSpy = jest.spyOn(mockedWebsocketTransporter, 'send'); - const sendContent = new Uint8Array(); - sut.send(sendContent); - expect(sendFunctionSpy).toHaveBeenCalledWith(sendContent); - }); - - it('forwards disconnect calls to the transporter', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const disconnectFunctionSpy = jest.spyOn( - mockedWebsocketTransporter, - 'disconnect', - ); - sut.disconnect(); - expect(disconnectFunctionSpy).toHaveBeenCalled(); - }); - - it('forwards isSynced checks to the transporter', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const isSyncedFunctionSpy = jest.spyOn( - mockedWebsocketTransporter, - 'isSynced', - ); - - expect(sut.isSynced()).toBe(false); - - isSyncedFunctionSpy.mockReturnValue(true); - expect(sut.isSynced()).toBe(true); - }); - - it('removes the client from the note on transporter disconnect', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient'); - - mockedWebsocketTransporter.emit('disconnected'); - - expect(removeClientSpy).toHaveBeenCalledWith(sut); - }); - - it('remembers the controlled awareness-ids on awareness update', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; - mockedAwareness.emit('update', [update, sut]); - - expect(sut.getControlledAwarenessIds()).toEqual(new Set([0])); - }); - - it("doesn't remembers the controlled awareness-ids of other connections on awareness update", () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; - mockedAwareness.emit('update', [update, Mock.of()]); - - expect(sut.getControlledAwarenessIds()).toEqual(new Set([])); - }); - - it('removes the controlled awareness ids on transport disconnect', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] }; - mockedAwareness.emit('update', [update, sut]); - - mockedWebsocketTransporter.emit('disconnected'); - - expect(removeAwarenessSpy).toHaveBeenCalledWith(mockedAwareness, [0], sut); - }); - - it('saves the correct user', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - expect(sut.getUser()).toBe(mockedUser); - }); - - it('returns the correct username', () => { - const mockedUserWithUsername = Mock.of({ username: 'MockUser' }); - - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUserWithUsername, - mockedRealtimeNote, - ); - - expect(sut.getUsername()).toBe('MockUser'); - }); - - it('returns a fallback if no username has been set', () => { - const sut = new WebsocketConnection( - mockedWebsocket, - mockedUser, - mockedRealtimeNote, - ); - - expect(sut.getUsername()).toBe('Guest'); - }); -}); diff --git a/backend/src/realtime/realtime-note/websocket-connection.ts b/backend/src/realtime/realtime-note/websocket-connection.ts deleted file mode 100644 index 1058d53fe..000000000 --- a/backend/src/realtime/realtime-note/websocket-connection.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { WebsocketTransporter } from '@hedgedoc/commons'; -import { Logger } from '@nestjs/common'; -import WebSocket from 'ws'; -import { Awareness, removeAwarenessStates } from 'y-protocols/awareness'; - -import { User } from '../../users/user.entity'; -import { RealtimeNote } from './realtime-note'; -import { ClientIdUpdate } from './websocket-awareness'; - -/** - * Manages the websocket connection to a specific client. - */ -export class WebsocketConnection { - protected readonly logger = new Logger(WebsocketConnection.name); - private controlledAwarenessIds: Set = new Set(); - private transporter: WebsocketTransporter; - - /** - * Instantiates the websocket connection wrapper for a websocket connection. - * - * @param websocket The client's raw websocket. - * @param user The user of the client - * @param realtimeNote The {@link RealtimeNote} that the client connected to. - * @throws Error if the socket is not open - */ - constructor( - websocket: WebSocket, - private user: User | null, - realtimeNote: RealtimeNote, - ) { - const awareness = realtimeNote.getAwareness(); - this.transporter = new WebsocketTransporter( - realtimeNote.getYDoc(), - awareness, - ); - this.transporter.on('disconnected', () => { - realtimeNote.removeClient(this); - }); - this.transporter.setupWebsocket(websocket); - this.bindAwarenessMessageEvents(awareness); - } - - /** - * Binds all additional events that are needed for awareness processing. - */ - private bindAwarenessMessageEvents(awareness: Awareness): void { - const callback = this.updateControlledAwarenessIds.bind(this); - awareness.on('update', callback); - this.transporter.on('disconnected', () => { - awareness.off('update', callback); - removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this); - }); - } - - private updateControlledAwarenessIds( - { added, removed }: ClientIdUpdate, - origin: WebsocketConnection, - ): void { - if (origin === this) { - added.forEach((id) => this.controlledAwarenessIds.add(id)); - removed.forEach((id) => this.controlledAwarenessIds.delete(id)); - } - } - - /** - * Defines if the current connection has received at least one full synchronisation. - */ - public isSynced(): boolean { - return this.transporter.isSynced(); - } - - /** - * Sends the given content to the client. - * - * @param content The content to send - */ - public send(content: Uint8Array): void { - this.transporter.send(content); - } - - /** - * Stops the connection - */ - public disconnect(): void { - this.transporter.disconnect(); - } - - public getControlledAwarenessIds(): ReadonlySet { - return this.controlledAwarenessIds; - } - - public getUser(): User | null { - return this.user; - } - - public getUsername(): string { - return this.getUser()?.username ?? 'Guest'; - } -} diff --git a/backend/src/realtime/realtime-note/websocket-doc.spec.ts b/backend/src/realtime/realtime-note/websocket-doc.spec.ts deleted file mode 100644 index 7458e2840..000000000 --- a/backend/src/realtime/realtime-note/websocket-doc.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import * as hedgedocRealtimeModule from '@hedgedoc/commons'; -import { Mock } from 'ts-mockery'; - -import { RealtimeNote } from './realtime-note'; -import { mockConnection } from './test-utils/mock-connection'; -import { WebsocketConnection } from './websocket-connection'; -import { WebsocketDoc } from './websocket-doc'; - -jest.mock('@hedgedoc/commons'); - -describe('websocket-doc', () => { - it('saves the initial content', () => { - const textContent = 'textContent'; - const websocketDoc = new WebsocketDoc(Mock.of(), textContent); - - expect(websocketDoc.getCurrentContent()).toBe(textContent); - }); - - it('distributes content updates to other synced clients', () => { - const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]); - const mockedEncodeUpdateFunction = jest.spyOn( - hedgedocRealtimeModule, - 'encodeDocumentUpdateMessage', - ); - mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate); - - const mockConnection1 = mockConnection(true); - const mockConnection2 = mockConnection(false); - const mockConnection3 = mockConnection(true); - - const send1 = jest.spyOn(mockConnection1, 'send'); - const send2 = jest.spyOn(mockConnection2, 'send'); - const send3 = jest.spyOn(mockConnection3, 'send'); - - const realtimeNote = Mock.of({ - getConnections(): WebsocketConnection[] { - return [mockConnection1, mockConnection2, mockConnection3]; - }, - getYDoc(): WebsocketDoc { - return websocketDoc; - }, - }); - - const websocketDoc = new WebsocketDoc(realtimeNote, ''); - const mockUpdate = new Uint8Array([4, 5, 6, 7]); - websocketDoc.emit('update', [mockUpdate, mockConnection1]); - expect(send1).not.toHaveBeenCalled(); - expect(send2).not.toHaveBeenCalled(); - expect(send3).toHaveBeenCalledWith(mockEncodedUpdate); - expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(mockUpdate); - websocketDoc.destroy(); - }); -}); diff --git a/backend/src/realtime/realtime-note/websocket-doc.ts b/backend/src/realtime/realtime-note/websocket-doc.ts deleted file mode 100644 index 9f515fef3..000000000 --- a/backend/src/realtime/realtime-note/websocket-doc.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - encodeDocumentUpdateMessage, - MARKDOWN_CONTENT_CHANNEL_NAME, -} from '@hedgedoc/commons'; -import { Doc } from 'yjs'; - -import { RealtimeNote } from './realtime-note'; -import { WebsocketConnection } from './websocket-connection'; - -/** - * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving. - */ -export class WebsocketDoc extends Doc { - /** - * Creates a new WebsocketDoc instance. - * - * The new instance is filled with the given initial content and an event listener will be registered to handle - * updates to the doc. - * - * @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc} - * @param initialContent - the initial content of the {@link Doc YDoc} - */ - constructor(private realtimeNote: RealtimeNote, initialContent: string) { - super(); - this.initializeContent(initialContent); - this.bindUpdateEvent(); - } - - /** - * Binds the event that distributes updates in the current {@link Doc y-doc} to all clients. - */ - private bindUpdateEvent(): void { - this.on('update', (update: Uint8Array, origin: WebsocketConnection) => { - const clients = this.realtimeNote - .getConnections() - .filter((client) => client !== origin && client.isSynced()); - if (clients.length > 0) { - clients.forEach((client) => { - client.send(encodeDocumentUpdateMessage(update)); - }); - } - }); - } - - /** - * Sets the {@link YDoc's Doc} content to include the initialContent. - * - * This message should only be called when a new {@link RealtimeNote } is created. - * - * @param initialContent - the initial content to set the {@link Doc YDoc's} content to. - * @private - */ - private initializeContent(initialContent: string): void { - this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).insert(0, initialContent); - } - - /** - * Gets the current content of the note as it's currently edited in realtime. - * - * Please be aware that the return of this method may be very quickly outdated. - * - * @return The current note content. - */ - public getCurrentContent(): string { - return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).toString(); - } -} diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index acd205510..2f5ab0d8c 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -40,15 +40,15 @@ import { Session } from '../../users/session.entity'; import { User } from '../../users/user.entity'; import { UsersModule } from '../../users/users.module'; import { UsersService } from '../../users/users.service'; +import * as websocketConnectionModule from '../realtime-note/realtime-connection'; +import { RealtimeConnection } from '../realtime-note/realtime-connection'; import { RealtimeNote } from '../realtime-note/realtime-note'; import { RealtimeNoteModule } from '../realtime-note/realtime-note.module'; import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; -import * as websocketConnectionModule from '../realtime-note/websocket-connection'; -import { WebsocketConnection } from '../realtime-note/websocket-connection'; import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url'; import { WebsocketGateway } from './websocket.gateway'; -import SpyInstance = jest.SpyInstance; +jest.mock('@hedgedoc/commons'); describe('Websocket gateway', () => { let gateway: WebsocketGateway; @@ -57,10 +57,10 @@ describe('Websocket gateway', () => { let notesService: NotesService; let realtimeNoteService: RealtimeNoteService; let permissionsService: PermissionsService; - let mockedWebsocketConnection: WebsocketConnection; + let mockedWebsocketConnection: RealtimeConnection; let mockedWebsocket: WebSocket; - let mockedWebsocketCloseSpy: SpyInstance; - let addClientSpy: SpyInstance; + let mockedWebsocketCloseSpy: jest.SpyInstance; + let addClientSpy: jest.SpyInstance; const mockedValidSessionCookie = 'mockedValidSessionCookie'; const mockedSessionIdWithUser = 'mockedSessionIdWithUser'; @@ -231,9 +231,9 @@ describe('Websocket gateway', () => { .spyOn(realtimeNoteService, 'getOrCreateRealtimeNote') .mockReturnValue(Promise.resolve(mockedRealtimeNote)); - mockedWebsocketConnection = Mock.of(); + mockedWebsocketConnection = Mock.of(); jest - .spyOn(websocketConnectionModule, 'WebsocketConnection') + .spyOn(websocketConnectionModule, 'RealtimeConnection') .mockReturnValue(mockedWebsocketConnection); mockedWebsocket = Mock.of({ diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index 10c9c225a..48ad651ac 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { WebsocketTransporter } from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; @@ -13,8 +14,8 @@ import { PermissionsService } from '../../permissions/permissions.service'; import { SessionService } from '../../session/session.service'; import { User } from '../../users/user.entity'; import { UsersService } from '../../users/users.service'; +import { RealtimeConnection } from '../realtime-note/realtime-connection'; import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; -import { WebsocketConnection } from '../realtime-note/websocket-connection'; import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url'; /** @@ -75,13 +76,17 @@ export class WebsocketGateway implements OnGatewayConnection { const realtimeNote = await this.realtimeNoteService.getOrCreateRealtimeNote(note); - const connection = new WebsocketConnection( - clientSocket, + const websocketTransporter = new WebsocketTransporter(); + const connection = new RealtimeConnection( + websocketTransporter, user, realtimeNote, ); + websocketTransporter.setWebsocket(clientSocket); realtimeNote.addClient(connection); + + websocketTransporter.sendReady(); } catch (error: unknown) { this.logger.error( `Error occurred while initializing: ${(error as Error).message}`, diff --git a/backend/tsconfig.json b/backend/tsconfig.json index c584ce5ef..3704bce5f 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -11,6 +11,7 @@ "baseUrl": "./", "incremental": true, "strict": true, - "strictPropertyInitialization": false + "strictPropertyInitialization": false, + "resolveJsonModule": true } } diff --git a/commons/package.json b/commons/package.json index 9eb5017e2..c5153e10b 100644 --- a/commons/package.json +++ b/commons/package.json @@ -30,6 +30,9 @@ "README.md", "dist/**" ], + "browserslist": [ + "node> 12" + ], "repository": { "type": "git", "url": "https://github.com/hedgedoc/hedgedoc.git" @@ -37,9 +40,7 @@ "dependencies": { "eventemitter2": "6.4.9", "isomorphic-ws": "5.0.0", - "lib0": "0.2.73", "ws": "8.13.0", - "y-protocols": "1.0.5", "yjs": "13.5.51" }, "devDependencies": { diff --git a/commons/src/connection-keep-alive-handler.ts b/commons/src/connection-keep-alive-handler.ts deleted file mode 100644 index e3b80ff1b..000000000 --- a/commons/src/connection-keep-alive-handler.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './messages/message-type.enum.js' -import type { YDocMessageTransporter } from './y-doc-message-transporter.js' -import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding' - -/** - * Provides a keep alive ping for a given {@link WebSocket websocket} connection by sending a periodic message. - */ -export class ConnectionKeepAliveHandler { - private pongReceived = false - private static readonly pingTimeout = 30 * 1000 - private intervalId: NodeJS.Timer | undefined - - /** - * Constructs the instance and starts the interval. - * - * @param messageTransporter The websocket to keep alive - */ - constructor(private messageTransporter: YDocMessageTransporter) { - this.messageTransporter.on('disconnected', () => this.stopTimer()) - this.messageTransporter.on('ready', () => this.startTimer()) - this.messageTransporter.on(String(MessageType.PING), () => { - this.sendPongMessage() - }) - this.messageTransporter.on( - String(MessageType.PONG), - () => (this.pongReceived = true) - ) - } - - /** - * Starts the ping timer. - */ - public startTimer(): void { - this.pongReceived = false - this.intervalId = setInterval( - () => this.check(), - ConnectionKeepAliveHandler.pingTimeout - ) - this.sendPingMessage() - } - - public stopTimer(): void { - clearInterval(this.intervalId) - } - - /** - * Checks if a pong has been received since the last run. If not, the connection is probably dead and will be terminated. - */ - private check(): void { - if (this.pongReceived) { - this.pongReceived = false - this.sendPingMessage() - } else { - this.messageTransporter.disconnect() - console.error( - `No pong received in the last ${ConnectionKeepAliveHandler.pingTimeout} seconds. Connection seems to be dead.` - ) - } - } - - private sendPingMessage(): void { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.PING) - this.messageTransporter.send(toUint8Array(encoder)) - } - - private sendPongMessage(): void { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.PONG) - this.messageTransporter.send(toUint8Array(encoder)) - } -} diff --git a/commons/src/constants/markdown-content-channel-name.ts b/commons/src/constants/markdown-content-channel-name.ts deleted file mode 100644 index 7b36c2a92..000000000 --- a/commons/src/constants/markdown-content-channel-name.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent' diff --git a/commons/src/index.ts b/commons/src/index.ts index 2f2d63b45..cf60bf063 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -1,28 +1,14 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -export { MessageType } from './messages/message-type.enum.js' -export { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js' -export { YDocMessageTransporter } from './y-doc-message-transporter.js' -export { - applyAwarenessUpdateMessage, - encodeAwarenessUpdateMessage -} from './messages/awareness-update-message.js' -export { - applyDocumentUpdateMessage, - encodeDocumentUpdateMessage -} from './messages/document-update-message.js' -export { encodeCompleteAwarenessStateRequestMessage } from './messages/complete-awareness-state-request-message.js' -export { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js' -export { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js' -export { encodeDocumentDeletedMessage } from './messages/document-deleted-message.js' -export { encodeMetadataUpdatedMessage } from './messages/metadata-updated-message.js' -export { encodeServerVersionUpdatedMessage } from './messages/server-version-updated-message.js' - -export { WebsocketTransporter } from './websocket-transporter.js' +export * from './message-transporters/mocked-backend-message-transporter.js' +export * from './message-transporters/message.js' +export * from './message-transporters/message-transporter.js' +export * from './message-transporters/realtime-user.js' +export * from './message-transporters/websocket-transporter.js' export { parseUrl } from './utils/parse-url.js' export { @@ -30,8 +16,10 @@ export { WrongProtocolError } from './utils/errors.js' -export type { MessageTransporterEvents } from './y-doc-message-transporter.js' +export * from './y-doc-sync/y-doc-sync-client-adapter.js' +export * from './y-doc-sync/y-doc-sync-server-adapter.js' +export * from './y-doc-sync/y-doc-sync-adapter.js' export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to-finish.js' -export { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js' +export { RealtimeDoc } from './y-doc-sync/realtime-doc' diff --git a/commons/src/message-transporters/message-transporter.ts b/commons/src/message-transporters/message-transporter.ts new file mode 100644 index 000000000..7b0b0d03c --- /dev/null +++ b/commons/src/message-transporters/message-transporter.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessagePayloads, MessageType } from './message.js' +import { EventEmitter2, Listener } from 'eventemitter2' + +export type MessageEvents = MessageType | 'connected' | 'disconnected' + +type MessageEventPayloadMap = { + [E in MessageEvents]: E extends keyof MessagePayloads + ? (message: Message) => void + : () => void +} + +export enum ConnectionState { + DISCONNECT, + CONNECTING, + CONNECTED +} + +/** + * Base class for event based message communication. + */ +export abstract class MessageTransporter extends EventEmitter2 { + private readyMessageReceived = false + + public abstract sendMessage(content: Message): void + + protected receiveMessage(message: Message): void { + if (message.type === MessageType.READY) { + this.readyMessageReceived = true + } + this.emit(message.type, message) + } + + public sendReady(): void { + this.sendMessage({ + type: MessageType.READY + }) + } + + public abstract disconnect(): void + + public abstract getConnectionState(): ConnectionState + + protected onConnected(): void { + this.emit('connected') + } + + protected onDisconnecting(): void { + this.readyMessageReceived = false + this.emit('disconnected') + } + + /** + * Indicates if the message transporter is connected and can send/receive messages. + */ + public isConnected(): boolean { + return this.getConnectionState() === ConnectionState.CONNECTED + } + + /** + * Indicates if the message transporter has receives a {@link MessageType.READY ready message} yet. + */ + public isReady(): boolean { + return this.readyMessageReceived + } + + /** + * Executes the given callback whenever the message transporter receives a ready message. + * If the messenger has already received a ready message then the callback will be executed immediately. + * + * @param callback The callback to execute when ready + * @return The event listener that waits for ready messages + */ + public doAsSoonAsReady(callback: () => void): Listener { + if (this.readyMessageReceived) { + callback() + } + return this.on(MessageType.READY, callback, { + objectify: true + }) as Listener + } + + /** + * Executes the given callback whenever the message transporter has established a connection. + * If the messenger is already connected then the callback will be executed immediately. + * + * @param callback The callback to execute when connected + * @return The event listener that waits for connection events + */ + public doAsSoonAsConnected(callback: () => void): Listener { + if (this.isConnected()) { + callback() + } + return this.on('connected', callback, { + objectify: true + }) as Listener + } +} diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts new file mode 100644 index 000000000..5ddfe1a03 --- /dev/null +++ b/commons/src/message-transporters/message.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { RealtimeUser, RemoteCursor } from './realtime-user.js' + +export enum MessageType { + NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST', + NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE', + PING = 'PING', + PONG = 'PONG', + METADATA_UPDATED = 'METADATA_UPDATED', + DOCUMENT_DELETED = 'DOCUMENT_DELETED', + SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED', + REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET', + REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE', + REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST', + READY = 'READY' +} + +export interface MessagePayloads { + [MessageType.NOTE_CONTENT_STATE_REQUEST]: number[] + [MessageType.NOTE_CONTENT_UPDATE]: number[] + [MessageType.REALTIME_USER_STATE_SET]: RealtimeUser[] + [MessageType.REALTIME_USER_SINGLE_UPDATE]: RemoteCursor +} + +export type Message = T extends keyof MessagePayloads + ? { + type: T + payload: MessagePayloads[T] + } + : { + type: T + } diff --git a/commons/src/message-transporters/mocked-backend-message-transporter.ts b/commons/src/message-transporters/mocked-backend-message-transporter.ts new file mode 100644 index 000000000..031da80c0 --- /dev/null +++ b/commons/src/message-transporters/mocked-backend-message-transporter.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { RealtimeDoc } from '../y-doc-sync/realtime-doc.js' +import { ConnectionState, MessageTransporter } from './message-transporter.js' +import { Message, MessageType } from './message.js' +import { Doc, encodeStateAsUpdate } from 'yjs' + +/** + * A mocked connection that doesn't send or receive any data and is instantly ready. + * The only exception is the note content state request that is answered with the given initial content. + */ +export class MockedBackendMessageTransporter extends MessageTransporter { + private readonly doc: Doc + + private connected = true + + constructor(initialContent: string) { + super() + this.doc = new RealtimeDoc(initialContent) + + this.onConnected() + } + + disconnect(): void { + if (!this.connected) { + return + } + this.connected = false + this.onDisconnecting() + } + + sendReady() { + this.receiveMessage({ + type: MessageType.READY + }) + } + + sendMessage(content: Message) { + if (content.type === MessageType.NOTE_CONTENT_STATE_REQUEST) { + setTimeout(() => { + const payload = Array.from( + encodeStateAsUpdate(this.doc, new Uint8Array(content.payload)) + ) + this.receiveMessage({ type: MessageType.NOTE_CONTENT_UPDATE, payload }) + }, 10) + } + } + + getConnectionState(): ConnectionState { + return this.connected + ? ConnectionState.CONNECTED + : ConnectionState.DISCONNECT + } +} diff --git a/commons/src/message-transporters/realtime-user.ts b/commons/src/message-transporters/realtime-user.ts new file mode 100644 index 000000000..b6d22fe63 --- /dev/null +++ b/commons/src/message-transporters/realtime-user.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface RealtimeUser { + displayName: string + username: string | null + active: boolean + styleIndex: number + cursor: RemoteCursor +} + +export interface RemoteCursor { + from: number + to?: number +} diff --git a/commons/src/message-transporters/websocket-transporter.ts b/commons/src/message-transporters/websocket-transporter.ts new file mode 100644 index 000000000..0ed1fdf8a --- /dev/null +++ b/commons/src/message-transporters/websocket-transporter.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConnectionState, MessageTransporter } from './message-transporter.js' +import { Message, MessageType } from './message.js' +import WebSocket, { CloseEvent, ErrorEvent, MessageEvent } from 'isomorphic-ws' + +export class WebsocketTransporter extends MessageTransporter { + private websocket: WebSocket | undefined + + private messageCallback: undefined | ((event: MessageEvent) => void) + private errorCallback: undefined | ((event: ErrorEvent) => void) + private closeCallback: undefined | ((event: CloseEvent) => void) + + constructor() { + super() + } + + public setWebsocket(websocket: WebSocket) { + if ( + websocket.readyState === WebSocket.CLOSED || + websocket.readyState === WebSocket.CLOSING + ) { + throw new Error('Websocket must be open') + } + this.undbindEventsFromPreviousWebsocket() + this.websocket = websocket + this.bindWebsocketEvents(websocket) + + if (this.websocket.readyState === WebSocket.OPEN) { + this.onConnected() + } else { + this.websocket.addEventListener('open', this.onConnected.bind(this)) + } + } + + private undbindEventsFromPreviousWebsocket() { + if (this.websocket) { + if (this.messageCallback) { + this.websocket.removeEventListener('message', this.messageCallback) + } + if (this.errorCallback) { + this.websocket.removeEventListener('error', this.errorCallback) + } + if (this.closeCallback) { + this.websocket.removeEventListener('close', this.closeCallback) + } + } + } + + private bindWebsocketEvents(websocket: WebSocket) { + this.messageCallback = this.processMessageEvent.bind(this) + this.errorCallback = this.disconnect.bind(this) + this.closeCallback = this.onDisconnecting.bind(this) + + websocket.addEventListener('message', this.messageCallback) + websocket.addEventListener('error', this.errorCallback) + websocket.addEventListener('close', this.closeCallback) + } + + private processMessageEvent(event: MessageEvent): void { + if (typeof event.data !== 'string') { + return + } + const message = JSON.parse(event.data) as Message + this.receiveMessage(message) + } + + public disconnect(): void { + this.websocket?.close() + } + + public sendMessage(content: Message): void { + if (this.websocket?.readyState !== WebSocket.OPEN) { + throw new Error("Can't send message over non-open socket") + } + + try { + this.websocket.send(JSON.stringify(content)) + } catch (error: unknown) { + this.disconnect() + throw error + } + } + + public getConnectionState(): ConnectionState { + if (this.websocket?.readyState === WebSocket.OPEN) { + return ConnectionState.CONNECTED + } else if (this.websocket?.readyState === WebSocket.CONNECTING) { + return ConnectionState.CONNECTING + } else { + return ConnectionState.DISCONNECT + } + } +} diff --git a/commons/src/messages/awareness-update-message.ts b/commons/src/messages/awareness-update-message.ts deleted file mode 100644 index 930964042..000000000 --- a/commons/src/messages/awareness-update-message.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './message-type.enum.js' -import type { Decoder } from 'lib0/decoding' -import { readVarUint8Array } from 'lib0/decoding' -import { - createEncoder, - toUint8Array, - writeVarUint, - writeVarUint8Array -} from 'lib0/encoding' -import type { Awareness } from 'y-protocols/awareness' -import { - applyAwarenessUpdate, - encodeAwarenessUpdate -} from 'y-protocols/awareness' - -export function applyAwarenessUpdateMessage( - decoder: Decoder, - awareness: Awareness, - origin: unknown -): void { - applyAwarenessUpdate(awareness, readVarUint8Array(decoder), origin) -} - -export function encodeAwarenessUpdateMessage( - awareness: Awareness, - updatedClientIds: number[] -): Uint8Array { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.AWARENESS_UPDATE) - writeVarUint8Array( - encoder, - encodeAwarenessUpdate(awareness, updatedClientIds) - ) - return toUint8Array(encoder) -} diff --git a/commons/src/messages/complete-awareness-state-request-message.ts b/commons/src/messages/complete-awareness-state-request-message.ts deleted file mode 100644 index 595133cbc..000000000 --- a/commons/src/messages/complete-awareness-state-request-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeCompleteAwarenessStateRequestMessage(): Uint8Array { - return encodeGenericMessage(MessageType.COMPLETE_AWARENESS_STATE_REQUEST) -} diff --git a/commons/src/messages/complete-document-state-answer-message.ts b/commons/src/messages/complete-document-state-answer-message.ts deleted file mode 100644 index 1809a196c..000000000 --- a/commons/src/messages/complete-document-state-answer-message.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './message-type.enum.js' -import { decoding } from 'lib0' -import { Decoder } from 'lib0/decoding' -import { - createEncoder, - toUint8Array, - writeVarUint, - writeVarUint8Array -} from 'lib0/encoding' -import type { Doc } from 'yjs' -import { encodeStateAsUpdate } from 'yjs' - -export function encodeCompleteDocumentStateAnswerMessage( - doc: Doc, - decoder: Decoder -): Uint8Array { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_ANSWER) - writeVarUint8Array( - encoder, - encodeStateAsUpdate(doc, decoding.readVarUint8Array(decoder)) - ) - return toUint8Array(encoder) -} diff --git a/commons/src/messages/complete-document-state-request-message.ts b/commons/src/messages/complete-document-state-request-message.ts deleted file mode 100644 index e01cc5f00..000000000 --- a/commons/src/messages/complete-document-state-request-message.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './message-type.enum.js' -import { - createEncoder, - toUint8Array, - writeVarUint, - writeVarUint8Array -} from 'lib0/encoding' -import type { Doc } from 'yjs' -import { encodeStateVector } from 'yjs' - -export function encodeCompleteDocumentStateRequestMessage( - doc: Doc -): Uint8Array { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_REQUEST) - writeVarUint8Array(encoder, encodeStateVector(doc)) - return toUint8Array(encoder) -} diff --git a/commons/src/messages/document-deleted-message.ts b/commons/src/messages/document-deleted-message.ts deleted file mode 100644 index bbdb3d195..000000000 --- a/commons/src/messages/document-deleted-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeDocumentDeletedMessage(): Uint8Array { - return encodeGenericMessage(MessageType.DOCUMENT_DELETED) -} diff --git a/commons/src/messages/document-update-message.ts b/commons/src/messages/document-update-message.ts deleted file mode 100644 index f7b365b8a..000000000 --- a/commons/src/messages/document-update-message.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './message-type.enum.js' -import { readVarUint8Array } from 'lib0/decoding' -import type { Decoder } from 'lib0/decoding.js' -import { - createEncoder, - toUint8Array, - writeVarUint, - writeVarUint8Array -} from 'lib0/encoding' -import type { Doc } from 'yjs' -import { applyUpdate } from 'yjs' - -export function applyDocumentUpdateMessage( - decoder: Decoder, - doc: Doc, - origin: unknown -): void { - applyUpdate(doc, readVarUint8Array(decoder), origin) -} - -export function encodeDocumentUpdateMessage( - documentUpdate: Uint8Array -): Uint8Array { - const encoder = createEncoder() - writeVarUint(encoder, MessageType.DOCUMENT_UPDATE) - writeVarUint8Array(encoder, documentUpdate) - return toUint8Array(encoder) -} diff --git a/commons/src/messages/generic-message.ts b/commons/src/messages/generic-message.ts deleted file mode 100644 index 14ff2de7b..000000000 --- a/commons/src/messages/generic-message.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MessageType } from './message-type.enum.js' -import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding' - -/** - * Encodes a generic message with a given message type but without content. - */ -export function encodeGenericMessage(messageType: MessageType): Uint8Array { - const encoder = createEncoder() - writeVarUint(encoder, messageType) - return toUint8Array(encoder) -} diff --git a/commons/src/messages/message-type.enum.ts b/commons/src/messages/message-type.enum.ts deleted file mode 100644 index f0369bd23..000000000 --- a/commons/src/messages/message-type.enum.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export enum MessageType { - COMPLETE_DOCUMENT_STATE_REQUEST = 0, - COMPLETE_DOCUMENT_STATE_ANSWER = 1, - DOCUMENT_UPDATE = 2, - AWARENESS_UPDATE = 3, - COMPLETE_AWARENESS_STATE_REQUEST = 4, - PING = 5, - PONG = 6, - READY_REQUEST = 7, - READY_ANSWER = 8, - METADATA_UPDATED = 9, - DOCUMENT_DELETED = 10, - SERVER_VERSION_UPDATED = 11 -} diff --git a/commons/src/messages/metadata-updated-message.ts b/commons/src/messages/metadata-updated-message.ts deleted file mode 100644 index 83d704a62..000000000 --- a/commons/src/messages/metadata-updated-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeMetadataUpdatedMessage(): Uint8Array { - return encodeGenericMessage(MessageType.METADATA_UPDATED) -} diff --git a/commons/src/messages/ready-answer-message.ts b/commons/src/messages/ready-answer-message.ts deleted file mode 100644 index 5edd74680..000000000 --- a/commons/src/messages/ready-answer-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeReadyAnswerMessage(): Uint8Array { - return encodeGenericMessage(MessageType.READY_ANSWER) -} diff --git a/commons/src/messages/ready-request-message.ts b/commons/src/messages/ready-request-message.ts deleted file mode 100644 index 2b1802006..000000000 --- a/commons/src/messages/ready-request-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeReadyRequestMessage(): Uint8Array { - return encodeGenericMessage(MessageType.READY_REQUEST) -} diff --git a/commons/src/messages/server-version-updated-message.ts b/commons/src/messages/server-version-updated-message.ts deleted file mode 100644 index 7b069e228..000000000 --- a/commons/src/messages/server-version-updated-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { encodeGenericMessage } from './generic-message.js' -import { MessageType } from './message-type.enum.js' - -export function encodeServerVersionUpdatedMessage(): Uint8Array { - return encodeGenericMessage(MessageType.SERVER_VERSION_UPDATED) -} diff --git a/commons/src/websocket-transporter.ts b/commons/src/websocket-transporter.ts deleted file mode 100644 index f8dd88a7d..000000000 --- a/commons/src/websocket-transporter.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js' -import { YDocMessageTransporter } from './y-doc-message-transporter.js' -import WebSocket from 'isomorphic-ws' -import { Awareness } from 'y-protocols/awareness' -import { Doc } from 'yjs' - -export class WebsocketTransporter extends YDocMessageTransporter { - private websocket: WebSocket | undefined - - constructor(doc: Doc, awareness: Awareness) { - super(doc, awareness) - new ConnectionKeepAliveHandler(this) - } - - public setupWebsocket(websocket: WebSocket) { - if ( - websocket.readyState === WebSocket.CLOSED || - websocket.readyState === WebSocket.CLOSING - ) { - throw new Error(`Socket is closed`) - } - this.websocket = websocket - websocket.binaryType = 'arraybuffer' - websocket.addEventListener('message', (event) => - this.decodeMessage(event.data as ArrayBuffer) - ) - websocket.addEventListener('error', () => this.disconnect()) - websocket.addEventListener('close', () => this.onClose()) - if (websocket.readyState === WebSocket.OPEN) { - this.onOpen() - } else { - websocket.addEventListener('open', this.onOpen.bind(this)) - } - } - - public disconnect(): void { - this.websocket?.close() - } - - public send(content: Uint8Array): void { - if (this.websocket?.readyState !== WebSocket.OPEN) { - throw new Error("Can't send message over non-open socket") - } - - try { - this.websocket.send(content) - } catch (error: unknown) { - this.disconnect() - throw error - } - } - - public isWebSocketOpen(): boolean { - return this.websocket?.readyState === WebSocket.OPEN - } -} diff --git a/commons/src/y-doc-message-transporter.test.ts b/commons/src/y-doc-message-transporter.test.ts deleted file mode 100644 index aac6a8423..000000000 --- a/commons/src/y-doc-message-transporter.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js' -import { encodeDocumentUpdateMessage } from './messages/document-update-message.js' -import { MessageType } from './messages/message-type.enum.js' -import { YDocMessageTransporter } from './y-doc-message-transporter.js' -import { describe, expect, it } from '@jest/globals' -import { Awareness } from 'y-protocols/awareness' -import { Doc } from 'yjs' - -class InMemoryMessageTransporter extends YDocMessageTransporter { - private otherSide: InMemoryMessageTransporter | undefined - - constructor(private name: string, doc: Doc, awareness: Awareness) { - super(doc, awareness) - } - - public connect(other: InMemoryMessageTransporter): void { - this.setOtherSide(other) - other.setOtherSide(this) - this.onOpen() - other.onOpen() - } - - private setOtherSide(other: InMemoryMessageTransporter | undefined): void { - this.otherSide = other - } - - public disconnect(): void { - this.onClose() - this.setOtherSide(undefined) - this.otherSide?.onClose() - this.otherSide?.setOtherSide(undefined) - } - - send(content: Uint8Array): void { - if (this.otherSide === undefined) { - throw new Error('Disconnected') - } - console.debug(`${this.name}`, 'Sending', content) - this.otherSide?.decodeMessage(content) - } - - public onOpen(): void { - super.onOpen() - } -} - -describe('message transporter', () => - it('server client communication', () => { - const docServer: Doc = new Doc() - const docClient1: Doc = new Doc() - const docClient2: Doc = new Doc() - const dummyAwareness: Awareness = new Awareness(docServer) - - const textServer = docServer.getText(MARKDOWN_CONTENT_CHANNEL_NAME) - const textClient1 = docClient1.getText(MARKDOWN_CONTENT_CHANNEL_NAME) - const textClient2 = docClient2.getText(MARKDOWN_CONTENT_CHANNEL_NAME) - textServer.insert(0, 'This is a test note') - - textServer.observe(() => - console.debug('textServer', new Date(), textServer.toString()) - ) - textClient1.observe(() => - console.debug('textClient1', new Date(), textClient1.toString()) - ) - textClient2.observe(() => - console.debug('textClient2', new Date(), textClient2.toString()) - ) - - const transporterServerTo1 = new InMemoryMessageTransporter( - 's>1', - docServer, - dummyAwareness - ) - const transporterServerTo2 = new InMemoryMessageTransporter( - 's>2', - docServer, - dummyAwareness - ) - const transporterClient1 = new InMemoryMessageTransporter( - '1>s', - docClient1, - dummyAwareness - ) - const transporterClient2 = new InMemoryMessageTransporter( - '2>s', - docClient2, - dummyAwareness - ) - - transporterServerTo1.on(String(MessageType.DOCUMENT_UPDATE), () => - console.debug('Received DOCUMENT_UPDATE from client 1 to server') - ) - transporterServerTo2.on(String(MessageType.DOCUMENT_UPDATE), () => - console.debug('Received DOCUMENT_UPDATE from client 2 to server') - ) - transporterClient1.on(String(MessageType.DOCUMENT_UPDATE), () => - console.debug('Received DOCUMENT_UPDATE from server to client 1') - ) - transporterClient2.on(String(MessageType.DOCUMENT_UPDATE), () => - console.debug('Received DOCUMENT_UPDATE from server to client 2') - ) - - transporterServerTo1.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 1 to server' - ) - ) - transporterServerTo2.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 2 to server' - ) - ) - transporterClient1.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 1' - ) - ) - transporterClient2.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 2' - ) - ) - - transporterServerTo1.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 1 to server' - ) - ) - transporterServerTo2.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 2 to server' - ) - ) - transporterClient1.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 1' - ) - ) - transporterClient2.on( - String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST), - () => - console.debug( - 'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 2' - ) - ) - transporterClient1.on('ready', () => console.debug('Client 1 is ready')) - transporterClient2.on('ready', () => console.debug('Client 2 is ready')) - - docServer.on('update', (update: Uint8Array, origin: unknown) => { - const message = encodeDocumentUpdateMessage(update) - if (origin !== transporterServerTo1) { - console.debug('YDoc on Server updated. Sending to Client 1') - transporterServerTo1.send(message) - } - if (origin !== transporterServerTo2) { - console.debug('YDoc on Server updated. Sending to Client 2') - transporterServerTo2.send(message) - } - }) - docClient1.on('update', (update: Uint8Array, origin: unknown) => { - if (origin !== transporterClient1) { - console.debug('YDoc on client 1 updated. Sending to Server') - transporterClient1.send(encodeDocumentUpdateMessage(update)) - } - }) - docClient2.on('update', (update: Uint8Array, origin: unknown) => { - if (origin !== transporterClient2) { - console.debug('YDoc on client 2 updated. Sending to Server') - transporterClient2.send(encodeDocumentUpdateMessage(update)) - } - }) - - transporterClient1.connect(transporterServerTo1) - transporterClient2.connect(transporterServerTo2) - - textClient1.insert(0, 'test2') - textClient1.insert(0, 'test3') - textClient2.insert(0, 'test4') - - expect(textServer.toString()).toBe('test4test3test2This is a test note') - expect(textClient1.toString()).toBe('test4test3test2This is a test note') - expect(textClient2.toString()).toBe('test4test3test2This is a test note') - - dummyAwareness.destroy() - docServer.destroy() - docClient1.destroy() - docClient2.destroy() - })) diff --git a/commons/src/y-doc-message-transporter.ts b/commons/src/y-doc-message-transporter.ts deleted file mode 100644 index 3c64e0fac..000000000 --- a/commons/src/y-doc-message-transporter.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - applyAwarenessUpdateMessage, - encodeAwarenessUpdateMessage -} from './messages/awareness-update-message.js' -import { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js' -import { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js' -import { applyDocumentUpdateMessage } from './messages/document-update-message.js' -import { MessageType } from './messages/message-type.enum.js' -import { encodeReadyAnswerMessage } from './messages/ready-answer-message.js' -import { encodeReadyRequestMessage } from './messages/ready-request-message.js' -import { EventEmitter2 } from 'eventemitter2' -import { Decoder, readVarUint } from 'lib0/decoding' -import { Awareness } from 'y-protocols/awareness' -import { Doc } from 'yjs' - -export type Handler = (decoder: Decoder) => void - -export type MessageTransporterEvents = { - disconnected: () => void - connected: () => void - ready: () => void - synced: () => void -} & Partial> - -export abstract class YDocMessageTransporter extends EventEmitter2 { - private synced = false - - protected constructor( - protected readonly doc: Doc, - protected readonly awareness: Awareness - ) { - super() - this.on(String(MessageType.READY_REQUEST), () => { - this.send(encodeReadyAnswerMessage()) - }) - this.on(String(MessageType.READY_ANSWER), () => { - this.emit('ready') - }) - this.bindDocumentSyncMessageEvents(doc) - } - - public isSynced(): boolean { - return this.synced - } - - protected onOpen(): void { - this.emit('connected') - this.send(encodeReadyRequestMessage()) - } - - protected onClose(): void { - this.emit('disconnected') - } - - protected markAsSynced(): void { - if (!this.synced) { - this.synced = true - this.emit('synced') - } - } - - protected decodeMessage(buffer: ArrayBuffer): void { - const data = new Uint8Array(buffer) - const decoder = new Decoder(data) - const messageType = readVarUint(decoder) as MessageType - - switch (messageType) { - case MessageType.COMPLETE_DOCUMENT_STATE_REQUEST: - this.send(encodeCompleteDocumentStateAnswerMessage(this.doc, decoder)) - break - case MessageType.DOCUMENT_UPDATE: - applyDocumentUpdateMessage(decoder, this.doc, this) - break - case MessageType.COMPLETE_DOCUMENT_STATE_ANSWER: - applyDocumentUpdateMessage(decoder, this.doc, this) - this.markAsSynced() - break - case MessageType.COMPLETE_AWARENESS_STATE_REQUEST: - this.send( - encodeAwarenessUpdateMessage( - this.awareness, - Array.from(this.awareness.getStates().keys()) - ) - ) - break - case MessageType.AWARENESS_UPDATE: - applyAwarenessUpdateMessage(decoder, this.awareness, this) - } - - this.emit(String(messageType), decoder) - } - - private bindDocumentSyncMessageEvents(doc: Doc) { - this.on('ready', () => { - this.send(encodeCompleteDocumentStateRequestMessage(doc)) - }) - this.on('disconnected', () => (this.synced = false)) - } - - /** - * Sends binary data to the client. Closes the connection on errors. - * - * @param content The binary data to send. - */ - public abstract send(content: Uint8Array): void - - public abstract disconnect(): void -} diff --git a/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts new file mode 100644 index 000000000..6c7e158f0 --- /dev/null +++ b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + ConnectionState, + MessageTransporter +} from '../message-transporters/message-transporter.js' +import { Message, MessageType } from '../message-transporters/message.js' + +/** + * Message transporter for testing purposes that redirects message to another in memory connection message transporter instance. + */ +export class InMemoryConnectionMessageTransporter extends MessageTransporter { + private otherSide: InMemoryConnectionMessageTransporter | undefined + + constructor(private name: string) { + super() + } + + public connect(other: InMemoryConnectionMessageTransporter): void { + this.otherSide = other + other.otherSide = this + this.onConnected() + other.onConnected() + } + + public disconnect(): void { + this.onDisconnecting() + + if (this.otherSide) { + this.otherSide.onDisconnecting() + this.otherSide.otherSide = undefined + this.otherSide = undefined + } + } + + sendMessage(content: Message): void { + if (this.otherSide === undefined) { + throw new Error('Disconnected') + } + console.debug(`${this.name}`, 'Sending', content) + this.otherSide?.receiveMessage(content) + } + + getConnectionState(): ConnectionState { + return this.otherSide !== undefined + ? ConnectionState.CONNECTED + : ConnectionState.DISCONNECT + } +} diff --git a/commons/src/y-doc-sync/realtime-doc.test.ts b/commons/src/y-doc-sync/realtime-doc.test.ts new file mode 100644 index 000000000..f0eee8180 --- /dev/null +++ b/commons/src/y-doc-sync/realtime-doc.test.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { RealtimeDoc } from './realtime-doc.js' +import { describe, expect, it } from '@jest/globals' + +describe('websocket-doc', () => { + it('saves the initial content', () => { + const textContent = 'textContent' + const websocketDoc = new RealtimeDoc(textContent) + + expect(websocketDoc.getCurrentContent()).toBe(textContent) + }) +}) diff --git a/commons/src/y-doc-sync/realtime-doc.ts b/commons/src/y-doc-sync/realtime-doc.ts new file mode 100644 index 000000000..13bd7bdf8 --- /dev/null +++ b/commons/src/y-doc-sync/realtime-doc.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Doc } from 'yjs' +import { Text as YText } from 'yjs' + +const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent' + +/** + * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving. + */ +export class RealtimeDoc extends Doc { + /** + * Creates a new instance. + * + * The new instance is filled with the given initial content. + * + * @param initialContent - the initial content of the {@link Doc YDoc} + */ + constructor(initialContent?: string) { + super() + if (initialContent) { + this.getMarkdownContentChannel().insert(0, initialContent) + } + } + + /** + * Extracts the {@link YText text channel} that contains the markdown code. + * + * @return The markdown channel + */ + public getMarkdownContentChannel(): YText { + return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME) + } + + /** + * Gets the current content of the note as it's currently edited in realtime. + * + * Please be aware that the return of this method may be very quickly outdated. + * + * @return The current note content. + */ + public getCurrentContent(): string { + return this.getMarkdownContentChannel().toString() + } +} diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts new file mode 100644 index 000000000..ce14ea802 --- /dev/null +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.test.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessageType } from '../message-transporters/message.js' +import { InMemoryConnectionMessageTransporter } from './in-memory-connection-message.transporter.js' +import { RealtimeDoc } from './realtime-doc.js' +import { YDocSyncClientAdapter } from './y-doc-sync-client-adapter.js' +import { YDocSyncServerAdapter } from './y-doc-sync-server-adapter.js' +import { describe, expect, it } from '@jest/globals' + +describe('message transporter', () => { + it('server client communication', async () => { + const docServer: RealtimeDoc = new RealtimeDoc('This is a test note') + const docClient1: RealtimeDoc = new RealtimeDoc() + const docClient2: RealtimeDoc = new RealtimeDoc() + + const textServer = docServer.getMarkdownContentChannel() + const textClient1 = docClient1.getMarkdownContentChannel() + const textClient2 = docClient2.getMarkdownContentChannel() + + textServer.observe(() => + console.debug('textServer', new Date(), textServer.toString()) + ) + textClient1.observe(() => + console.debug('textClient1', new Date(), textClient1.toString()) + ) + textClient2.observe(() => + console.debug('textClient2', new Date(), textClient2.toString()) + ) + + const transporterServerTo1 = new InMemoryConnectionMessageTransporter('s>1') + const transporterServerTo2 = new InMemoryConnectionMessageTransporter('s>2') + const transporterClient1 = new InMemoryConnectionMessageTransporter('1>s') + const transporterClient2 = new InMemoryConnectionMessageTransporter('2>s') + + transporterServerTo1.on(MessageType.NOTE_CONTENT_UPDATE, () => + console.debug('Received NOTE_CONTENT_UPDATE from client 1 to server') + ) + transporterServerTo2.on(MessageType.NOTE_CONTENT_UPDATE, () => + console.debug('Received NOTE_CONTENT_UPDATE from client 2 to server') + ) + transporterClient1.on(MessageType.NOTE_CONTENT_UPDATE, () => + console.debug('Received NOTE_CONTENT_UPDATE from server to client 1') + ) + transporterClient2.on(MessageType.NOTE_CONTENT_UPDATE, () => + console.debug('Received NOTE_CONTENT_UPDATE from server to client 2') + ) + transporterServerTo1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () => + console.debug('Received NOTE_CONTENT_REQUEST from client 1 to server') + ) + transporterServerTo2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () => + console.debug('Received NOTE_CONTENT_REQUEST from client 2 to server') + ) + transporterClient1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () => + console.debug('Received NOTE_CONTENT_REQUEST from server to client 1') + ) + transporterClient2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () => + console.debug('Received NOTE_CONTENT_REQUEST from server to client 2') + ) + transporterClient1.on('connected', () => console.debug('1>s is connected')) + transporterClient2.on('connected', () => console.debug('2>s is connected')) + transporterServerTo1.on('connected', () => + console.debug('s>1 is connected') + ) + transporterServerTo2.on('connected', () => + console.debug('s>2 is connected') + ) + + docServer.on('update', (update: Uint8Array, origin: unknown) => { + const message: Message = { + type: MessageType.NOTE_CONTENT_UPDATE, + payload: Array.from(update) + } + if (origin !== transporterServerTo1) { + console.debug('YDoc on Server updated. Sending to Client 1') + transporterServerTo1.sendMessage(message) + } + if (origin !== transporterServerTo2) { + console.debug('YDoc on Server updated. Sending to Client 2') + transporterServerTo2.sendMessage(message) + } + }) + docClient1.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== transporterClient1) { + console.debug('YDoc on client 1 updated. Sending to Server') + } + }) + docClient2.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== transporterClient2) { + console.debug('YDoc on client 2 updated. Sending to Server') + } + }) + + const yDocSyncAdapter1 = new YDocSyncClientAdapter(transporterClient1) + yDocSyncAdapter1.setYDoc(docClient1) + + const yDocSyncAdapter2 = new YDocSyncClientAdapter(transporterClient2) + yDocSyncAdapter2.setYDoc(docClient2) + + const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter( + transporterServerTo1 + ) + yDocSyncAdapterServerTo1.setYDoc(docServer) + + const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter( + transporterServerTo2 + ) + yDocSyncAdapterServerTo2.setYDoc(docServer) + + const waitForClient1Sync = new Promise((resolve) => { + yDocSyncAdapter1.doAsSoonAsSynced(() => { + console.debug('client 1 received the first sync') + resolve() + }) + }) + const waitForClient2Sync = new Promise((resolve) => { + yDocSyncAdapter2.doAsSoonAsSynced(() => { + console.debug('client 2 received the first sync') + resolve() + }) + }) + const waitForServerTo11Sync = new Promise((resolve) => { + yDocSyncAdapterServerTo1.doAsSoonAsSynced(() => { + console.debug('server 1 received the first sync') + resolve() + }) + }) + const waitForServerTo21Sync = new Promise((resolve) => { + yDocSyncAdapterServerTo2.doAsSoonAsSynced(() => { + console.debug('server 2 received the first sync') + resolve() + }) + }) + + transporterClient1.connect(transporterServerTo1) + transporterClient2.connect(transporterServerTo2) + + yDocSyncAdapter1.requestDocumentState() + yDocSyncAdapter2.requestDocumentState() + + await Promise.all([ + waitForClient1Sync, + waitForClient2Sync, + waitForServerTo11Sync, + waitForServerTo21Sync + ]) + + textClient1.insert(0, 'test2') + textClient1.insert(0, 'test3') + textClient2.insert(0, 'test4') + + expect(textServer.toString()).toBe('test4test3test2This is a test note') + expect(textClient1.toString()).toBe('test4test3test2This is a test note') + expect(textClient2.toString()).toBe('test4test3test2This is a test note') + + docServer.destroy() + docClient1.destroy() + docClient2.destroy() + }) +}) diff --git a/commons/src/y-doc-sync/y-doc-sync-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-adapter.ts new file mode 100644 index 000000000..65b32a347 --- /dev/null +++ b/commons/src/y-doc-sync/y-doc-sync-adapter.ts @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MessageTransporter } from '../message-transporters/message-transporter.js' +import { Message, MessageType } from '../message-transporters/message.js' +import { Listener } from 'eventemitter2' +import { EventEmitter2 } from 'eventemitter2' +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs' + +type EventMap = Record<'synced' | 'desynced', () => void> + +/** + * Sends and processes messages that are used to first-synchronize and update a {@link Doc y-doc}. + */ +export abstract class YDocSyncAdapter { + public readonly eventEmitter = new EventEmitter2() + + protected doc: Doc | undefined + + private destroyYDocUpdateCallback: undefined | (() => void) + private destroyEventListenerCallback: undefined | (() => void) + private synced = false + + constructor(protected readonly messageTransporter: MessageTransporter) { + this.bindDocumentSyncMessageEvents() + } + + /** + * Executes the given callback as soon as the sync adapter has synchronized the y-doc. + * If the y-doc has already been synchronized then the callback is executed immediately. + * + * @param callback the callback to execute + * @return The event listener that waits for the sync event + */ + public doAsSoonAsSynced(callback: () => void): Listener { + if (this.isSynced()) { + callback() + } + return this.eventEmitter.on('synced', callback, { + objectify: true + }) as Listener + } + + public getMessageTransporter(): MessageTransporter { + return this.messageTransporter + } + + public isSynced(): boolean { + return this.synced + } + + /** + * Sets the {@link Doc y-doc} that should be synchronized. + * + * @param doc the doc to synchronize. + */ + public setYDoc(doc: Doc | undefined): void { + this.doc = doc + + this.destroyYDocUpdateCallback?.() + if (!doc) { + return + } + const yDocUpdateCallback = this.processDocUpdate.bind(this) + doc.on('update', yDocUpdateCallback) + this.destroyYDocUpdateCallback = () => doc.off('update', yDocUpdateCallback) + this.eventEmitter.emit('desynced') + } + + public destroy(): void { + this.destroyYDocUpdateCallback?.() + this.destroyEventListenerCallback?.() + } + + protected bindDocumentSyncMessageEvents(): void { + const stateRequestListener = this.messageTransporter.on( + MessageType.NOTE_CONTENT_STATE_REQUEST, + (payload) => { + if (this.doc) { + this.messageTransporter.sendMessage({ + type: MessageType.NOTE_CONTENT_UPDATE, + payload: Array.from( + encodeStateAsUpdate(this.doc, new Uint8Array(payload.payload)) + ) + }) + } + }, + { objectify: true } + ) as Listener + + const disconnectedListener = this.messageTransporter.on( + 'disconnected', + () => { + this.synced = false + this.eventEmitter.emit('desynced') + this.destroy() + }, + { objectify: true } + ) as Listener + + const noteContentUpdateListener = this.messageTransporter.on( + MessageType.NOTE_CONTENT_UPDATE, + (payload) => { + if (this.doc) { + applyUpdate(this.doc, new Uint8Array(payload.payload), this) + } + }, + { objectify: true } + ) as Listener + + this.destroyEventListenerCallback = () => { + stateRequestListener.off() + disconnectedListener.off() + noteContentUpdateListener.off() + } + } + + private processDocUpdate(update: Uint8Array, origin: unknown): void { + if (!this.isSynced() || origin === this) { + return + } + const message: Message = { + type: MessageType.NOTE_CONTENT_UPDATE, + payload: Array.from(update) + } + + this.messageTransporter.sendMessage(message) + } + + protected markAsSynced(): void { + if (this.synced) { + return + } + this.synced = true + this.eventEmitter.emit('synced') + } + + public requestDocumentState(): void { + if (this.doc) { + this.messageTransporter.sendMessage({ + type: MessageType.NOTE_CONTENT_STATE_REQUEST, + payload: Array.from(encodeStateVector(this.doc)) + }) + } + } +} diff --git a/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts new file mode 100644 index 000000000..0332d9a3b --- /dev/null +++ b/commons/src/y-doc-sync/y-doc-sync-client-adapter.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MessageType } from '../message-transporters/message.js' +import { YDocSyncAdapter } from './y-doc-sync-adapter.js' + +export class YDocSyncClientAdapter extends YDocSyncAdapter { + protected bindDocumentSyncMessageEvents() { + super.bindDocumentSyncMessageEvents() + + this.messageTransporter.on(MessageType.NOTE_CONTENT_UPDATE, () => { + this.markAsSynced() + }) + } +} diff --git a/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts new file mode 100644 index 000000000..54df1395e --- /dev/null +++ b/commons/src/y-doc-sync/y-doc-sync-server-adapter.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MessageTransporter } from '../message-transporters/message-transporter.js' +import { YDocSyncAdapter } from './y-doc-sync-adapter.js' + +export class YDocSyncServerAdapter extends YDocSyncAdapter { + constructor(readonly messageTransporter: MessageTransporter) { + super(messageTransporter) + this.markAsSynced() + } +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index a3fbd8a73..29813983b 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -583,6 +583,9 @@ "text": "You were redirected to the history page, because the note you just edited was deleted." } }, + "realtime": { + "reconnect": "Reconnecting to HedgeDoc…" + }, "settings": { "title": "Settings", "editor": { diff --git a/frontend/package.json b/frontend/package.json index a3a03cc8c..b3c67b992 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -121,8 +121,6 @@ "vega-lite": "5.6.1", "words-count": "2.0.2", "ws": "8.13.0", - "y-codemirror.next": "0.3.2", - "y-protocols": "1.0.5", "yjs": "13.5.51" }, "devDependencies": { diff --git a/frontend/src/components/editor-page/editor-page-content.tsx b/frontend/src/components/editor-page/editor-page-content.tsx index 38e99f4ab..42ae90eed 100644 --- a/frontend/src/components/editor-page/editor-page-content.tsx +++ b/frontend/src/components/editor-page/editor-page-content.tsx @@ -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 = () => { +
diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts new file mode 100644 index 000000000..d84f96e09 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/document-sync/y-text-sync-view-plugin.ts @@ -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) + } +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts new file mode 100644 index 000000000..3b8e69c76 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class.ts @@ -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)}`] +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss new file mode 100644 index 000000000..517e9c1b5 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-colors.module.scss @@ -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; +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts new file mode 100644 index 000000000..ad1b3de1c --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/cursor-layers-extensions.ts @@ -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() + +/** + * 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({ + 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 + ) + }) + } + }) diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts new file mode 100644 index 000000000..5c61e1ecf --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin.ts @@ -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() + } +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts new file mode 100644 index 000000000..bd7c9143b --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/remote-cursor-marker.ts @@ -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) + } +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts new file mode 100644 index 000000000..571018cf1 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/send-cursor-view-plugin.ts @@ -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 + } + }) + } +} diff --git a/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss new file mode 100644 index 000000000..3c616576b --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/codemirror-extensions/remote-cursors/style.module.scss @@ -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; + } +} diff --git a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx index 48460dba5..9dfdd7172 100644 --- a/frontend/src/components/editor-page/editor-pane/editor-pane.tsx +++ b/frontend/src/components/editor-page/editor-pane/editor-pane.tsx @@ -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 = ({ scrollState, onScroll, onMakeScrollSource }) => { - const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures) - +export const EditorPane: React.FC = ({ 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 = ({ 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 (
= ({ scrollState, onScroll, onMak onTouchStart={onMakeScrollSource} onMouseEnter={onMakeScrollSource} {...cypressId('editor-pane')} - {...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}> + {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}> + useMemo( + () => [ + remoteCursorStateField.extension, + createCursorLayer(), + createSelectionLayer(), + ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)), + ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter)) + ], + [messageTransporter] + ) diff --git a/frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension.ts similarity index 100% rename from frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension.ts rename to frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension.ts diff --git a/frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-spell-check-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-spell-check-extension.ts similarity index 100% rename from frontend/src/components/editor-page/editor-pane/hooks/code-mirror-extensions/use-code-mirror-spell-check-extension.ts rename to frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-spell-check-extension.ts diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts deleted file mode 100644 index 981268078..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/mock-connection.ts +++ /dev/null @@ -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 - } -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts deleted file mode 100644 index 12ffb91a8..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-awareness.ts +++ /dev/null @@ -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 -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts index 7437fec08..f7181cb6c 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-bind-y-text-to-redux.ts @@ -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) diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts index 7dcf30b03..5825354d3 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-code-mirror-yjs-extension.ts @@ -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] + ) } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts deleted file mode 100644 index 1da56473f..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect.ts +++ /dev/null @@ -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]) -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts deleted file mode 100644 index 82c7ed408..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-is-connection-synced.ts +++ /dev/null @@ -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(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 -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts index ccf74abf0..7124c3349 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-markdown-content-y-text.ts @@ -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]) } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts deleted file mode 100644 index d6ff59bcf..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-first-editor-update-extension.ts +++ /dev/null @@ -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(false) - const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), []) - return [extension, firstUpdateHappened] -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts index 0325e4cf5..23540a83f 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-metadata-updated.ts @@ -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]) } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts index 022512807..992801c9f 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-on-note-deleted.ts @@ -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]) } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts new file mode 100644 index 000000000..aab476948 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts @@ -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(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 +} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts new file mode 100644 index 000000000..e409e3947 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-receive-realtime-users.ts @@ -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]) +} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts deleted file mode 100644 index 1442f4764..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-connection.ts +++ /dev/null @@ -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 -} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts index ad5930690..1eec7c444 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-websocket-url.ts @@ -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 diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts new file mode 100644 index 000000000..60d47477f --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc-sync-client-adapter.ts @@ -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 +} diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts index 89c3d1bae..5501d50ce 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-y-doc.ts @@ -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() + + useEffect(() => { + messageTransporter.doAsSoonAsConnected(() => { + setYDoc(new RealtimeDoc()) + }) + messageTransporter.on('disconnected', () => { + setYDoc(undefined) + }) + }, [messageTransporter]) + + useEffect(() => () => yDoc?.destroy(), [yDoc]) + return yDoc } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts deleted file mode 100644 index ec6e6cfa1..000000000 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/websocket-connection.ts +++ /dev/null @@ -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)) - } - }) - } -} diff --git a/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx b/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx new file mode 100644 index 000000000..af52e21ce --- /dev/null +++ b/frontend/src/components/editor-page/reset-realtime-state-boundary.tsx @@ -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 = ({ children }) => { + const [globalStateInitialized, setGlobalStateInitialized] = useState(false) + useEffect(() => { + resetRealtimeStatus() + setGlobalStateInitialized(true) + }, []) + if (!globalStateInitialized) { + return + } else { + return {children} + } +} diff --git a/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss b/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss index 5b2ef46f6..31cdede99 100644 --- a/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss +++ b/frontend/src/components/editor-page/sidebar/user-line/user-line.module.scss @@ -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; diff --git a/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx b/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx index 5815746b3..7f07cc333 100644 --- a/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx +++ b/frontend/src/components/editor-page/sidebar/user-line/user-line.tsx @@ -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 = ({ username, color, status }) => { +export const UserLine: React.FC = ({ username, active, color }) => { return (
- +
) diff --git a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx index 758eb2f4f..03e86a3b5 100644 --- a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx +++ b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/active-indicator.tsx @@ -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 = ({ status }) => { - return +export const ActiveIndicator: React.FC = ({ active }) => { + return } diff --git a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx index 040928bd7..179e33fd2 100644 --- a/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx +++ b/frontend/src/components/editor-page/sidebar/users-online-sidebar-menu/users-online-sidebar-menu.tsx @@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC = ({ selectedMenuId }) => { const buttonRef = useRef(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 ( @@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC = ({ ) } else { - return entries.map(([clientId, onlineUser]) => { + return realtimeUsers.map((realtimeUser) => { return ( - - + + ) }) } - }, [onlineUsers]) + }, [realtimeUsers]) return ( diff --git a/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx b/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx new file mode 100644 index 000000000..3b0ed00b4 --- /dev/null +++ b/frontend/src/components/editor-page/websocket-connection-modal/realtime-connection-modal.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/pages/n/[noteId].tsx b/frontend/src/pages/n/[noteId].tsx index e7a4881be..cf39ebc0c 100644 --- a/frontend/src/pages/n/[noteId].tsx +++ b/frontend/src/pages/n/[noteId].tsx @@ -6,6 +6,7 @@ import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary' import { EditorPageContent } from '../../components/editor-page/editor-page-content' import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider' +import { ResetRealtimeStateBoundary } from '../../components/editor-page/reset-realtime-state-boundary' import type { NextPage } from 'next' import React from 'react' @@ -14,11 +15,13 @@ import React from 'react' */ export const EditorPage: NextPage = () => { return ( - - - - - + + + + + + + ) } diff --git a/frontend/src/redux/application-state.d.ts b/frontend/src/redux/application-state.d.ts index 761b2d3d9..358935d36 100644 --- a/frontend/src/redux/application-state.d.ts +++ b/frontend/src/redux/application-state.d.ts @@ -8,7 +8,7 @@ import type { HistoryEntryWithOrigin } from '../api/history/types' import type { DarkModeConfig } from './dark-mode/types' import type { EditorConfig } from './editor/types' import type { NoteDetails } from './note-details/types/note-details' -import type { RealtimeState } from './realtime/types' +import type { RealtimeStatus } from './realtime/types' import type { RendererStatus } from './renderer-status/types' import type { OptionalUserState } from './user/types' @@ -20,5 +20,5 @@ export interface ApplicationState { darkMode: DarkModeConfig noteDetails: NoteDetails rendererStatus: RendererStatus - realtime: RealtimeState + realtimeStatus: RealtimeStatus } diff --git a/frontend/src/redux/realtime/methods.ts b/frontend/src/redux/realtime/methods.ts index 836859c2c..39011435f 100644 --- a/frontend/src/redux/realtime/methods.ts +++ b/frontend/src/redux/realtime/methods.ts @@ -4,33 +4,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { store } from '..' -import type { AddOnlineUserAction, OnlineUser, RemoveOnlineUserAction } from './types' -import { RealtimeActionType } from './types' +import type { SetRealtimeSyncStatusAction, SetRealtimeUsersAction, SetRealtimeConnectionStatusAction } from './types' +import { RealtimeStatusActionType } from './types' +import type { RealtimeUser } from '@hedgedoc/commons' /** * Dispatches an event to add a user - * - * @param clientId The clientId of the user to add - * @param user The user to add. */ -export const addOnlineUser = (clientId: number, user: OnlineUser): void => { - const action: AddOnlineUserAction = { - type: RealtimeActionType.ADD_ONLINE_USER, - clientId, - user +export const setRealtimeUsers = (users: RealtimeUser[]): void => { + const action: SetRealtimeUsersAction = { + type: RealtimeStatusActionType.SET_REALTIME_USERS, + users } store.dispatch(action) } -/** - * Dispatches an event to remove a user from the online users list. - * - * @param clientId The yjs client id of the user to remove from the online users list. - */ -export const removeOnlineUser = (clientId: number): void => { - const action: RemoveOnlineUserAction = { - type: RealtimeActionType.REMOVE_ONLINE_USER, - clientId - } - store.dispatch(action) +export const setRealtimeConnectionState = (status: boolean): void => { + store.dispatch({ + type: RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS, + isConnected: status + } as SetRealtimeConnectionStatusAction) +} + +export const setRealtimeSyncedState = (status: boolean): void => { + store.dispatch({ + type: RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS, + isSynced: status + } as SetRealtimeSyncStatusAction) +} + +export const resetRealtimeStatus = (): void => { + store.dispatch({ + type: RealtimeStatusActionType.RESET_REALTIME_STATUS + }) } diff --git a/frontend/src/redux/realtime/reducers.ts b/frontend/src/redux/realtime/reducers.ts index debad5169..50655c6d6 100644 --- a/frontend/src/redux/realtime/reducers.ts +++ b/frontend/src/redux/realtime/reducers.ts @@ -3,32 +3,45 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { buildStateFromAddUser } from './reducers/build-state-from-add-user' -import { buildStateFromRemoveUser } from './reducers/build-state-from-remove-user' -import type { RealtimeActions, RealtimeState } from './types' -import { RealtimeActionType } from './types' +import type { RealtimeStatusActions, RealtimeStatus } from './types' +import { RealtimeStatusActionType } from './types' import type { Reducer } from 'redux' -const initialState: RealtimeState = { - users: [] +const initialState: RealtimeStatus = { + isSynced: false, + isConnected: false, + onlineUsers: [] } /** - * Applies {@link RealtimeReducer realtime actions} to the global application state. + * Applies {@link RealtimeStatusReducer realtime actions} to the global application state. * * @param state the current state * @param action the action that should get applied * @return The new changed state */ -export const RealtimeReducer: Reducer = ( +export const RealtimeStatusReducer: Reducer = ( state = initialState, - action: RealtimeActions + action: RealtimeStatusActions ) => { switch (action.type) { - case RealtimeActionType.ADD_ONLINE_USER: - return buildStateFromAddUser(state, action.clientId, action.user) - case RealtimeActionType.REMOVE_ONLINE_USER: - return buildStateFromRemoveUser(state, action.clientId) + case RealtimeStatusActionType.SET_REALTIME_USERS: + return { + ...state, + onlineUsers: action.users + } + case RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS: + return { + ...state, + isConnected: action.isConnected + } + case RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS: + return { + ...state, + isSynced: action.isSynced + } + case RealtimeStatusActionType.RESET_REALTIME_STATUS: + return initialState default: return state } diff --git a/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts b/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts deleted file mode 100644 index 74b584d00..000000000 --- a/frontend/src/redux/realtime/reducers/build-state-from-add-user.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { OnlineUser, RealtimeState } from '../types' - -/** - * Builds a new {@link RealtimeState} with a new client id that is shown as online. - * - * @param oldState The old state that will be copied - * @param clientId The identifier of the new client - * @param user The information about the new user - * @return the generated state - */ -export const buildStateFromAddUser = (oldState: RealtimeState, clientId: number, user: OnlineUser): RealtimeState => { - return { - users: { - ...oldState.users, - [clientId]: user - } - } -} diff --git a/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts b/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts deleted file mode 100644 index 964f3b965..000000000 --- a/frontend/src/redux/realtime/reducers/build-state-from-remove-user.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { RealtimeState } from '../types' - -/** - * Builds a new {@link RealtimeState} but removes the information about a client. - * - * @param oldState The old state that will be copied - * @param clientIdToRemove The identifier of the client that should be removed - * @return the generated state - */ -export const buildStateFromRemoveUser = (oldState: RealtimeState, clientIdToRemove: number): RealtimeState => { - const newUsers = { ...oldState.users } - delete newUsers[clientIdToRemove] - return { - users: newUsers - } -} diff --git a/frontend/src/redux/realtime/types.ts b/frontend/src/redux/realtime/types.ts index 40983f9bc..c09199036 100644 --- a/frontend/src/redux/realtime/types.ts +++ b/frontend/src/redux/realtime/types.ts @@ -3,38 +3,43 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import type { RealtimeUser } from '@hedgedoc/commons' import type { Action } from 'redux' -export enum RealtimeActionType { - ADD_ONLINE_USER = 'realtime/add-user', - REMOVE_ONLINE_USER = 'realtime/remove-user', - UPDATE_ONLINE_USER = 'realtime/update-user' +export enum RealtimeStatusActionType { + SET_REALTIME_USERS = 'realtime/set-users', + SET_REALTIME_CONNECTION_STATUS = 'realtime/set-connection-status', + SET_REALTIME_SYNCED_STATUS = 'realtime/set-synced-status', + RESET_REALTIME_STATUS = 'realtime/reset-realtime-status' } -export interface RealtimeState { - users: Record +export interface SetRealtimeUsersAction extends Action { + type: RealtimeStatusActionType.SET_REALTIME_USERS + users: RealtimeUser[] } -export enum ActiveIndicatorStatus { - ACTIVE = 'active', - INACTIVE = 'inactive' +export interface SetRealtimeConnectionStatusAction extends Action { + type: RealtimeStatusActionType.SET_REALTIME_CONNECTION_STATUS + isConnected: boolean } -export interface OnlineUser { - username: string - color: string - active: ActiveIndicatorStatus +export interface SetRealtimeSyncStatusAction extends Action { + type: RealtimeStatusActionType.SET_REALTIME_SYNCED_STATUS + isSynced: boolean } -export interface AddOnlineUserAction extends Action { - type: RealtimeActionType.ADD_ONLINE_USER - clientId: number - user: OnlineUser +export interface ResetRealtimeStatusAction extends Action { + type: RealtimeStatusActionType.RESET_REALTIME_STATUS } -export interface RemoveOnlineUserAction extends Action { - type: RealtimeActionType.REMOVE_ONLINE_USER - clientId: number +export interface RealtimeStatus { + onlineUsers: RealtimeUser[] + isConnected: boolean + isSynced: boolean } -export type RealtimeActions = AddOnlineUserAction | RemoveOnlineUserAction +export type RealtimeStatusActions = + | SetRealtimeUsersAction + | SetRealtimeConnectionStatusAction + | SetRealtimeSyncStatusAction + | ResetRealtimeStatusAction diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts index 693bec896..9c55ec892 100644 --- a/frontend/src/redux/reducers.ts +++ b/frontend/src/redux/reducers.ts @@ -9,7 +9,7 @@ import { DarkModeConfigReducer } from './dark-mode/reducers' import { EditorConfigReducer } from './editor/reducers' import { HistoryReducer } from './history/reducers' import { NoteDetailsReducer } from './note-details/reducer' -import { RealtimeReducer } from './realtime/reducers' +import { RealtimeStatusReducer } from './realtime/reducers' import { RendererStatusReducer } from './renderer-status/reducers' import { UserReducer } from './user/reducers' import type { Reducer } from 'redux' @@ -23,5 +23,5 @@ export const allReducers: Reducer = combineReducers