1 import type { LoggerInterface } from '@proton/utils/logs'
4 type ClientRequiresEditorMethods,
5 type DataTypesThatDocumentCanBeExportedAs,
6 } from '@proton/docs-shared'
7 import type { ExportAndDownload } from '../UseCase/ExportAndDownload'
8 import type { SerializedEditorState } from 'lexical'
9 import type { DocumentState, DocumentStateValues, PublicDocumentState } from '../State/DocumentState'
10 import metrics from '@proton/metrics'
11 import type { HttpsProtonMeDocsReadonlyModeDocumentsTotalV1SchemaJson } from '@proton/metrics/types/docs_readonly_mode_documents_total_v1.schema'
12 import { EventTypeEnum } from '@proton/docs-proto'
13 import { EventType } from '@proton/docs-proto'
15 export interface EditorControllerInterface {
16 exportAndDownload(format: DataTypesThatDocumentCanBeExportedAs): Promise<void>
17 exportData(format: DataTypesThatDocumentCanBeExportedAs): Promise<Uint8Array>
18 getDocumentClientId(): Promise<number | undefined>
19 getDocumentState(): Promise<Uint8Array>
20 getEditorJSON(): Promise<SerializedEditorState | undefined>
21 printAsPDF(): Promise<void>
22 receiveEditor(editorInvoker: ClientRequiresEditorMethods): void
23 restoreRevisionByReplacing(lexicalState: SerializedEditorState): Promise<void>
24 showCommentsPanel(): void
25 toggleDebugTreeView(): Promise<void>
28 export class EditorController implements EditorControllerInterface {
29 private editorInvoker?: ClientRequiresEditorMethods
32 private readonly logger: LoggerInterface,
33 private _exportAndDownload: ExportAndDownload,
34 private readonly documentState: DocumentState | PublicDocumentState,
36 documentState.subscribeToProperty('realtimeReadyToBroadcast', (value) => {
37 if (this.editorInvoker && value) {
38 this.showEditorForTheFirstTime()
42 documentState.subscribeToProperty('realtimeConnectionTimedOut', (value) => {
43 if (this.editorInvoker && value) {
44 this.showEditorForTheFirstTime()
48 documentState.subscribeToProperty('baseCommit', (_value) => {
49 this.sendBaseCommitToEditor()
52 this.documentState.subscribeToEvent('RealtimeReceivedOtherClientPresenceState', (payload) => {
53 if (this.editorInvoker) {
54 void this.editorInvoker.receiveMessage({
57 eventType: EventType.create(EventTypeEnum.ClientIsBroadcastingItsPresenceState).value,
64 this.documentState.subscribeToEvent('RealtimeRequestingClientToBroadcastItsState', () => {
65 if (this.editorInvoker) {
66 void this.editorInvoker.broadcastPresenceState()
70 this.documentState.subscribeToEvent('RealtimeConnectionClosed', () => {
71 if (this.editorInvoker) {
72 this.logger.info('Changing editing allowance to false after RTS disconnect')
74 void this.editorInvoker.performClosingCeremony()
78 const propertiesToObserve: (keyof DocumentStateValues)[] = [
80 'editorHasRenderingIssue',
81 'realtimeIsExperiencingErroredSync',
82 'realtimeIsLockedDueToSizeContraint',
83 'realtimeIsParticipantLimitReached',
88 propertiesToObserve.forEach((property) => {
89 documentState.subscribeToProperty(property, () => {
90 this.reloadEditingLockedState()
94 this.documentState.subscribeToEvent('RealtimeReceivedDocumentUpdate', (payload) => {
95 void this.editorInvoker?.receiveMessage({
96 type: { wrapper: 'du' },
97 content: payload.content,
102 sendBaseCommitToEditor(): void {
103 const baseCommit = this.documentState.getProperty('baseCommit')
108 const squashedContent = baseCommit.squashedRepresentation()
109 void this.editorInvoker?.receiveMessage({
110 type: { wrapper: 'du' },
111 content: squashedContent,
112 origin: DocUpdateOrigin.InitialLoad,
116 receiveEditor(editorInvoker: ClientRequiresEditorMethods): void {
117 this.editorInvoker = editorInvoker
119 this.logger.info('Editor is ready to receive invocations')
121 this.documentState.setProperty('editorReady', true)
123 this.sendBaseCommitToEditor()
125 this.showEditorForTheFirstTime()
128 showEditorForTheFirstTime(): void {
129 if (!this.editorInvoker) {
130 throw new Error('Editor invoker not initialized')
133 const realtimeEnabled = this.documentState.getProperty('realtimeEnabled')
134 const realtimeReadyToBroadcast = this.documentState.getProperty('realtimeReadyToBroadcast')
135 const realtimeConnectionTimedOut = this.documentState.getProperty('realtimeConnectionTimedOut')
136 const realtimeIsDoneLoading = realtimeReadyToBroadcast || realtimeConnectionTimedOut
138 if (realtimeEnabled && !realtimeIsDoneLoading) {
139 this.logger.info('Not showing editor for the first time due to RTS status', {
141 realtimeReadyToBroadcast,
142 realtimeConnectionTimedOut,
147 this.logger.info('Showing editor for the first time')
149 void this.editorInvoker.showEditor()
150 void this.editorInvoker.performOpeningCeremony()
152 this.documentState.emitEvent({
153 name: 'EditorIsReadyToBeShown',
157 this.reloadEditingLockedState()
160 changeLockedState(shouldLock: boolean): void {
161 if (!this.editorInvoker) {
162 throw new Error('Editor invoker not initialized')
165 void this.editorInvoker.changeLockedState(shouldLock)
168 performClosingCeremony(): void {
169 if (!this.editorInvoker) {
170 throw new Error('Editor invoker not initialized')
173 void this.editorInvoker.performClosingCeremony()
176 broadcastPresenceState(): void {
177 if (!this.editorInvoker) {
178 throw new Error('Editor invoker not initialized')
181 void this.editorInvoker.broadcastPresenceState()
184 async toggleDebugTreeView(): Promise<void> {
185 if (!this.editorInvoker) {
186 throw new Error('Editor invoker not initialized')
189 void this.editorInvoker.toggleDebugTreeView()
192 async printAsPDF(): Promise<void> {
193 if (!this.editorInvoker) {
194 throw new Error('Editor invoker not initialized')
197 void this.editorInvoker.printAsPDF()
200 async getEditorJSON(): Promise<SerializedEditorState | undefined> {
201 if (!this.editorInvoker) {
202 throw new Error('Editor invoker not initialized')
205 const json = await this.editorInvoker.getCurrentEditorState()
209 showCommentsPanel(): void {
210 if (!this.editorInvoker) {
214 void this.editorInvoker.showCommentsPanel()
217 async exportData(format: DataTypesThatDocumentCanBeExportedAs): Promise<Uint8Array> {
218 if (!this.editorInvoker) {
219 throw new Error(`Attepting to export document before editor invoker or decrypted node is initialized`)
222 return this.editorInvoker.exportData(format)
225 async getDocumentState(): Promise<Uint8Array> {
226 if (!this.editorInvoker) {
227 throw new Error('Attempting to get document state before editor invoker is initialized')
230 return this.editorInvoker.getDocumentState()
233 async exportAndDownload(format: DataTypesThatDocumentCanBeExportedAs): Promise<void> {
234 if (!this.editorInvoker) {
235 throw new Error(`Attepting to export document before editor invoker or decrypted node is initialized`)
238 const data = await this.exportData(format)
240 await this._exportAndDownload.execute(data, this.documentState.getProperty('documentName'), format)
243 public async getDocumentClientId(): Promise<number | undefined> {
244 if (this.editorInvoker) {
245 return this.editorInvoker.getClientId()
251 public async restoreRevisionByReplacing(lexicalState: SerializedEditorState): Promise<void> {
252 if (!this.editorInvoker) {
253 throw new Error('Attempting to restore revision by replacing before editor invoker is initialized')
256 await this.editorInvoker.replaceEditorState(lexicalState)
259 reloadEditingLockedState(): void {
260 if (!this.editorInvoker) {
264 const role = this.documentState.getProperty('userRole')
266 let shouldLock = true
268 if (this.documentState.getProperty('realtimeIsParticipantLimitReached') && !role.isAdmin()) {
269 this.logger.info('Max users. Changing editing locked to true')
270 } else if (!role.canEdit()) {
271 this.logger.info('Locking editor due to lack of editing permissions')
272 } else if (this.documentState.getProperty('realtimeIsExperiencingErroredSync')) {
273 this.logger.info('Locking editor due to errored sync')
274 } else if (this.documentState.getProperty('realtimeIsLockedDueToSizeContraint')) {
275 this.logger.info('Locking editor due to size constraint')
276 } else if (this.documentState.getProperty('editorHasRenderingIssue')) {
277 this.logger.info('Locking editor due to editor rendering issue')
278 } else if (this.documentState.getProperty('realtimeStatus') !== 'connected') {
279 this.logger.info('Locking editor due to websocket status', this.documentState.getProperty('realtimeStatus'))
280 } else if (this.documentState.getProperty('documentTrashState') === 'trashed') {
281 this.logger.info('Locking editor due to trash state')
283 this.logger.info('Unlocking editor')
287 void this.editorInvoker.changeLockedState(shouldLock)
289 this.incrementMetricsReadonlyState()
293 incrementMetricsReadonlyState(): void {
294 let reason: HttpsProtonMeDocsReadonlyModeDocumentsTotalV1SchemaJson['Labels']['reason'] = 'unknown'
296 if (this.documentState.getProperty('realtimeIsParticipantLimitReached')) {
297 reason = 'user_limit_reached'
298 } else if (!this.documentState.getProperty('userRole').canEdit()) {
299 reason = 'no_editing_permissions'
300 } else if (this.documentState.getProperty('realtimeIsExperiencingErroredSync')) {
301 reason = 'errored_sync'
302 } else if (this.documentState.getProperty('realtimeIsLockedDueToSizeContraint')) {
303 reason = 'size_limit'
304 } else if (this.documentState.getProperty('realtimeStatus') !== 'connected') {
305 reason = 'not_connected'
308 metrics.docs_readonly_mode_documents_total.increment({