Merge branch 'feat/inda-347-host-update' into 'main'
[ProtonMail-WebClient.git] / packages / docs-core / lib / Services / Comments / CommentController.ts
blob3db5da0ce22551dbb818d0058544bb275d280411
1 import type {
2   AnyCommentMessageData,
3   CommentControllerInterface,
4   CommentThreadInterface,
5   CommentInterface,
6   InternalEventHandlerInterface,
7   InternalEventInterface,
8   InternalEventBusInterface,
9   CommentMarkNodeChangeData,
10   SuggestionThreadStateAction,
11   SuggestionSummaryType,
12 } from '@proton/docs-shared'
13 import {
14   CommentsMessageType,
15   CommentsEvent,
16   BroadcastSource,
17   CommentThreadState,
18   CommentType,
19 } from '@proton/docs-shared'
20 import type { EncryptComment } from '../../UseCase/EncryptComment'
21 import type { LoggerInterface } from '@proton/utils/logs'
22 import { CreateRealtimeCommentPayload } from './CreateRealtimeCommentPayload'
23 import type { DocumentKeys, NodeMeta } from '@proton/drive-store'
24 import { LocalCommentsState } from './LocalCommentsState'
25 import type { HandleRealtimeCommentsEvent } from '../../UseCase/HandleRealtimeCommentsEvent'
26 import type { CreateThread } from '../../UseCase/CreateThread'
27 import type { CreateComment } from '../../UseCase/CreateComment'
28 import type { LoadThreads } from '../../UseCase/LoadThreads'
29 import { LiveComments } from '../../Realtime/LiveComments/LiveComments'
30 import type { WebsocketServiceInterface } from '../Websockets/WebsocketServiceInterface'
31 import type { DocControllerEventPayloads } from '../../Controller/Document/DocControllerEvent'
32 import { DocControllerEvent } from '../../Controller/Document/DocControllerEvent'
33 import metrics from '@proton/metrics'
34 import { EventTypeEnum } from '@proton/docs-proto'
35 import type { DocsApi } from '../../Api/DocsApi'
36 import { CommentThreadType } from '@proton/docs-shared'
37 import { WebsocketConnectionEvent } from '../../Realtime/WebsocketEvent/WebsocketConnectionEvent'
38 import type { MetricService } from '../Metrics/MetricService'
39 import { TelemetryDocsEvents } from '@proton/shared/lib/api/telemetry'
40 import type { UnleashClient } from '@proton/unleash'
42 const DocsEnableNotificationsOnNewCommentFeatureFlag = 'DocsEnableNotificationsOnNewComment'
44 /**
45  * Controls comments for a single document.
46  */
47 export class CommentController implements CommentControllerInterface, InternalEventHandlerInterface {
48   private localCommentsState: LocalCommentsState
50   public readonly liveComments: LiveComments = new LiveComments(
51     this.websocketService,
52     this.document,
53     this.keys.userOwnAddress,
54     this.eventBus,
55     this.logger,
56   )
58   private shouldSendDocumentName = false
60   constructor(
61     private readonly document: NodeMeta,
62     private readonly keys: DocumentKeys,
63     private readonly websocketService: WebsocketServiceInterface,
64     private readonly metricService: MetricService,
65     private api: DocsApi,
66     private _encryptComment: EncryptComment,
67     private _createThread: CreateThread,
68     private _createComment: CreateComment,
69     private _loadThreads: LoadThreads,
70     private _handleRealtimeEvent: HandleRealtimeCommentsEvent,
71     private getLatestDocumentName: () => string,
72     unleashClient: UnleashClient,
73     public readonly eventBus: InternalEventBusInterface,
74     private logger: LoggerInterface,
75   ) {
76     this.localCommentsState = new LocalCommentsState(eventBus)
77     eventBus.addEventHandler(this, DocControllerEvent.RealtimeCommentMessageReceived)
78     eventBus.addEventHandler(this, WebsocketConnectionEvent.ConnectionEstablishedButNotYetReady)
80     if (unleashClient.isReady()) {
81       this.shouldSendDocumentName = unleashClient.isEnabled(DocsEnableNotificationsOnNewCommentFeatureFlag)
82     }
84     unleashClient.on('update', () => {
85       this.shouldSendDocumentName = unleashClient.isEnabled(DocsEnableNotificationsOnNewCommentFeatureFlag)
86     })
87   }
89   get userDisplayName(): string {
90     return this.keys.userOwnAddress
91   }
93   public fetchAllComments(): void {
94     void this._loadThreads.execute({
95       lookup: this.document,
96       keys: this.keys,
97       commentsState: this.localCommentsState,
98     })
99   }
101   private broadcastCommentMessage(type: CommentsMessageType, dto: AnyCommentMessageData): void {
102     const data = CreateRealtimeCommentPayload(type, dto)
104     void this.websocketService.sendEventMessage(
105       this.document,
106       data,
107       EventTypeEnum.ClientHasSentACommentMessage,
108       BroadcastSource.CommentsController,
109     )
111     if ([CommentsMessageType.AddThread, CommentsMessageType.AddComment].includes(type)) {
112       metrics.docs_comments_total.increment({
113         type: CommentsMessageType.AddThread === type ? 'comment' : 'reply',
114       })
115     }
116   }
118   public getTypersExcludingSelf(threadId: string): string[] {
119     return this.liveComments.getTypingUsers(threadId).filter((user) => user !== this.keys.userOwnAddress)
120   }
122   public beganTypingInThread(threadID: string): void {
123     this.liveComments.setIsTypingComment(threadID, true)
124   }
126   public stoppedTypingInThread(threadID: string): void {
127     this.liveComments.setIsTypingComment(threadID, false)
128   }
130   async handleEvent(event: InternalEventInterface): Promise<void> {
131     if (event.type === WebsocketConnectionEvent.ConnectionEstablishedButNotYetReady) {
132       void this.fetchAllComments()
133     } else if (event.type === DocControllerEvent.RealtimeCommentMessageReceived) {
134       const { message } = event.payload as DocControllerEventPayloads[DocControllerEvent.RealtimeCommentMessageReceived]
136       this._handleRealtimeEvent.execute(this.localCommentsState, this.liveComments, message)
137     }
138   }
140   getAllThreads(): CommentThreadInterface[] {
141     return this.localCommentsState.getAllThreads()
142   }
144   async createCommentThread(
145     commentContent: string,
146     markID?: string,
147     createMarkNode = true,
148   ): Promise<CommentThreadInterface | undefined> {
149     const decryptedDocumentName = this.shouldSendDocumentName ? this.getLatestDocumentName() : null
151     const threadResult = await this._createThread.execute({
152       text: commentContent,
153       keys: this.keys,
154       lookup: this.document,
155       commentsState: this.localCommentsState,
156       markID,
157       createMarkNode,
158       type: CommentThreadType.Comment,
159       decryptedDocumentName,
160     })
162     if (threadResult.isFailed()) {
163       this.logger.error(threadResult.getError())
164       return undefined
165     }
167     const thread = threadResult.getValue()
169     this.broadcastCommentMessage(CommentsMessageType.AddThread, thread.asPayload())
171     return thread
172   }
174   async createSuggestionThread(
175     suggestionID: string,
176     commentContent: string,
177     suggestionType: SuggestionSummaryType,
178   ): Promise<CommentThreadInterface | undefined> {
179     const decryptedDocumentName = this.shouldSendDocumentName ? this.getLatestDocumentName() : null
181     const threadResult = await this._createThread.execute({
182       text: commentContent,
183       keys: this.keys,
184       lookup: this.document,
185       commentsState: this.localCommentsState,
186       markID: suggestionID,
187       createMarkNode: false,
188       type: CommentThreadType.Suggestion,
189       decryptedDocumentName,
190     })
192     if (threadResult.isFailed()) {
193       this.logger.error(threadResult.getError())
194       return undefined
195     }
197     const thread = threadResult.getValue()
199     this.broadcastCommentMessage(CommentsMessageType.AddThread, thread.asPayload())
201     this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_created)
202     this.metricService.reportSuggestionCreated(suggestionType)
204     return thread
205   }
207   async createComment(content: string, threadID: string): Promise<CommentInterface | undefined> {
208     const decryptedDocumentName = this.shouldSendDocumentName ? this.getLatestDocumentName() : null
210     const commentResult = await this._createComment.execute({
211       text: content,
212       threadID,
213       keys: this.keys,
214       lookup: this.document,
215       commentsState: this.localCommentsState,
216       type: CommentType.Comment,
217       decryptedDocumentName,
218     })
220     if (commentResult.isFailed()) {
221       this.logger.error(commentResult.getError())
222       return undefined
223     }
225     const thread = this.localCommentsState.findThreadById(threadID)
226     if (thread && thread.type === CommentThreadType.Suggestion) {
227       this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_commented)
228     }
230     const comment = commentResult.getValue()
232     this.broadcastCommentMessage(CommentsMessageType.AddComment, { comment: comment.asPayload(), threadID })
234     return comment
235   }
237   async createSuggestionSummaryComment(content: string, threadID: string): Promise<CommentInterface | undefined> {
238     const decryptedDocumentName = this.shouldSendDocumentName ? this.getLatestDocumentName() : null
240     const commentResult = await this._createComment.execute({
241       text: content,
242       threadID,
243       keys: this.keys,
244       lookup: this.document,
245       commentsState: this.localCommentsState,
246       type: CommentType.Suggestion,
247       decryptedDocumentName,
248     })
250     if (commentResult.isFailed()) {
251       this.logger.error(commentResult.getError())
252       return undefined
253     }
255     const thread = this.localCommentsState.findThreadById(threadID)
256     if (thread && thread.type === CommentThreadType.Suggestion) {
257       this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_commented)
258     }
260     const comment = commentResult.getValue()
262     this.broadcastCommentMessage(CommentsMessageType.AddComment, { comment: comment.asPayload(), threadID })
264     return comment
265   }
267   async editComment(threadID: string, commentID: string, content: string): Promise<boolean> {
268     const thread = this.localCommentsState.findThreadById(threadID)
269     if (!thread) {
270       throw new Error('Thread not found')
271     }
273     const encryptionResult = await this._encryptComment.execute(content, thread.markID, this.keys)
274     if (encryptionResult.isFailed()) {
275       return false
276     }
278     const encryptedContent = encryptionResult.getValue()
280     const result = await this.api.editComment({
281       volumeId: this.document.volumeId,
282       linkId: this.document.linkId,
283       threadId: threadID,
284       commentId: commentID,
285       encryptedContent: encryptedContent,
286       authorEmail: this.keys.userOwnAddress,
287     })
288     if (result.isFailed()) {
289       return false
290     }
292     this.localCommentsState.editComment({ commentID, threadID, content })
294     this.broadcastCommentMessage(CommentsMessageType.EditComment, { commentID, threadID, content })
296     return true
297   }
299   async deleteThread(id: string): Promise<boolean> {
300     const response = await this.api.deleteThread(this.document.volumeId, this.document.linkId, id)
301     if (response.isFailed()) {
302       return false
303     }
305     this.localCommentsState.deleteThread(id)
307     this.broadcastCommentMessage(CommentsMessageType.DeleteThread, { threadId: id })
309     return true
310   }
312   async deleteComment(threadID: string, commentID: string): Promise<boolean> {
313     const response = await this.api.deleteComment(this.document.volumeId, this.document.linkId, threadID, commentID)
314     if (response.isFailed()) {
315       return false
316     }
318     this.localCommentsState.deleteComment({ commentID, threadID })
320     this.broadcastCommentMessage(CommentsMessageType.DeleteComment, { commentID, threadID })
322     return true
323   }
325   async resolveThread(threadId: string): Promise<boolean> {
326     const response = await this.api.resolveThread(this.document.volumeId, this.document.linkId, threadId)
327     if (response.isFailed()) {
328       return false
329     }
331     const resolvedThread = this.localCommentsState.resolveThread(threadId)
332     if (!resolvedThread) {
333       return false
334     }
336     this.eventBus.publish<CommentMarkNodeChangeData>({
337       type: CommentsEvent.ResolveMarkNode,
338       payload: {
339         markID: resolvedThread.markID,
340       },
341     })
343     this.broadcastCommentMessage(CommentsMessageType.ResolveThread, { threadId })
345     return true
346   }
348   async unresolveThread(threadId: string): Promise<boolean> {
349     const response = await this.api.unresolveThread(this.document.volumeId, this.document.linkId, threadId)
350     if (response.isFailed()) {
351       return false
352     }
354     const unresolvedThread = this.localCommentsState.unresolveThread(threadId)
355     if (!unresolvedThread) {
356       return false
357     }
359     this.eventBus.publish<CommentMarkNodeChangeData>({
360       type: CommentsEvent.UnresolveMarkNode,
361       payload: {
362         markID: unresolvedThread.markID,
363       },
364     })
366     this.broadcastCommentMessage(CommentsMessageType.UnresolveThread, { threadId })
368     return true
369   }
371   async changeSuggestionThreadState(
372     threadId: string,
373     action: SuggestionThreadStateAction,
374     summary?: string,
375   ): Promise<boolean> {
376     if (summary) {
377       const comment = await this.createSuggestionSummaryComment(summary, threadId)
378       if (!comment) {
379         return false
380       }
381     }
383     const response = await this.api.changeSuggestionThreadState(
384       this.document.volumeId,
385       this.document.linkId,
386       threadId,
387       action,
388     )
389     if (response.isFailed()) {
390       return false
391     }
393     let state = CommentThreadState.Active
394     if (action === 'accept') {
395       state = CommentThreadState.Accepted
396     } else if (action === 'reject') {
397       state = CommentThreadState.Rejected
398     }
400     const thread = this.localCommentsState.changeThreadState(threadId, state)
401     if (!thread) {
402       return false
403     }
405     if (state === CommentThreadState.Accepted || state === CommentThreadState.Rejected) {
406       this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_resolved)
407       this.metricService.reportSuggestionResolved(state === CommentThreadState.Accepted ? 'accepted' : 'rejected')
408     }
410     return true
411   }
413   markThreadAsRead(id: string): void {
414     this.localCommentsState.markThreadAsRead(id)
415   }