3 CommentControllerInterface,
4 CommentThreadInterface,
6 InternalEventHandlerInterface,
7 InternalEventInterface,
8 InternalEventBusInterface,
9 CommentMarkNodeChangeData,
10 SuggestionThreadStateAction,
11 SuggestionSummaryType,
12 } from '@proton/docs-shared'
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'
45 * Controls comments for a single document.
47 export class CommentController implements CommentControllerInterface, InternalEventHandlerInterface {
48 private localCommentsState: LocalCommentsState
50 public readonly liveComments: LiveComments = new LiveComments(
51 this.websocketService,
53 this.keys.userOwnAddress,
58 private shouldSendDocumentName = false
61 private readonly document: NodeMeta,
62 private readonly keys: DocumentKeys,
63 private readonly websocketService: WebsocketServiceInterface,
64 private readonly metricService: MetricService,
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,
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)
84 unleashClient.on('update', () => {
85 this.shouldSendDocumentName = unleashClient.isEnabled(DocsEnableNotificationsOnNewCommentFeatureFlag)
89 get userDisplayName(): string {
90 return this.keys.userOwnAddress
93 public fetchAllComments(): void {
94 void this._loadThreads.execute({
95 lookup: this.document,
97 commentsState: this.localCommentsState,
101 private broadcastCommentMessage(type: CommentsMessageType, dto: AnyCommentMessageData): void {
102 const data = CreateRealtimeCommentPayload(type, dto)
104 void this.websocketService.sendEventMessage(
107 EventTypeEnum.ClientHasSentACommentMessage,
108 BroadcastSource.CommentsController,
111 if ([CommentsMessageType.AddThread, CommentsMessageType.AddComment].includes(type)) {
112 metrics.docs_comments_total.increment({
113 type: CommentsMessageType.AddThread === type ? 'comment' : 'reply',
118 public getTypersExcludingSelf(threadId: string): string[] {
119 return this.liveComments.getTypingUsers(threadId).filter((user) => user !== this.keys.userOwnAddress)
122 public beganTypingInThread(threadID: string): void {
123 this.liveComments.setIsTypingComment(threadID, true)
126 public stoppedTypingInThread(threadID: string): void {
127 this.liveComments.setIsTypingComment(threadID, false)
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)
140 getAllThreads(): CommentThreadInterface[] {
141 return this.localCommentsState.getAllThreads()
144 async createCommentThread(
145 commentContent: 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,
154 lookup: this.document,
155 commentsState: this.localCommentsState,
158 type: CommentThreadType.Comment,
159 decryptedDocumentName,
162 if (threadResult.isFailed()) {
163 this.logger.error(threadResult.getError())
167 const thread = threadResult.getValue()
169 this.broadcastCommentMessage(CommentsMessageType.AddThread, thread.asPayload())
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,
184 lookup: this.document,
185 commentsState: this.localCommentsState,
186 markID: suggestionID,
187 createMarkNode: false,
188 type: CommentThreadType.Suggestion,
189 decryptedDocumentName,
192 if (threadResult.isFailed()) {
193 this.logger.error(threadResult.getError())
197 const thread = threadResult.getValue()
199 this.broadcastCommentMessage(CommentsMessageType.AddThread, thread.asPayload())
201 this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_created)
202 this.metricService.reportSuggestionCreated(suggestionType)
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({
214 lookup: this.document,
215 commentsState: this.localCommentsState,
216 type: CommentType.Comment,
217 decryptedDocumentName,
220 if (commentResult.isFailed()) {
221 this.logger.error(commentResult.getError())
225 const thread = this.localCommentsState.findThreadById(threadID)
226 if (thread && thread.type === CommentThreadType.Suggestion) {
227 this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_commented)
230 const comment = commentResult.getValue()
232 this.broadcastCommentMessage(CommentsMessageType.AddComment, { comment: comment.asPayload(), threadID })
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({
244 lookup: this.document,
245 commentsState: this.localCommentsState,
246 type: CommentType.Suggestion,
247 decryptedDocumentName,
250 if (commentResult.isFailed()) {
251 this.logger.error(commentResult.getError())
255 const thread = this.localCommentsState.findThreadById(threadID)
256 if (thread && thread.type === CommentThreadType.Suggestion) {
257 this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_commented)
260 const comment = commentResult.getValue()
262 this.broadcastCommentMessage(CommentsMessageType.AddComment, { comment: comment.asPayload(), threadID })
267 async editComment(threadID: string, commentID: string, content: string): Promise<boolean> {
268 const thread = this.localCommentsState.findThreadById(threadID)
270 throw new Error('Thread not found')
273 const encryptionResult = await this._encryptComment.execute(content, thread.markID, this.keys)
274 if (encryptionResult.isFailed()) {
278 const encryptedContent = encryptionResult.getValue()
280 const result = await this.api.editComment({
281 volumeId: this.document.volumeId,
282 linkId: this.document.linkId,
284 commentId: commentID,
285 encryptedContent: encryptedContent,
286 authorEmail: this.keys.userOwnAddress,
288 if (result.isFailed()) {
292 this.localCommentsState.editComment({ commentID, threadID, content })
294 this.broadcastCommentMessage(CommentsMessageType.EditComment, { commentID, threadID, content })
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()) {
305 this.localCommentsState.deleteThread(id)
307 this.broadcastCommentMessage(CommentsMessageType.DeleteThread, { threadId: id })
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()) {
318 this.localCommentsState.deleteComment({ commentID, threadID })
320 this.broadcastCommentMessage(CommentsMessageType.DeleteComment, { commentID, threadID })
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()) {
331 const resolvedThread = this.localCommentsState.resolveThread(threadId)
332 if (!resolvedThread) {
336 this.eventBus.publish<CommentMarkNodeChangeData>({
337 type: CommentsEvent.ResolveMarkNode,
339 markID: resolvedThread.markID,
343 this.broadcastCommentMessage(CommentsMessageType.ResolveThread, { threadId })
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()) {
354 const unresolvedThread = this.localCommentsState.unresolveThread(threadId)
355 if (!unresolvedThread) {
359 this.eventBus.publish<CommentMarkNodeChangeData>({
360 type: CommentsEvent.UnresolveMarkNode,
362 markID: unresolvedThread.markID,
366 this.broadcastCommentMessage(CommentsMessageType.UnresolveThread, { threadId })
371 async changeSuggestionThreadState(
373 action: SuggestionThreadStateAction,
375 ): Promise<boolean> {
377 const comment = await this.createSuggestionSummaryComment(summary, threadId)
383 const response = await this.api.changeSuggestionThreadState(
384 this.document.volumeId,
385 this.document.linkId,
389 if (response.isFailed()) {
393 let state = CommentThreadState.Active
394 if (action === 'accept') {
395 state = CommentThreadState.Accepted
396 } else if (action === 'reject') {
397 state = CommentThreadState.Rejected
400 const thread = this.localCommentsState.changeThreadState(threadId, state)
405 if (state === CommentThreadState.Accepted || state === CommentThreadState.Rejected) {
406 this.metricService.reportSuggestionsTelemetry(TelemetryDocsEvents.suggestion_resolved)
407 this.metricService.reportSuggestionResolved(state === CommentThreadState.Accepted ? 'accepted' : 'rejected')
413 markThreadAsRead(id: string): void {
414 this.localCommentsState.markThreadAsRead(id)