diff --git a/backend/src/realtime/realtime-note/realtime-connection.ts b/backend/src/realtime/realtime-note/realtime-connection.ts index 6e215e37b..f0cf9b100 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.ts @@ -52,6 +52,7 @@ export class RealtimeConnection { this.user?.username ?? null, this.getDisplayName(), this, + acceptEdits, ); } 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 index 4ab729221..8d25ecb0d 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts @@ -21,17 +21,20 @@ describe('realtime user status adapter', () => { let clientLoggedIn2: RealtimeConnection; let clientGuest: RealtimeConnection; let clientNotReady: RealtimeConnection; + let clientDecline: RealtimeConnection; let clientLoggedIn1SendMessageSpy: SendMessageSpy; let clientLoggedIn2SendMessageSpy: SendMessageSpy; let clientGuestSendMessageSpy: SendMessageSpy; let clientNotReadySendMessageSpy: SendMessageSpy; + let clientDeclineSendMessageSpy: SendMessageSpy; let realtimeNote: RealtimeNote; const clientLoggedIn1Username = 'logged.in1'; const clientLoggedIn2Username = 'logged.in2'; const clientNotReadyUsername = 'not.ready'; + const clientDeclineUsername = 'read.only'; const guestDisplayName = 'Virtuous Mockingbird'; @@ -45,30 +48,36 @@ describe('realtime user status adapter', () => { 'mockedContent', ); clientLoggedIn1 = new MockConnectionBuilder(realtimeNote) - .withRealtimeUserStatus() + .withAcceptingRealtimeUserStatus() .withLoggedInUser(clientLoggedIn1Username) .build(); clientLoggedIn2 = new MockConnectionBuilder(realtimeNote) - .withRealtimeUserStatus() + .withAcceptingRealtimeUserStatus() .withLoggedInUser(clientLoggedIn2Username) .build(); clientGuest = new MockConnectionBuilder(realtimeNote) - .withRealtimeUserStatus() + .withAcceptingRealtimeUserStatus() .withGuestUser(guestDisplayName) .build(); clientNotReady = new MockConnectionBuilder(realtimeNote) - .withRealtimeUserStatus() + .withAcceptingRealtimeUserStatus() .withLoggedInUser(clientNotReadyUsername) .build(); + clientDecline = new MockConnectionBuilder(realtimeNote) + .withDecliningRealtimeUserStatus() + .withLoggedInUser(clientDeclineUsername) + .build(); clientLoggedIn1SendMessageSpy = spyOnSendMessage(clientLoggedIn1); clientLoggedIn2SendMessageSpy = spyOnSendMessage(clientLoggedIn2); clientGuestSendMessageSpy = spyOnSendMessage(clientGuest); clientNotReadySendMessageSpy = spyOnSendMessage(clientNotReady); + clientDeclineSendMessageSpy = spyOnSendMessage(clientDecline); clientLoggedIn1.getTransporter().sendReady(); clientLoggedIn2.getTransporter().sendReady(); clientGuest.getTransporter().sendReady(); + clientDecline.getTransporter().sendReady(); }); it('can answer a state request', () => { @@ -76,6 +85,7 @@ describe('realtime user status adapter', () => { expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); clientLoggedIn1 .getTransporter() @@ -119,6 +129,7 @@ describe('realtime user status adapter', () => { expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); }); it('can save an cursor update', () => { @@ -126,6 +137,7 @@ describe('realtime user status adapter', () => { expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); const newFrom = Math.floor(Math.random() * 100); const newTo = Math.floor(Math.random() * 100); @@ -214,6 +226,7 @@ describe('realtime user status adapter', () => { expectedMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); }); it('will inform other clients about removed client', () => { @@ -221,6 +234,7 @@ describe('realtime user status adapter', () => { expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); clientLoggedIn2.getTransporter().disconnect(); @@ -278,6 +292,7 @@ describe('realtime user status adapter', () => { expectedMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); }); it('will inform other clients about inactivity and reactivity', () => { @@ -285,6 +300,7 @@ describe('realtime user status adapter', () => { expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); clientLoggedIn1 .getTransporter() @@ -371,6 +387,7 @@ describe('realtime user status adapter', () => { expectedInactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); clientLoggedIn1 .getTransporter() @@ -391,6 +408,7 @@ describe('realtime user status adapter', () => { expectedInactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); clientLoggedIn1 .getTransporter() @@ -477,6 +495,7 @@ describe('realtime user status adapter', () => { expectedReactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(2); clientLoggedIn1 .getTransporter() @@ -497,5 +516,30 @@ describe('realtime user status adapter', () => { expectedReactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(2); + }); + + it('will ignore updates from read only clients', () => { + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + + clientDecline + .getTransporter() + .emit(MessageType.REALTIME_USER_SINGLE_UPDATE, { + type: MessageType.REALTIME_USER_SINGLE_UPDATE, + payload: { + from: 0, + to: 1234, + }, + }); + + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).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 index f83e0c760..ac3c329f5 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { MessageType, RealtimeUser } from '@hedgedoc/commons'; +import { Message, MessageType, RealtimeUser } from '@hedgedoc/commons'; import { Listener } from 'eventemitter2'; import { RealtimeConnection } from './realtime-connection'; @@ -19,6 +19,7 @@ export class RealtimeUserStatusAdapter { username: string | null, displayName: string, private connection: RealtimeConnection, + private acceptCursorUpdate: boolean, ) { this.realtimeUser = this.createInitialRealtimeUserState( username, @@ -51,13 +52,14 @@ export class RealtimeUserStatusAdapter { const realtimeNote = connection.getRealtimeNote(); const transporterMessagesListener = connection.getTransporter().on( MessageType.REALTIME_USER_SINGLE_UPDATE, - (message) => { - this.realtimeUser.cursor = message.payload; - this.sendRealtimeUserStatusUpdateEvent(connection); + (message: Message) => { + if (this.isAcceptingCursorUpdates()) { + this.realtimeUser.cursor = message.payload; + this.sendRealtimeUserStatusUpdateEvent(connection); + } }, { objectify: true }, ) as Listener; - const transporterRequestMessageListener = connection.getTransporter().on( MessageType.REALTIME_USER_STATE_REQUEST, () => { @@ -80,8 +82,11 @@ export class RealtimeUserStatusAdapter { const realtimeUserSetActivityListener = connection.getTransporter().on( MessageType.REALTIME_USER_SET_ACTIVITY, - (message) => { - if (this.realtimeUser.active === message.payload.active) { + (message: Message) => { + if ( + !this.isAcceptingCursorUpdates() || + this.realtimeUser.active === message.payload.active + ) { return; } this.realtimeUser.active = message.payload.active; @@ -91,7 +96,7 @@ export class RealtimeUserStatusAdapter { ) as Listener; connection.getTransporter().on('disconnected', () => { - transporterMessagesListener.off(); + transporterMessagesListener?.off(); transporterRequestMessageListener.off(); clientRemoveListener.off(); realtimeUserSetActivityListener.off(); @@ -106,25 +111,32 @@ export class RealtimeUserStatusAdapter { ); } - private sendCompleteStateToClient(client: RealtimeConnection): void { - const realtimeUsers = this.collectAllConnectionsExcept(client).map( - (client) => client.getRealtimeUserStateAdapter().realtimeUser, - ); + private sendCompleteStateToClient(receivingClient: RealtimeConnection): void { + const realtimeUser = + receivingClient.getRealtimeUserStateAdapter().realtimeUser; + const realtimeUsers = this.collectAllConnectionsExcept(receivingClient) + .filter((client) => + client.getRealtimeUserStateAdapter().isAcceptingCursorUpdates(), + ) + .map((client) => client.getRealtimeUserStateAdapter().realtimeUser) + .filter((realtimeUser) => realtimeUser !== null); - client.getTransporter().sendMessage({ + receivingClient.getTransporter().sendMessage({ type: MessageType.REALTIME_USER_STATE_SET, payload: { users: realtimeUsers, ownUser: { - displayName: - client.getRealtimeUserStateAdapter().realtimeUser.displayName, - styleIndex: - client.getRealtimeUserStateAdapter().realtimeUser.styleIndex, + displayName: realtimeUser.displayName, + styleIndex: realtimeUser.styleIndex, }, }, }); } + private isAcceptingCursorUpdates(): boolean { + return this.acceptCursorUpdate; + } + private collectAllConnectionsExcept( exceptClient: RealtimeConnection, ): RealtimeConnection[] { @@ -157,11 +169,13 @@ export class RealtimeUserStatusAdapter { .getConnections() .map( (connection) => - connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex, + connection.getRealtimeUserStateAdapter().realtimeUser?.styleIndex, ) .reduce((map, styleIndex) => { - const count = (map.get(styleIndex) ?? 0) + 1; - map.set(styleIndex, count); + if (styleIndex !== undefined) { + 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-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts index 47a2203c4..ffde2d2ac 100644 --- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts +++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -14,6 +14,12 @@ import { RealtimeConnection } from '../realtime-connection'; import { RealtimeNote } from '../realtime-note'; import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter'; +enum RealtimeUserState { + WITHOUT, + WITH_READWRITE, + WITH_READONLY, +} + const MOCK_FALLBACK_USERNAME = 'mock'; /** @@ -22,7 +28,8 @@ const MOCK_FALLBACK_USERNAME = 'mock'; export class MockConnectionBuilder { private username: string | null; private displayName: string | undefined; - private includeRealtimeUserStatus = false; + private includeRealtimeUserStatus: RealtimeUserState = + RealtimeUserState.WITHOUT; constructor(private readonly realtimeNote: RealtimeNote) {} @@ -50,10 +57,18 @@ export class MockConnectionBuilder { } /** - * Defines that the connection should contain a {@link RealtimeUserStatusAdapter}. + * Defines that the connection should contain a {@link RealtimeUserStatusAdapter} that is accepting cursor updates. */ - public withRealtimeUserStatus(): this { - this.includeRealtimeUserStatus = true; + public withAcceptingRealtimeUserStatus(): this { + this.includeRealtimeUserStatus = RealtimeUserState.WITH_READWRITE; + return this; + } + + /** + * Defines that the connection should contain a {@link RealtimeUserStatusAdapter} that is declining cursor updates. + */ + public withDecliningRealtimeUserStatus(): this { + this.includeRealtimeUserStatus = RealtimeUserState.WITH_READONLY; return this; } @@ -92,11 +107,12 @@ export class MockConnectionBuilder { this.realtimeNote.removeClient(connection), ); - if (this.includeRealtimeUserStatus) { + if (this.includeRealtimeUserStatus !== RealtimeUserState.WITHOUT) { realtimeUserStateAdapter = new RealtimeUserStatusAdapter( this.username ?? null, displayName, connection, + this.includeRealtimeUserStatus === RealtimeUserState.WITH_READWRITE, ); } 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 index 571018cf1..0a84d8ca7 100644 --- 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 @@ -14,16 +14,18 @@ import type { Listener } from 'eventemitter2' */ export class SendCursorViewPlugin implements PluginValue { private lastCursor: SelectionRange | undefined - private listener: Listener + private listener?: Listener - constructor(private view: EditorView, private messageTransporter: MessageTransporter) { - this.listener = messageTransporter.doAsSoonAsReady(() => { - this.sendCursor(this.lastCursor) - }) + constructor(private view: EditorView, private messageTransporter: MessageTransporter, private mayEdit: boolean) { + if (mayEdit) { + this.listener = messageTransporter.doAsSoonAsReady(() => { + this.sendCursor(this.lastCursor) + }) + } } destroy() { - this.listener.off() + this.listener?.off() } update(update: ViewUpdate) { @@ -37,7 +39,8 @@ export class SendCursorViewPlugin implements PluginValue { if ( !this.messageTransporter.isReady() || currentCursor === undefined || - (this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from) + (this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from) || + !this.mayEdit ) { return } diff --git a/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts index 1816b5c84..5a2d7d0e1 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { useMayEdit } from '../../../../../hooks/common/use-may-edit' import { createCursorLayer, createSelectionLayer, @@ -19,14 +20,16 @@ import { useMemo } from 'react' * Bundles all extensions that are needed for the remote cursor display. * @return The created codemirror extensions */ -export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension => - useMemo( +export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension => { + const mayEdit = useMayEdit() + return useMemo( () => [ remoteCursorStateField.extension, createCursorLayer(), createSelectionLayer(), ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)), - ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter)) + ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter, mayEdit)) ], - [messageTransporter] + [mayEdit, messageTransporter] ) +}