/* * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Message, MessageTransporter, MessageType, RealtimeUser, } from '@hedgedoc/commons'; import { Listener } from 'eventemitter2'; import { Username } from '../../utils/username'; export type OtherAdapterCollector = () => RealtimeUserStatusAdapter[]; /** * 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( private readonly username: Username | null, private readonly displayName: string, private collectOtherAdapters: OtherAdapterCollector, private messageTransporter: MessageTransporter, private acceptCursorUpdateProvider: () => boolean, ) { this.realtimeUser = this.createInitialRealtimeUserState(); this.bindRealtimeUserStateEvents(); } private createInitialRealtimeUserState(): RealtimeUser { return { username: this.username, displayName: this.displayName, active: true, styleIndex: this.findLeastUsedStyleIndex( this.createStyleIndexToCountMap(), ), cursor: !this.acceptCursorUpdateProvider() ? null : { from: 0, to: 0, }, }; } private bindRealtimeUserStateEvents(): void { const transporterMessagesListener = this.messageTransporter.on( MessageType.REALTIME_USER_SINGLE_UPDATE, (message: Message) => { this.realtimeUser.cursor = this.acceptCursorUpdateProvider() ? message.payload : null; this.collectOtherAdapters() .filter((adapter) => adapter !== this) .forEach((adapter) => adapter.sendCompleteStateToClient()); }, { objectify: true }, ) as Listener; const transporterRequestMessageListener = this.messageTransporter.on( MessageType.REALTIME_USER_STATE_REQUEST, () => { this.sendCompleteStateToClient(); }, { objectify: true }, ) as Listener; const clientRemoveListener = this.messageTransporter.on( 'disconnected', () => { this.collectOtherAdapters() .filter((adapter) => adapter !== this) .forEach((adapter) => adapter.sendCompleteStateToClient()); }, { objectify: true, }, ) as Listener; const realtimeUserSetActivityListener = this.messageTransporter.on( MessageType.REALTIME_USER_SET_ACTIVITY, (message: Message) => { if (this.realtimeUser.active === message.payload.active) { return; } this.realtimeUser.active = message.payload.active; this.collectOtherAdapters() .filter((adapter) => adapter !== this) .forEach((adapter) => adapter.sendCompleteStateToClient()); }, { objectify: true }, ) as Listener; this.messageTransporter.on('disconnected', () => { transporterMessagesListener?.off(); transporterRequestMessageListener.off(); clientRemoveListener.off(); realtimeUserSetActivityListener.off(); }); } private getSendableState(): RealtimeUser | undefined { return this.messageTransporter.isReady() ? this.realtimeUser : undefined; } public sendCompleteStateToClient(): void { if (!this.messageTransporter.isReady()) { return; } const realtimeUsers = this.collectOtherAdapters() .filter((adapter) => adapter !== this) .map((adapter) => adapter.getSendableState()) .filter((value) => value !== undefined) as RealtimeUser[]; this.messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_SET, payload: { users: realtimeUsers, ownUser: { displayName: this.realtimeUser.displayName, styleIndex: this.realtimeUser.styleIndex, username: this.realtimeUser.username, }, }, }); } private findLeastUsedStyleIndex(map: Map): number { let leastUsedStyleIndex = 0; let leastUsedStyleIndexCount = map.get(0) ?? 0; for (let styleIndex = 0; styleIndex < 8; styleIndex++) { const count = map.get(styleIndex) ?? 0; if (count < leastUsedStyleIndexCount) { leastUsedStyleIndexCount = count; leastUsedStyleIndex = styleIndex; } } return leastUsedStyleIndex; } private createStyleIndexToCountMap(): Map { return this.collectOtherAdapters() .map((adapter) => adapter.realtimeUser.styleIndex) .reduce((map, styleIndex) => { if (styleIndex !== undefined) { const count = (map.get(styleIndex) ?? 0) + 1; map.set(styleIndex, count); } return map; }, new Map()); } }