Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / docs-core / lib / EditorController / EditorController.ts
blob1cb1f7879190e48500cdff54ffeeb15fd3d404fe
1 import type { LoggerInterface } from '@proton/utils/logs'
2 import {
3   DocUpdateOrigin,
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
31   constructor(
32     private readonly logger: LoggerInterface,
33     private _exportAndDownload: ExportAndDownload,
34     private readonly documentState: DocumentState | PublicDocumentState,
35   ) {
36     documentState.subscribeToProperty('realtimeReadyToBroadcast', (value) => {
37       if (this.editorInvoker && value) {
38         this.showEditorForTheFirstTime()
39       }
40     })
42     documentState.subscribeToProperty('realtimeConnectionTimedOut', (value) => {
43       if (this.editorInvoker && value) {
44         this.showEditorForTheFirstTime()
45       }
46     })
48     documentState.subscribeToProperty('baseCommit', (_value) => {
49       this.sendBaseCommitToEditor()
50     })
52     this.documentState.subscribeToEvent('RealtimeReceivedOtherClientPresenceState', (payload) => {
53       if (this.editorInvoker) {
54         void this.editorInvoker.receiveMessage({
55           type: {
56             wrapper: 'events',
57             eventType: EventType.create(EventTypeEnum.ClientIsBroadcastingItsPresenceState).value,
58           },
59           content: payload,
60         })
61       }
62     })
64     this.documentState.subscribeToEvent('RealtimeRequestingClientToBroadcastItsState', () => {
65       if (this.editorInvoker) {
66         void this.editorInvoker.broadcastPresenceState()
67       }
68     })
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()
75       }
76     })
78     const propertiesToObserve: (keyof DocumentStateValues)[] = [
79       'documentTrashState',
80       'editorHasRenderingIssue',
81       'realtimeIsExperiencingErroredSync',
82       'realtimeIsLockedDueToSizeContraint',
83       'realtimeIsParticipantLimitReached',
84       'realtimeStatus',
85       'userRole',
86     ]
88     propertiesToObserve.forEach((property) => {
89       documentState.subscribeToProperty(property, () => {
90         this.reloadEditingLockedState()
91       })
92     })
94     this.documentState.subscribeToEvent('RealtimeReceivedDocumentUpdate', (payload) => {
95       void this.editorInvoker?.receiveMessage({
96         type: { wrapper: 'du' },
97         content: payload.content,
98       })
99     })
100   }
102   sendBaseCommitToEditor(): void {
103     const baseCommit = this.documentState.getProperty('baseCommit')
104     if (!baseCommit) {
105       return
106     }
108     const squashedContent = baseCommit.squashedRepresentation()
109     void this.editorInvoker?.receiveMessage({
110       type: { wrapper: 'du' },
111       content: squashedContent,
112       origin: DocUpdateOrigin.InitialLoad,
113     })
114   }
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()
126   }
128   showEditorForTheFirstTime(): void {
129     if (!this.editorInvoker) {
130       throw new Error('Editor invoker not initialized')
131     }
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', {
140         realtimeEnabled,
141         realtimeReadyToBroadcast,
142         realtimeConnectionTimedOut,
143       })
144       return
145     }
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',
154       payload: undefined,
155     })
157     this.reloadEditingLockedState()
158   }
160   changeLockedState(shouldLock: boolean): void {
161     if (!this.editorInvoker) {
162       throw new Error('Editor invoker not initialized')
163     }
165     void this.editorInvoker.changeLockedState(shouldLock)
166   }
168   performClosingCeremony(): void {
169     if (!this.editorInvoker) {
170       throw new Error('Editor invoker not initialized')
171     }
173     void this.editorInvoker.performClosingCeremony()
174   }
176   broadcastPresenceState(): void {
177     if (!this.editorInvoker) {
178       throw new Error('Editor invoker not initialized')
179     }
181     void this.editorInvoker.broadcastPresenceState()
182   }
184   async toggleDebugTreeView(): Promise<void> {
185     if (!this.editorInvoker) {
186       throw new Error('Editor invoker not initialized')
187     }
189     void this.editorInvoker.toggleDebugTreeView()
190   }
192   async printAsPDF(): Promise<void> {
193     if (!this.editorInvoker) {
194       throw new Error('Editor invoker not initialized')
195     }
197     void this.editorInvoker.printAsPDF()
198   }
200   async getEditorJSON(): Promise<SerializedEditorState | undefined> {
201     if (!this.editorInvoker) {
202       throw new Error('Editor invoker not initialized')
203     }
205     const json = await this.editorInvoker.getCurrentEditorState()
206     return json
207   }
209   showCommentsPanel(): void {
210     if (!this.editorInvoker) {
211       return
212     }
214     void this.editorInvoker.showCommentsPanel()
215   }
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`)
220     }
222     return this.editorInvoker.exportData(format)
223   }
225   async getDocumentState(): Promise<Uint8Array> {
226     if (!this.editorInvoker) {
227       throw new Error('Attempting to get document state before editor invoker is initialized')
228     }
230     return this.editorInvoker.getDocumentState()
231   }
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`)
236     }
238     const data = await this.exportData(format)
240     await this._exportAndDownload.execute(data, this.documentState.getProperty('documentName'), format)
241   }
243   public async getDocumentClientId(): Promise<number | undefined> {
244     if (this.editorInvoker) {
245       return this.editorInvoker.getClientId()
246     }
248     return undefined
249   }
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')
254     }
256     await this.editorInvoker.replaceEditorState(lexicalState)
257   }
259   reloadEditingLockedState(): void {
260     if (!this.editorInvoker) {
261       return
262     }
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')
282     } else {
283       this.logger.info('Unlocking editor')
284       shouldLock = false
285     }
287     void this.editorInvoker.changeLockedState(shouldLock)
288     if (shouldLock) {
289       this.incrementMetricsReadonlyState()
290     }
291   }
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'
306     }
308     metrics.docs_readonly_mode_documents_total.increment({
309       reason: reason,
310     })
311   }