Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / docs-core / lib / AuthenticatedDocController / AuthenticatedDocController.ts
blob7fab3ea47cae7c223b13979c940cdd06802845af
1 import { c } from 'ttag'
2 import type { SquashDocument } from '../UseCase/SquashDocument'
3 import type { DuplicateDocument } from '../UseCase/DuplicateDocument'
4 import type { CreateNewDocument } from '../UseCase/CreateNewDocument'
5 import type { DriveCompat } from '@proton/drive-store'
6 import type { InternalEventBusInterface, YjsState } from '@proton/docs-shared'
7 import type { AuthenticatedDocControllerInterface } from './AuthenticatedDocControllerInterface'
8 import type { SeedInitialCommit } from '../UseCase/SeedInitialCommit'
9 import type { VersionHistoryUpdate } from '../VersionHistory'
10 import { NativeVersionHistory } from '../VersionHistory'
11 import { DocControllerEvent } from './AuthenticatedDocControllerEvent'
13 import type { DocsClientSquashVerificationObjectionMadePayload } from '../Application/ApplicationEvent'
14 import { ApplicationEvent, PostApplicationError } from '../Application/ApplicationEvent'
15 import type { SquashVerificationObjectionCallback } from '../Types/SquashVerificationObjection'
16 import { TranslatedResult } from '@proton/docs-shared'
17 import type { Result } from '@proton/docs-shared'
18 import { getPlatformFriendlyDateForFileName } from '../Util/PlatformFriendlyFileNameDate'
19 import { MAX_DOC_SIZE } from '../Models/Constants'
20 import type { GetNode } from '../UseCase/GetNode'
21 import { isDocumentState, type DocumentState } from '../State/DocumentState'
22 import type { LoggerInterface } from '@proton/utils/logs'
23 import { getErrorString } from '../Util/GetErrorString'
25 /**
26  * Controls the lifecycle of a single document for an authenticated user.
27  */
28 export class AuthenticatedDocController implements AuthenticatedDocControllerInterface {
29   isDestroyed = false
30   didTrashDocInCurrentSession = false
31   /** Used for history tracking in Version History */
32   receivedOrSentDUs: VersionHistoryUpdate[] = []
34   constructor(
35     private readonly documentState: DocumentState,
36     private driveCompat: DriveCompat,
37     private _squashDocument: SquashDocument,
38     readonly _createInitialCommit: SeedInitialCommit,
39     private _duplicateDocument: DuplicateDocument,
40     private _createNewDocument: CreateNewDocument,
41     readonly _getNode: GetNode,
42     readonly eventBus: InternalEventBusInterface,
43     readonly logger: LoggerInterface,
44   ) {
45     this.subscribeToEvents()
46   }
48   destroy(): void {
49     this.isDestroyed = true
50   }
52   subscribeToEvents(): void {
53     this.documentState.subscribeToProperty('baseCommit', (value) => {
54       if (value && value.needsSquash()) {
55         void this.squashDocument()
56       }
57     })
59     this.documentState.subscribeToEvent('EditorRequestsPropagationOfUpdate', (payload) => {
60       if (this.isDestroyed) {
61         return
62       }
64       if (payload.message.type.wrapper === 'conversion') {
65         void this.handleEditorProvidingInitialConversionContent(payload.message.content)
66       } else if (payload.message.type.wrapper === 'du') {
67         this.receivedOrSentDUs.push({
68           content: payload.message.content,
69           timestamp: Date.now(),
70         })
71       }
72     })
74     this.documentState.subscribeToProperty('baseCommit', (value) => {
75       if (value) {
76         this.receivedOrSentDUs = []
77       }
78     })
80     this.documentState.subscribeToEvent('RealtimeReceivedDocumentUpdate', (payload) => {
81       this.receivedOrSentDUs.push(payload)
82     })
83   }
85   public getVersionHistory(): NativeVersionHistory | undefined {
86     const updates = [...(this.documentState.getProperty('baseCommit')?.updates ?? []), ...this.receivedOrSentDUs]
88     return updates.length > 0 ? new NativeVersionHistory(updates) : undefined
89   }
91   /**
92    *
93    * @param imposeTrashState getNode may return a cached value, due to a race condition with DriveCompat where the node
94    * is removed from cache but done asyncronously. So the refetch below might return a cached value when we are expecting
95    * a fresh reloaded value. This should ultimately be fixed with the DriveCompat but goes too deep into its functionality
96    * to do so.
97    */
98   async refreshNodeAndDocMeta(options: { imposeTrashState: 'trashed' | 'not_trashed' | undefined }): Promise<void> {
99     const docMeta = this.documentState.getProperty('documentMeta')
100     const { nodeMeta } = this.documentState.getProperty('entitlements')
102     const result = await this._getNode.execute(nodeMeta, docMeta)
103     if (result.isFailed()) {
104       this.logger.error('Failed to get node', result.getError())
105       return
106     }
108     const { node, refreshedDocMeta } = result.getValue()
109     this.documentState.setProperty('decryptedNode', node)
111     if (refreshedDocMeta) {
112       this.documentState.setProperty('documentMeta', refreshedDocMeta)
113       this.documentState.setProperty('documentName', refreshedDocMeta.name)
114     }
116     if (options.imposeTrashState) {
117       this.documentState.setProperty('documentTrashState', options.imposeTrashState)
118     } else {
119       this.documentState.setProperty('documentTrashState', node.trashed ? 'trashed' : 'not_trashed')
120     }
121   }
123   async handleEditorProvidingInitialConversionContent(content: Uint8Array): Promise<void> {
124     this.logger.info('Received conversion content from editor, seeding initial commit of size', content.byteLength)
126     this.documentState.emitEvent({
127       name: 'DriveFileConversionToDocBegan',
128       payload: undefined,
129     })
131     if (content.byteLength >= MAX_DOC_SIZE) {
132       this.logger.info('Initial conversion content is too large')
134       PostApplicationError(this.eventBus, {
135         translatedError: c('Error')
136           .t`The document you are trying to convert is too large. This may occur if the document has a large number of images or other media. Please try again with a smaller document.`,
137         irrecoverable: true,
138       })
140       return
141     }
143     const result = await this.createInitialCommit(content)
145     if (result.isFailed()) {
146       PostApplicationError(this.eventBus, {
147         translatedError: c('Error').t`An error occurred while attempting to convert the document. Please try again.`,
148         irrecoverable: true,
149       })
151       return
152     }
154     this.documentState.emitEvent({
155       name: 'DriveFileConversionToDocSucceeded',
156       payload: undefined,
157     })
158   }
160   public async debugSendCommitCommandToRTS(): Promise<void> {
161     if (!isDocumentState(this.documentState)) {
162       return
163     }
165     this.documentState.emitEvent({
166       name: 'DebugMenuRequestingCommitWithRTS',
167       payload: this.documentState.getProperty('entitlements'),
168     })
169   }
171   public async createInitialCommit(content: Uint8Array): Promise<Result<unknown>> {
172     if (!isDocumentState(this.documentState)) {
173       throw new Error('Cannot perform createInitialCommit as a public user')
174     }
176     const result = await this._createInitialCommit.execute(
177       this.documentState.getProperty('entitlements').nodeMeta,
178       content,
179       this.documentState.getProperty('entitlements').keys,
180     )
182     if (result.isFailed()) {
183       this.logger.error('Failed to seed document', result.getError())
184     } else {
185       const resultValue = result.getValue()
186       this.documentState.setProperty('currentCommitId', resultValue.commitId)
187     }
189     return result
190   }
192   public async squashDocument(): Promise<void> {
193     if (!isDocumentState(this.documentState)) {
194       throw new Error('Cannot perform squashDocument as a public user')
195     }
197     const baseCommit = this.documentState.getProperty('baseCommit')
198     if (!baseCommit) {
199       this.logger.info('No initial commit to squash')
200       return
201     }
203     this.logger.info('Squashing document')
205     const handleVerificationObjection: SquashVerificationObjectionCallback = async () => {
206       this.eventBus.publish({
207         type: DocControllerEvent.SquashVerificationObjectionDecisionRequired,
208         payload: undefined,
209       })
211       return new Promise((resolve) => {
212         const disposer = this.eventBus.addEventCallback((data: DocsClientSquashVerificationObjectionMadePayload) => {
213           disposer()
214           resolve(data.decision)
215         }, ApplicationEvent.SquashVerificationObjectionDecisionMade)
216       })
217     }
219     const { keys, nodeMeta } = this.documentState.getProperty('entitlements')
221     const result = await this._squashDocument.execute({
222       nodeMeta,
223       commitId: baseCommit.commitId,
224       keys,
225       handleVerificationObjection,
226     })
228     if (result.isFailed()) {
229       this.logger.error('Failed to squash document', result.getError())
230     } else {
231       this.logger.info('Squash result', result.getValue())
232     }
233   }
235   public async duplicateDocument(editorYjsState: Uint8Array): Promise<void> {
236     const result = await this._duplicateDocument.executePrivate(
237       this.documentState.getProperty('entitlements').nodeMeta,
238       this.documentState.getProperty('documentMeta'),
239       editorYjsState,
240     )
242     if (result.isFailed()) {
243       this.logger.error('Failed to duplicate document', result.getError())
245       PostApplicationError(this.eventBus, {
246         translatedError: c('Error').t`An error occurred while attempting to duplicate the document. Please try again.`,
247       })
249       return
250     }
252     const shell = result.getValue()
254     void this.driveCompat.openDocument(shell)
255   }
257   public async restoreRevisionAsCopy(yjsContent: YjsState): Promise<void> {
258     const result = await this._duplicateDocument.executePrivate(
259       this.documentState.getProperty('entitlements').nodeMeta,
260       this.documentState.getProperty('documentMeta'),
261       yjsContent,
262     )
264     if (result.isFailed()) {
265       this.logger.error('Failed to restore document as copy', result.getError())
267       PostApplicationError(this.eventBus, {
268         translatedError: c('Error').t`An error occurred while attempting to restore the document. Please try again.`,
269       })
271       return
272     }
274     const shell = result.getValue()
276     void this.driveCompat.openDocument(shell)
277   }
279   public async createNewDocument(): Promise<void> {
280     const date = getPlatformFriendlyDateForFileName()
281     // translator: Default title for a new Proton Document (example: Untitled document 2024-04-23)
282     const baseTitle = c('Title').t`Untitled document ${date}`
283     const newName = `${baseTitle}`
285     const result = await this._createNewDocument.execute(
286       newName,
287       this.documentState.getProperty('entitlements').nodeMeta,
288       this.documentState.getProperty('decryptedNode'),
289     )
291     if (result.isFailed()) {
292       this.logger.error('Failed to create new document', result.getError())
294       PostApplicationError(this.eventBus, {
295         translatedError: c('Error').t`An error occurred while creating a new document. Please try again.`,
296       })
298       return
299     }
301     const shell = result.getValue()
303     void this.driveCompat.openDocument(shell)
304   }
306   public async renameDocument(newName: string): Promise<TranslatedResult<void>> {
307     try {
308       const decryptedNode = this.documentState.getProperty('decryptedNode')
309       if (!decryptedNode.parentNodeId) {
310         throw new Error('Cannot rename document')
311       }
313       const name = await this.driveCompat.findAvailableNodeName(
314         {
315           volumeId: decryptedNode.volumeId,
316           linkId: decryptedNode.parentNodeId,
317         },
318         newName,
319       )
320       await this.driveCompat.renameDocument(this.documentState.getProperty('entitlements').nodeMeta, name)
321       await this.refreshNodeAndDocMeta({ imposeTrashState: undefined })
322       return TranslatedResult.ok()
323     } catch (e) {
324       this.logger.error(getErrorString(e) ?? 'Failed to rename document')
326       return TranslatedResult.failWithTranslatedError(c('Error').t`Failed to rename document. Please try again later.`)
327     }
328   }
330   public async trashDocument(): Promise<void> {
331     this.documentState.setProperty('documentTrashState', 'trashing')
333     try {
334       const decryptedNode = this.documentState.getProperty('decryptedNode')
335       const parentLinkId = decryptedNode.parentNodeId || (await this.driveCompat.getMyFilesNodeMeta()).linkId
336       await this.driveCompat.trashDocument(this.documentState.getProperty('entitlements').nodeMeta, parentLinkId)
338       await this.refreshNodeAndDocMeta({ imposeTrashState: 'trashed' })
340       this.didTrashDocInCurrentSession = true
341     } catch (error) {
342       this.logger.error(getErrorString(error) ?? 'Failed to trash document')
344       PostApplicationError(this.eventBus, {
345         translatedError: c('Error').t`An error occurred while attempting to trash the document. Please try again.`,
346       })
347       this.documentState.setProperty('documentTrashState', 'not_trashed')
348     }
349   }
351   public async restoreDocument(): Promise<void> {
352     this.documentState.setProperty('documentTrashState', 'restoring')
354     try {
355       const decryptedNode = this.documentState.getProperty('decryptedNode')
356       const parentLinkId = decryptedNode.parentNodeId || (await this.driveCompat.getMyFilesNodeMeta()).linkId
357       await this.driveCompat.restoreDocument(this.documentState.getProperty('entitlements').nodeMeta, parentLinkId)
359       await this.refreshNodeAndDocMeta({ imposeTrashState: 'not_trashed' })
360     } catch (error) {
361       this.logger.error(getErrorString(error) ?? 'Failed to restore document')
363       PostApplicationError(this.eventBus, {
364         translatedError: c('Error').t`An error occurred while attempting to restore the document. Please try again.`,
365       })
366       this.documentState.setProperty('documentTrashState', 'trashed')
367     }
368   }
370   public openDocumentSharingModal(): void {
371     void this.driveCompat.openDocumentSharingModal(this.documentState.getProperty('entitlements').nodeMeta)
372   }
374   deinit() {}