mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-24 10:46:30 -05:00
refactor: reimplement realtime-communication
This commit refactors a lot of things that are not easy to separate. It replaces the binary protocol of y-protocols with json. It introduces event based message processing. It implements our own code mirror plugins for synchronisation of content and remote cursors Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
67cf1432b2
commit
3a06f84af1
110 changed files with 3920 additions and 2201 deletions
|
@ -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"
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -182,7 +182,10 @@ export class NotesService {
|
|||
*/
|
||||
async getNoteContent(note: Note): Promise<string> {
|
||||
return (
|
||||
this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
|
||||
this.realtimeNoteStore
|
||||
.find(note.id)
|
||||
?.getRealtimeDoc()
|
||||
.getCurrentContent() ??
|
||||
(await this.revisionsService.getLatestRevision(note)).content
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: The author of https://www.randomlists.com/
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
156
backend/src/realtime/realtime-note/realtime-connection.spec.ts
Normal file
156
backend/src/realtime/realtime-note/realtime-connection.spec.ts
Normal file
|
@ -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<YDocSyncServerAdapter>({
|
||||
setYDoc: jest.fn(),
|
||||
}),
|
||||
),
|
||||
} as Record<string, unknown>),
|
||||
);
|
||||
|
||||
describe('websocket connection', () => {
|
||||
let mockedRealtimeNote: RealtimeNote;
|
||||
let mockedUser: User;
|
||||
let mockedMessageTransporter: MessageTransporter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedRealtimeNote = new RealtimeNote(Mock.of<Note>({}), '');
|
||||
mockedUser = Mock.of<User>({});
|
||||
|
||||
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<RealtimeUserStatusAdapter>();
|
||||
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<YDocSyncServerAdapter>({
|
||||
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<User>({ 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);
|
||||
});
|
||||
});
|
76
backend/src/realtime/realtime-note/realtime-connection.ts
Normal file
76
backend/src/realtime/realtime-note/realtime-connection.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<Note>({ id: mockedNoteId });
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
mockedNote,
|
||||
Mock.of<WebsocketDoc>(),
|
||||
Mock.of<WebsocketAwareness>(),
|
||||
);
|
||||
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,
|
||||
|
|
|
@ -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<Revision>({
|
||||
content: mockedContent,
|
||||
|
@ -60,13 +55,8 @@ describe('RealtimeNoteService', () => {
|
|||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
|
||||
websocketDoc = mockWebsocketDoc();
|
||||
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
mockedNote,
|
||||
websocketDoc,
|
||||
mockAwareness(),
|
||||
);
|
||||
note = Mock.of<Note>({ id: mockedNoteId });
|
||||
realtimeNote = new RealtimeNote(note, mockedContent);
|
||||
|
||||
revisionsService = Mock.of<RevisionsService>({
|
||||
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<Revision>()));
|
||||
|
||||
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();
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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<Note>({ 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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<MapType> {
|
||||
export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
|
||||
protected logger: Logger;
|
||||
private readonly websocketDoc: WebsocketDoc;
|
||||
private readonly websocketAwareness: WebsocketAwareness;
|
||||
private readonly clients = new Set<WebsocketConnection>();
|
||||
private readonly doc: RealtimeDoc;
|
||||
private readonly clients = new Set<RealtimeConnection>();
|
||||
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<MapType> {
|
|||
*
|
||||
* @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<MapType> {
|
|||
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<MapType> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<MapType> {
|
|||
* 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<MapType> {
|
|||
*
|
||||
* @param {Uint8Array} content The binary message to broadcast
|
||||
*/
|
||||
private sendToAllClients(content: Uint8Array): void {
|
||||
private sendToAllClients(content: Message<MessageType>): void {
|
||||
this.getConnections().forEach((connection) => {
|
||||
connection.send(content);
|
||||
connection.getTransporter().sendMessage(content);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MessageTransporter['sendMessage']>]
|
||||
>;
|
||||
|
||||
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<Note>({ 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<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
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<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
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<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
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<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 2,
|
||||
username: username3,
|
||||
displayName: username3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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, number>): 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<number, number> {
|
||||
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<number, number>());
|
||||
}
|
||||
}
|
|
@ -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<string> {
|
||||
destroy(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketAwareness}.
|
||||
*/
|
||||
export function mockAwareness(): WebsocketAwareness {
|
||||
return Mock.from<WebsocketAwareness>(new MockAwareness());
|
||||
}
|
|
@ -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<WebsocketConnection>({
|
||||
isSynced: jest.fn(() => synced),
|
||||
send: jest.fn(),
|
||||
getUser: jest.fn(() => Mock.of<User>({ 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<RealtimeUserStatusAdapter>();
|
||||
|
||||
const connection = Mock.of<RealtimeConnection>({
|
||||
getUser: jest.fn(() => Mock.of<User>({ username: this.username })),
|
||||
getDisplayName: jest.fn(() => this.username),
|
||||
getSyncAdapter: jest.fn(() => Mock.of<YDocSyncServerAdapter>({})),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MapType> {
|
||||
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<RealtimeNote>(
|
||||
new MockRealtimeNote(
|
||||
note ?? Mock.of<Note>(),
|
||||
doc ?? mockWebsocketDoc(),
|
||||
awareness ?? mockAwareness(),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -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<WebsocketDoc>({
|
||||
on: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
getCurrentContent: jest.fn(),
|
||||
});
|
||||
}
|
|
@ -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<WebsocketTransporter>(new MockMessageTransporter());
|
||||
}
|
|
@ -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<RealtimeNote>({
|
||||
getYDoc(): WebsocketDoc {
|
||||
return Mock.of<WebsocketDoc>({
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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<Note>(),
|
||||
mockedDoc,
|
||||
mockedAwareness,
|
||||
);
|
||||
mockedWebsocket = Mock.of<WebSocket>({});
|
||||
mockedUser = Mock.of<User>({});
|
||||
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<WebsocketConnection>()]);
|
||||
|
||||
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<User>({ 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');
|
||||
});
|
||||
});
|
|
@ -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<number> = 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<number> {
|
||||
return this.controlledAwarenessIds;
|
||||
}
|
||||
|
||||
public getUser(): User | null {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
return this.getUser()?.username ?? 'Guest';
|
||||
}
|
||||
}
|
|
@ -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<RealtimeNote>(), 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<RealtimeNote>({
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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<WebsocketConnection>();
|
||||
mockedWebsocketConnection = Mock.of<RealtimeConnection>();
|
||||
jest
|
||||
.spyOn(websocketConnectionModule, 'WebsocketConnection')
|
||||
.spyOn(websocketConnectionModule, 'RealtimeConnection')
|
||||
.mockReturnValue(mockedWebsocketConnection);
|
||||
|
||||
mockedWebsocket = Mock.of<WebSocket>({
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false
|
||||
"strictPropertyInitialization": false,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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'
|
|
@ -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'
|
||||
|
|
102
commons/src/message-transporters/message-transporter.ts
Normal file
102
commons/src/message-transporters/message-transporter.ts
Normal file
|
@ -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<E>) => void
|
||||
: () => void
|
||||
}
|
||||
|
||||
export enum ConnectionState {
|
||||
DISCONNECT,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for event based message communication.
|
||||
*/
|
||||
export abstract class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
|
||||
private readyMessageReceived = false
|
||||
|
||||
public abstract sendMessage<M extends MessageType>(content: Message<M>): void
|
||||
|
||||
protected receiveMessage<L extends MessageType>(message: Message<L>): 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
|
||||
}
|
||||
}
|
36
commons/src/message-transporters/message.ts
Normal file
36
commons/src/message-transporters/message.ts
Normal file
|
@ -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 MessageType> = T extends keyof MessagePayloads
|
||||
? {
|
||||
type: T
|
||||
payload: MessagePayloads[T]
|
||||
}
|
||||
: {
|
||||
type: T
|
||||
}
|
|
@ -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<M extends MessageType>(content: Message<M>) {
|
||||
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
|
||||
}
|
||||
}
|
18
commons/src/message-transporters/realtime-user.ts
Normal file
18
commons/src/message-transporters/realtime-user.ts
Normal file
|
@ -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
|
||||
}
|
97
commons/src/message-transporters/websocket-transporter.ts
Normal file
97
commons/src/message-transporters/websocket-transporter.ts
Normal file
|
@ -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<MessageType>
|
||||
this.receiveMessage(message)
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.websocket?.close()
|
||||
}
|
||||
|
||||
public sendMessage(content: Message<MessageType>): 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}))
|
|
@ -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<Record<MessageType, Handler>>
|
||||
|
||||
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
|
||||
}
|
|
@ -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<MessageType>): 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
|
||||
}
|
||||
}
|
16
commons/src/y-doc-sync/realtime-doc.test.ts
Normal file
16
commons/src/y-doc-sync/realtime-doc.test.ts
Normal file
|
@ -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)
|
||||
})
|
||||
})
|
48
commons/src/y-doc-sync/realtime-doc.ts
Normal file
48
commons/src/y-doc-sync/realtime-doc.ts
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
162
commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
Normal file
162
commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
Normal file
|
@ -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<MessageType.NOTE_CONTENT_UPDATE> = {
|
||||
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<void>((resolve) => {
|
||||
yDocSyncAdapter1.doAsSoonAsSynced(() => {
|
||||
console.debug('client 1 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForClient2Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapter2.doAsSoonAsSynced(() => {
|
||||
console.debug('client 2 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForServerTo11Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapterServerTo1.doAsSoonAsSynced(() => {
|
||||
console.debug('server 1 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForServerTo21Sync = new Promise<void>((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()
|
||||
})
|
||||
})
|
148
commons/src/y-doc-sync/y-doc-sync-adapter.ts
Normal file
148
commons/src/y-doc-sync/y-doc-sync-adapter.ts
Normal file
|
@ -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<EventMap>()
|
||||
|
||||
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<MessageType.NOTE_CONTENT_UPDATE> = {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
17
commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
Normal file
17
commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
Normal file
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
14
commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
Normal file
14
commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -20,6 +20,7 @@ import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-ent
|
|||
import { Sidebar } from './sidebar/sidebar'
|
||||
import { Splitter } from './splitter/splitter'
|
||||
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
||||
import { RealtimeConnectionModal } from './websocket-connection-modal/realtime-connection-modal'
|
||||
import equal from 'fast-deep-equal'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
@ -79,7 +80,6 @@ export const EditorPageContent: React.FC = () => {
|
|||
)
|
||||
|
||||
useApplyDarkMode()
|
||||
|
||||
useUpdateLocalHistoryEntry()
|
||||
|
||||
const setRendererToScrollSource = useCallback(() => {
|
||||
|
@ -129,6 +129,7 @@ export const EditorPageContent: React.FC = () => {
|
|||
<CommunicatorImageLightbox />
|
||||
<HeadMetaProperties />
|
||||
<MotdModal />
|
||||
<RealtimeConnectionModal />
|
||||
<div className={'d-flex flex-column vh-100'}>
|
||||
<AppBar mode={AppBarMode.EDITOR} />
|
||||
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)}`]
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { createCursorCssClass } from './create-cursor-css-class'
|
||||
import { RemoteCursorMarker } from './remote-cursor-marker'
|
||||
import styles from './style.module.scss'
|
||||
import type { Extension, Transaction } from '@codemirror/state'
|
||||
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
|
||||
import type { ViewUpdate } from '@codemirror/view'
|
||||
import { layer, RectangleMarker } from '@codemirror/view'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import equal from 'fast-deep-equal'
|
||||
|
||||
export interface RemoteCursor {
|
||||
displayName: string
|
||||
from: number
|
||||
to?: number
|
||||
styleIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to provide a new set of {@link RemoteCursor remote cursors} to a codemirror state.
|
||||
*/
|
||||
export const remoteCursorUpdateEffect = StateEffect.define<RemoteCursor[]>()
|
||||
|
||||
/**
|
||||
* Saves the currently visible {@link RemoteCursor remote cursors}
|
||||
* and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched.
|
||||
*/
|
||||
export const remoteCursorStateField = StateField.define<RemoteCursor[]>({
|
||||
compare(a: RemoteCursor[], b: RemoteCursor[]): boolean {
|
||||
return equal(a, b)
|
||||
},
|
||||
create(): RemoteCursor[] {
|
||||
return []
|
||||
},
|
||||
update(currentValue: RemoteCursor[], transaction: Transaction): RemoteCursor[] {
|
||||
return Optional.ofNullable(transaction.effects.find((effect) => effect.is(remoteCursorUpdateEffect)))
|
||||
.map((remoteCursor) => remoteCursor.value as RemoteCursor[])
|
||||
.orElse(currentValue)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Checks if the given {@link ViewUpdate view update} should trigger a rerender of remote cursor components.
|
||||
* @param update The update to check
|
||||
*/
|
||||
const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
|
||||
const effect = update.transactions
|
||||
.flatMap((transaction) => transaction.effects)
|
||||
.filter((effect) => effect.is(remoteCursorUpdateEffect))
|
||||
|
||||
return update.docChanged || update.viewportChanged || effect.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the codemirror extension that renders the remote cursor selection layer.
|
||||
* @return The created codemirror extension
|
||||
*/
|
||||
export const createCursorLayer = (): Extension =>
|
||||
layer({
|
||||
above: true,
|
||||
class: styles.cursorLayer,
|
||||
update: isRemoteCursorUpdate,
|
||||
markers: (view) => {
|
||||
return view.state.field(remoteCursorStateField).flatMap((remoteCursor) => {
|
||||
const selectionRange = EditorSelection.cursor(remoteCursor.from)
|
||||
return RemoteCursorMarker.createCursor(view, selectionRange, remoteCursor.displayName, remoteCursor.styleIndex)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates the codemirror extension that renders the blinking remote cursor layer.
|
||||
* @return The created codemirror extension
|
||||
*/
|
||||
export const createSelectionLayer = (): Extension =>
|
||||
layer({
|
||||
above: false,
|
||||
class: styles.selectionLayer,
|
||||
update: isRemoteCursorUpdate,
|
||||
markers: (view) => {
|
||||
return view.state
|
||||
.field(remoteCursorStateField)
|
||||
.filter((remoteCursor) => remoteCursor.to !== undefined && remoteCursor.from !== remoteCursor.to)
|
||||
.flatMap((remoteCursor) => {
|
||||
const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
|
||||
return RectangleMarker.forRange(
|
||||
view,
|
||||
`${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`,
|
||||
selectionRange
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -4,31 +4,30 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useBaseUrl, ORIGIN } from '../../../hooks/common/use-base-url'
|
||||
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
|
||||
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
|
||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||
import styles from './extended-codemirror/codemirror.module.scss'
|
||||
import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension'
|
||||
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
|
||||
import { useCodeMirrorSpellCheckExtension } from './hooks/code-mirror-extensions/use-code-mirror-spell-check-extension'
|
||||
import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension'
|
||||
import { useCodeMirrorRemoteCursorsExtension } from './hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions'
|
||||
import { useCodeMirrorScrollWatchExtension } from './hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension'
|
||||
import { useCodeMirrorSpellCheckExtension } from './hooks/codemirror-extensions/use-code-mirror-spell-check-extension'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
|
||||
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
|
||||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
|
||||
import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference'
|
||||
import { useAwareness } from './hooks/yjs/use-awareness'
|
||||
import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
|
||||
import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
|
||||
import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
|
||||
import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
|
||||
import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
|
||||
import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
|
||||
import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated'
|
||||
import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
|
||||
import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection'
|
||||
import { useRealtimeConnection } from './hooks/yjs/use-realtime-connection'
|
||||
import { useReceiveRealtimeUsers } from './hooks/yjs/use-receive-realtime-users'
|
||||
import { useYDoc } from './hooks/yjs/use-y-doc'
|
||||
import { useYDocSyncClientAdapter } from './hooks/yjs/use-y-doc-sync-client-adapter'
|
||||
import { useLinter } from './linter/linter'
|
||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import { StatusBar } from './status-bar/status-bar'
|
||||
|
@ -40,9 +39,11 @@ import { lintGutter } from '@codemirror/lint'
|
|||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import ReactCodeMirror from '@uiw/react-codemirror'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EditorPaneProps = ScrollProps
|
||||
|
||||
/**
|
||||
* Renders the text editor pane of the editor.
|
||||
* The used editor is {@link ReactCodeMirror code mirror}.
|
||||
|
@ -52,41 +53,41 @@ import { useTranslation } from 'react-i18next'
|
|||
* @param onMakeScrollSource The callback to request to become the scroll source.
|
||||
* @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror
|
||||
*/
|
||||
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
|
||||
export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
useApplyScrollState(scrollState)
|
||||
|
||||
const messageTransporter = useRealtimeConnection()
|
||||
const yDoc = useYDoc(messageTransporter)
|
||||
const yText = useMarkdownContentYText(yDoc)
|
||||
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
|
||||
const tablePasteExtensions = useCodeMirrorTablePasteExtension()
|
||||
const fileInsertExtension = useCodeMirrorFileInsertExtension()
|
||||
const spellCheckExtension = useCodeMirrorSpellCheckExtension()
|
||||
const cursorActivityExtension = useCursorActivityCallback()
|
||||
|
||||
const updateViewContextExtension = useUpdateCodeMirrorReference()
|
||||
|
||||
const yDoc = useYDoc()
|
||||
const awareness = useAwareness(yDoc)
|
||||
const yText = useMarkdownContentYText(yDoc)
|
||||
const websocketConnection = useWebsocketConnection(yDoc, awareness)
|
||||
const connectionSynced = useIsConnectionSynced(websocketConnection)
|
||||
useBindYTextToRedux(yText)
|
||||
useOnMetadataUpdated(websocketConnection)
|
||||
useOnNoteDeleted(websocketConnection)
|
||||
const remoteCursorsExtension = useCodeMirrorRemoteCursorsExtension(messageTransporter)
|
||||
|
||||
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
|
||||
const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
|
||||
useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection)
|
||||
const linter = useLinter()
|
||||
const linterExtension = useLinter()
|
||||
|
||||
const syncAdapter = useYDocSyncClientAdapter(messageTransporter, yDoc)
|
||||
const yjsExtension = useCodeMirrorYjsExtension(yText, syncAdapter)
|
||||
|
||||
useOnMetadataUpdated(messageTransporter)
|
||||
useOnNoteDeleted(messageTransporter)
|
||||
|
||||
useBindYTextToRedux(yText)
|
||||
useReceiveRealtimeUsers(messageTransporter)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
linter,
|
||||
linterExtension,
|
||||
lintGutter(),
|
||||
markdown({
|
||||
base: markdownLanguage,
|
||||
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
|
||||
}),
|
||||
remoteCursorsExtension,
|
||||
EditorView.lineWrapping,
|
||||
editorScrollExtension,
|
||||
tablePasteExtensions,
|
||||
|
@ -95,34 +96,40 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
cursorActivityExtension,
|
||||
updateViewContextExtension,
|
||||
yjsExtension,
|
||||
firstEditorUpdateExtension,
|
||||
spellCheckExtension
|
||||
],
|
||||
[
|
||||
linter,
|
||||
linterExtension,
|
||||
remoteCursorsExtension,
|
||||
editorScrollExtension,
|
||||
tablePasteExtensions,
|
||||
fileInsertExtension,
|
||||
cursorActivityExtension,
|
||||
updateViewContextExtension,
|
||||
yjsExtension,
|
||||
firstEditorUpdateExtension,
|
||||
spellCheckExtension
|
||||
]
|
||||
)
|
||||
|
||||
useOnImageUploadFromRenderer()
|
||||
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
const codeMirrorClassName = useMemo(
|
||||
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
|
||||
[ligaturesEnabled]
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const darkModeActivated = useDarkModeState()
|
||||
|
||||
const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
|
||||
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
|
||||
return () => {
|
||||
listener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -130,11 +137,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
onTouchStart={onMakeScrollSource}
|
||||
onMouseEnter={onMakeScrollSource}
|
||||
{...cypressId('editor-pane')}
|
||||
{...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}>
|
||||
{...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
|
||||
<MaxLengthWarning />
|
||||
<ToolBar />
|
||||
<ReactCodeMirror
|
||||
editable={firstUpdateHappened && connectionSynced}
|
||||
editable={updateViewContextExtension !== null && isSynced}
|
||||
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
|
||||
extensions={extensions}
|
||||
width={'100%'}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
createCursorLayer,
|
||||
createSelectionLayer,
|
||||
remoteCursorStateField
|
||||
} from '../../codemirror-extensions/remote-cursors/cursor-layers-extensions'
|
||||
import { ReceiveRemoteCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin'
|
||||
import { SendCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/send-cursor-view-plugin'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Bundles all extensions that are needed for the remote cursor display.
|
||||
* @return The created codemirror extensions
|
||||
*/
|
||||
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
|
||||
useMemo(
|
||||
() => [
|
||||
remoteCursorStateField.extension,
|
||||
createCursorLayer(),
|
||||
createSelectionLayer(),
|
||||
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
|
||||
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
|
||||
],
|
||||
[messageTransporter]
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Checks if the given message transporter has received at least one full synchronisation.
|
||||
*
|
||||
* @param connection The connection whose sync status should be checked
|
||||
* @return If at least one full synchronisation is occurred.
|
||||
*/
|
||||
export const useIsConnectionSynced = (connection: YDocMessageTransporter): boolean => {
|
||||
const [editorEnabled, setEditorEnabled] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const enableEditor = () => setEditorEnabled(true)
|
||||
const disableEditor = () => setEditorEnabled(false)
|
||||
connection.on('synced', enableEditor)
|
||||
connection.on('disconnected', disableEditor)
|
||||
return () => {
|
||||
connection.off('synced', enableEditor)
|
||||
connection.off('disconnected', disableEditor)
|
||||
}
|
||||
}, [connection])
|
||||
|
||||
return editorEnabled
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Provides an extension that checks when the code mirror, that loads the extension, has its first update.
|
||||
*
|
||||
* @return The extension that listens for editor updates and a boolean that defines if the first update already happened
|
||||
*/
|
||||
export const useOnFirstEditorUpdateExtension = (): [Extension, boolean] => {
|
||||
const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
|
||||
const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), [])
|
||||
return [extension, firstUpdateHappened]
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { useWebsocketUrl } from './use-websocket-url'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
const logger = new Logger('websocket connection')
|
||||
const WEBSOCKET_RECONNECT_INTERVAL = 3000
|
||||
|
||||
/**
|
||||
* Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend.
|
||||
*
|
||||
* @return the created connection handler
|
||||
*/
|
||||
export const useRealtimeConnection = (): MessageTransporter => {
|
||||
const websocketUrl = useWebsocketUrl()
|
||||
const messageTransporter = useMemo(() => {
|
||||
if (isMockMode) {
|
||||
logger.debug('Creating Loopback connection...')
|
||||
return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
|
||||
} else {
|
||||
logger.debug('Creating Websocket connection...')
|
||||
return new WebsocketTransporter()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const establishWebsocketConnection = useCallback(() => {
|
||||
if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
|
||||
logger.debug(`Connecting to ${websocketUrl.toString()}`)
|
||||
const socket = new WebSocket(websocketUrl)
|
||||
socket.addEventListener('error', () => {
|
||||
setTimeout(() => {
|
||||
establishWebsocketConnection()
|
||||
}, WEBSOCKET_RECONNECT_INTERVAL)
|
||||
})
|
||||
socket.addEventListener('open', () => {
|
||||
messageTransporter.setWebsocket(socket)
|
||||
})
|
||||
}
|
||||
}, [messageTransporter, websocketUrl])
|
||||
|
||||
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
|
||||
const firstConnect = useRef(true)
|
||||
|
||||
const reconnectTimeout = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
return
|
||||
}
|
||||
if (firstConnect.current) {
|
||||
establishWebsocketConnection()
|
||||
firstConnect.current = false
|
||||
} else {
|
||||
reconnectTimeout.current = window.setTimeout(() => {
|
||||
establishWebsocketConnection()
|
||||
}, WEBSOCKET_RECONNECT_INTERVAL)
|
||||
}
|
||||
}, [establishWebsocketConnection, isConnected, messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const readyListener = messageTransporter.doAsSoonAsReady(() => {
|
||||
const timerId = reconnectTimeout.current
|
||||
if (timerId !== undefined) {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
reconnectTimeout.current = undefined
|
||||
})
|
||||
|
||||
messageTransporter.on('connected', () => logger.debug(`Connected`))
|
||||
messageTransporter.on('disconnected', () => logger.debug(`Disconnected`))
|
||||
|
||||
return () => {
|
||||
const interval = reconnectTimeout.current
|
||||
interval && window.clearTimeout(interval)
|
||||
readyListener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const disconnectCallback = () => messageTransporter.disconnect()
|
||||
window.addEventListener('beforeunload', disconnectCallback)
|
||||
return () => window.removeEventListener('beforeunload', disconnectCallback)
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const connectedListener = messageTransporter.doAsSoonAsConnected(() => setRealtimeConnectionState(true))
|
||||
const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
|
||||
return () => {
|
||||
connectedListener.off()
|
||||
disconnectedListener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
return messageTransporter
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -3,16 +3,28 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Doc } from 'yjs'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { RealtimeDoc } from '@hedgedoc/commons'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Creates a new {@link Doc y-doc}.
|
||||
* Creates a new {@link RealtimeDoc y-doc}.
|
||||
*
|
||||
* @return The created {@link Doc y-doc}
|
||||
* @return The created {@link RealtimeDoc y-doc}
|
||||
*/
|
||||
export const useYDoc = (): Doc => {
|
||||
const yDoc = useMemo(() => new Doc(), [])
|
||||
useEffect(() => () => yDoc.destroy(), [yDoc])
|
||||
export const useYDoc = (messageTransporter: MessageTransporter): RealtimeDoc | undefined => {
|
||||
const [yDoc, setYDoc] = useState<RealtimeDoc>()
|
||||
|
||||
useEffect(() => {
|
||||
messageTransporter.doAsSoonAsConnected(() => {
|
||||
setYDoc(new RealtimeDoc())
|
||||
})
|
||||
messageTransporter.on('disconnected', () => {
|
||||
setYDoc(undefined)
|
||||
})
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => () => yDoc?.destroy(), [yDoc])
|
||||
|
||||
return yDoc
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { resetRealtimeStatus } from '../../redux/realtime/methods'
|
||||
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
|
||||
*
|
||||
* @param children The children to load after the reset
|
||||
*/
|
||||
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
|
||||
useEffect(() => {
|
||||
resetRealtimeStatus()
|
||||
setGlobalStateInitialized(true)
|
||||
}, [])
|
||||
if (!globalStateInitialized) {
|
||||
return <LoadingScreen />
|
||||
} else {
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -3,16 +3,16 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
|
||||
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
|
||||
import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
|
||||
import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
|
||||
import styles from './user-line.module.scss'
|
||||
import React from 'react'
|
||||
|
||||
export interface UserLineProps {
|
||||
username: string | null
|
||||
color: string
|
||||
status: ActiveIndicatorStatus
|
||||
active: boolean
|
||||
color: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -22,19 +22,20 @@ export interface UserLineProps {
|
|||
* @param color The color of the user's edits.
|
||||
* @param status The user's current online status.
|
||||
*/
|
||||
export const UserLine: React.FC<UserLineProps> = ({ username, color, status }) => {
|
||||
export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => {
|
||||
return (
|
||||
<div className={'d-flex align-items-center h-100 w-100'}>
|
||||
<div
|
||||
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']}`}
|
||||
style={{ borderLeftColor: color }}
|
||||
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']} ${createCursorCssClass(
|
||||
color
|
||||
)}`}
|
||||
/>
|
||||
<UserAvatarForUsername
|
||||
username={username}
|
||||
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
|
||||
/>
|
||||
<div className={styles['active-indicator-container']}>
|
||||
<ActiveIndicator status={status} />
|
||||
<ActiveIndicator active={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -3,12 +3,11 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
|
||||
import styles from './active-indicator.module.scss'
|
||||
import React from 'react'
|
||||
|
||||
export interface ActiveIndicatorProps {
|
||||
status: ActiveIndicatorStatus
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,6 +15,6 @@ export interface ActiveIndicatorProps {
|
|||
*
|
||||
* @param status The state of the indicator to render
|
||||
*/
|
||||
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ status }) => {
|
||||
return <span className={`${styles['activeIndicator']} ${status}`} />
|
||||
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ active }) => {
|
||||
return <span className={`${styles['activeIndicator']} ${active ? styles.active : styles.inactive}`} />
|
||||
}
|
||||
|
|
|
@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
selectedMenuId
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const onlineUsers = useApplicationState((state) => state.realtime.users)
|
||||
const realtimeUsers = useApplicationState((state) => state.realtimeStatus.onlineUsers)
|
||||
useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const value = `${Object.keys(onlineUsers).length}`
|
||||
buttonRef.current?.style.setProperty('--users-online', `"${value}"`)
|
||||
}, [onlineUsers])
|
||||
buttonRef.current?.style.setProperty('--users-online', `"${realtimeUsers.length}"`)
|
||||
}, [realtimeUsers])
|
||||
|
||||
const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
|
||||
const expand = selectedMenuId === menuId
|
||||
const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick])
|
||||
|
||||
const onlineUserElements = useMemo(() => {
|
||||
const entries = Object.entries(onlineUsers)
|
||||
if (entries.length === 0) {
|
||||
if (realtimeUsers.length === 0) {
|
||||
return (
|
||||
<SidebarButton>
|
||||
<span className={'ms-3'}>
|
||||
|
@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
</SidebarButton>
|
||||
)
|
||||
} else {
|
||||
return entries.map(([clientId, onlineUser]) => {
|
||||
return realtimeUsers.map((realtimeUser) => {
|
||||
return (
|
||||
<SidebarButton key={clientId}>
|
||||
<UserLine username={onlineUser.username} color={onlineUser.color} status={onlineUser.active} />
|
||||
<SidebarButton key={realtimeUser.styleIndex}>
|
||||
<UserLine
|
||||
username={realtimeUser.displayName}
|
||||
color={realtimeUser.styleIndex}
|
||||
active={realtimeUser.active}
|
||||
/>
|
||||
</SidebarButton>
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [onlineUsers])
|
||||
}, [realtimeUsers])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { WaitSpinner } from '../../common/wait-spinner/wait-spinner'
|
||||
import React from 'react'
|
||||
import { Col, Container, Modal, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Modal with a spinner that is only shown while reconnecting to the realtime backend
|
||||
*/
|
||||
export const RealtimeConnectionModal: React.FC = () => {
|
||||
const isConnected = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<Modal show={!isConnected}>
|
||||
<Modal.Body>
|
||||
<Container className={'text-center'}>
|
||||
<Row className={'mb-4'}>
|
||||
<Col xs={12}>
|
||||
<WaitSpinner size={5}></WaitSpinner>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<span>
|
||||
<Trans i18nKey={'realtime.reconnect'}></Trans>
|
||||
</span>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue