diff --git a/CHANGELOG.md b/CHANGELOG.md index 199ae96a..9f3b5e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to - โœจ(frontend) create skeleton component for DocEditor #1491 - โœจ(frontend) add an EmojiPicker in the document tree and title #1381 - โœจ(frontend) ajustable left panel #1456 +- โœจ Add comments feature to the editor #1330 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts new file mode 100644 index 00000000..cb80af27 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -0,0 +1,289 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common'; +import { writeInEditor } from './utils-editor'; +import { + addNewMember, + connectOtherUserToDoc, + updateRoleUser, + updateShareLink, +} from './utils-share'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Comments', () => { + test('it checks comments with 2 users in real time', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1); + + // We share the doc with another user + const otherBrowserName = getOtherBrowserName(browserName); + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Administrator', otherBrowserName); + + await expect( + page + .getByRole('listbox', { name: 'Suggestions' }) + .getByText(new RegExp(otherBrowserName)), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + // We add a comment with the first user + const editor = await writeInEditor({ page, text: 'Hello World' }); + await editor.getByText('Hello').selectText(); + await page.getByRole('button', { name: 'Add comment' }).click(); + + const thread = page.locator('.bn-thread'); + await thread.getByRole('paragraph').first().fill('This is a comment'); + await thread.locator('[data-test="save"]').click(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + await editor.getByText('Hello').click(); + + await thread.getByText('This is a comment').first().hover(); + + // We add a reaction with the first user + await thread.locator('[data-test="addreaction"]').first().click(); + await page.getByRole('button', { name: '๐Ÿ‘' }).click(); + + await expect(thread.getByText('This is a comment').first()).toBeVisible(); + await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible(); + await expect(thread.locator('.bn-comment-reaction')).toHaveText('๐Ÿ‘1'); + + const urlCommentDoc = page.url(); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + otherBrowserName, + docUrl: urlCommentDoc, + docTitle, + }); + + const otherEditor = otherPage.locator('.ProseMirror'); + await otherEditor.getByText('Hello').click(); + const otherThread = otherPage.locator('.bn-thread'); + + await otherThread.getByText('This is a comment').first().hover(); + await otherThread.locator('[data-test="addreaction"]').first().click(); + await otherPage.getByRole('button', { name: '๐Ÿ‘' }).click(); + + // We check that the comment made by the first user is visible for the second user + await expect( + otherThread.getByText('This is a comment').first(), + ).toBeVisible(); + await expect( + otherThread.getByText(`E2E ${browserName}`).first(), + ).toBeVisible(); + await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('๐Ÿ‘2'); + + // We add a comment with the second user + await otherThread + .getByRole('paragraph') + .last() + .fill('This is a comment from the other user'); + await otherThread.locator('[data-test="save"]').click(); + + // We check that the second user can see the comment he just made + await expect( + otherThread.getByText('This is a comment from the other user').first(), + ).toBeVisible(); + await expect( + otherThread.getByText(`E2E ${otherBrowserName}`).first(), + ).toBeVisible(); + + // We check that the first user can see the comment made by the second user in real time + await expect( + thread.getByText('This is a comment from the other user').first(), + ).toBeVisible(); + await expect( + thread.getByText(`E2E ${otherBrowserName}`).first(), + ).toBeVisible(); + + await cleanup(); + }); + + test('it checks the comments interactions', async ({ page, browserName }) => { + await createDoc(page, 'comment-interaction', browserName, 1); + + // Checks add react reaction + const editor = page.locator('.ProseMirror'); + await editor.locator('.bn-block-outer').last().fill('Hello World'); + await editor.getByText('Hello').selectText(); + await page.getByRole('button', { name: 'Add comment' }).click(); + + const thread = page.locator('.bn-thread'); + await thread.getByRole('paragraph').first().fill('This is a comment'); + await thread.locator('[data-test="save"]').click(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + // Check background color changed + await expect(editor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + await editor.getByText('Hello').click(); + + await thread.getByText('This is a comment').first().hover(); + + // We add a reaction with the first user + await thread.locator('[data-test="addreaction"]').first().click(); + await page.getByRole('button', { name: '๐Ÿ‘' }).click(); + + await expect(thread.locator('.bn-comment-reaction')).toHaveText('๐Ÿ‘1'); + + // Edit Comment + await thread.getByText('This is a comment').first().hover(); + await thread.locator('[data-test="moreactions"]').first().click(); + await thread.getByRole('menuitem', { name: 'Edit comment' }).click(); + const commentEditor = thread.getByText('This is a comment').first(); + await commentEditor.fill('This is an edited comment'); + const saveBtn = thread.getByRole('button', { name: 'Save' }); + await saveBtn.click(); + await expect(saveBtn).toBeHidden(); + await expect( + thread.getByText('This is an edited comment').first(), + ).toBeVisible(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + // Add second comment + await thread.getByRole('paragraph').last().fill('This is a second comment'); + await thread.getByRole('button', { name: 'Save' }).click(); + await expect( + thread.getByText('This is an edited comment').first(), + ).toBeVisible(); + await expect( + thread.getByText('This is a second comment').first(), + ).toBeVisible(); + + // Delete second comment + await thread.getByText('This is a second comment').first().hover(); + await thread.locator('[data-test="moreactions"]').first().click(); + await thread.getByRole('menuitem', { name: 'Delete comment' }).click(); + await expect( + thread.getByText('This is a second comment').first(), + ).toBeHidden(); + + // Resolve thread + await thread.getByText('This is an edited comment').first().hover(); + await thread.locator('[data-test="resolve"]').click(); + await expect(thread).toBeHidden(); + await expect(editor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + }); + + test('it checks the comments abilities', async ({ page, browserName }) => { + test.slow(); + + const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1); + + // We share the doc with another user + const otherBrowserName = getOtherBrowserName(browserName); + + // Add a new member with editor role + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Editor', otherBrowserName); + + await expect( + page + .getByRole('listbox', { name: 'Suggestions' }) + .getByText(new RegExp(otherBrowserName)), + ).toBeVisible(); + + const urlCommentDoc = page.url(); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + otherBrowserName, + docUrl: urlCommentDoc, + docTitle, + }); + + const otherEditor = await writeInEditor({ + page: otherPage, + text: 'Hello, I can edit the document', + }); + await expect( + otherEditor.getByText('Hello, I can edit the document'), + ).toBeVisible(); + await otherEditor.getByText('Hello').selectText(); + await otherPage.getByRole('button', { name: 'Comment' }).click(); + const otherThread = otherPage.locator('.bn-thread'); + await otherThread + .getByRole('paragraph') + .first() + .fill('I can add a comment'); + await otherThread.locator('[data-test="save"]').click(); + await expect( + otherThread.getByText('I can add a comment').first(), + ).toBeHidden(); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + + // We change the role of the second user to reader + await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`); + + // With the reader role, the second user cannot see comments + await otherPage.reload(); + await verifyDocName(otherPage, docTitle); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + await otherEditor.getByText('Hello').click(); + await expect(otherThread).toBeHidden(); + await otherEditor.getByText('Hello').selectText(); + await expect( + otherPage.getByRole('button', { name: 'Comment' }), + ).toBeHidden(); + + await otherPage.reload(); + + // Change the link role of the doc to set it in commenting mode + await updateShareLink(page, 'Public', 'Editing'); + + // Anonymous user can see and add comments + await otherPage.getByRole('button', { name: 'Logout' }).click(); + + await otherPage.goto(urlCommentDoc); + + await verifyDocName(otherPage, docTitle); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + await otherEditor.getByText('Hello').click(); + await expect( + otherThread.getByText('I can add a comment').first(), + ).toBeVisible(); + + await otherThread + .locator('.ProseMirror.bn-editor[contenteditable="true"]') + .getByRole('paragraph') + .first() + .fill('Comment by anonymous user'); + await otherThread.locator('[data-test="save"]').click(); + + await expect( + otherThread.getByText('Comment by anonymous user').first(), + ).toBeVisible(); + + await expect( + otherThread.getByRole('img', { name: `Anonymous` }).first(), + ).toBeVisible(); + + await otherThread.getByText('Comment by anonymous user').first().hover(); + await expect(otherThread.locator('[data-test="moreactions"]')).toBeHidden(); + + await cleanup(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index c8262367..49038642 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -70,6 +70,14 @@ export const keyCloakSignIn = async ( await page.click('button[type="submit"]', { force: true }); }; +export const getOtherBrowserName = (browserName: BrowserName) => { + const otherBrowserName = BROWSERS.find((b) => b !== browserName); + if (!otherBrowserName) { + throw new Error('No alternative browser found'); + } + return otherBrowserName; +}; + export const randomName = (name: string, browserName: string, length: number) => Array.from({ length }, (_el, index) => { return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`; @@ -125,7 +133,9 @@ export const verifyDocName = async (page: Page, docName: string) => { try { await expect( page.getByRole('textbox', { name: 'Document title' }), - ).toContainText(docName); + ).toContainText(docName, { + timeout: 1000, + }); } catch { await expect(page.getByRole('heading', { name: docName })).toBeVisible(); } diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts index dcc798da..1b3a6820 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts @@ -1,8 +1,8 @@ import { Page, chromium, expect } from '@playwright/test'; import { - BROWSERS, BrowserName, + getOtherBrowserName, keyCloakSignIn, verifyDocName, } from './utils-common'; @@ -88,21 +88,30 @@ export const updateRoleUser = async ( * @param docTitle The title of the document (optional). * @returns An object containing the other browser, context, and page. */ +type ConnectOtherUserToDocParams = { + docUrl: string; + docTitle?: string; + withoutSignIn?: boolean; +} & ( + | { + otherBrowserName: BrowserName; + browserName?: never; + } + | { + browserName: BrowserName; + otherBrowserName?: never; + } +); + export const connectOtherUserToDoc = async ({ browserName, docUrl, docTitle, + otherBrowserName: _otherBrowserName, withoutSignIn, -}: { - browserName: BrowserName; - docUrl: string; - docTitle?: string; - withoutSignIn?: boolean; -}) => { - const otherBrowserName = BROWSERS.find((b) => b !== browserName); - if (!otherBrowserName) { - throw new Error('No alternative browser found'); - } +}: ConnectOtherUserToDocParams) => { + const otherBrowserName = + _otherBrowserName || getOtherBrowserName(browserName); const otherBrowser = await chromium.launch({ headless: true }); const otherContext = await otherBrowser.newContext({ diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 680329d1..75a46581 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -13,3 +13,5 @@ export interface User { short_name: string; language?: string; } + +export type UserLight = Pick; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index eee27c22..3c0dd74b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -14,11 +14,13 @@ import { useCreateBlockNote } from '@blocknote/react'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; import { Doc, useProviderStore } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; +import { useResponsiveStore } from '@/stores'; import { useHeadings, @@ -34,6 +36,7 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; +import { cssComments, useComments } from './comments/'; import { AccessibleImageBlock, CalloutBlock, @@ -79,8 +82,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); const { t } = useTranslation(); + const { isDesktop } = useResponsiveStore(); const { isSynced: isConnectedToCollabServer } = useProviderStore(); const refEditorContainer = useRef(null); + const canSeeComment = doc.abilities.comment && isDesktop; useSaveDoc(doc.id, provider.document, isConnectedToCollabServer); const { i18n } = useTranslation(); @@ -91,6 +96,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const collabName = user?.full_name || user?.email || t('Anonymous'); const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; + const threadStore = useComments(doc.id, canSeeComment, user); + const editor: DocsBlockNoteEditor = useCreateBlockNote( { collaboration: { @@ -138,11 +145,25 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, + comments: { threadStore }, dictionary: { ...locales[lang as keyof typeof locales], multi_column: multiColumnLocales?.[lang as keyof typeof multiColumnLocales], }, + resolveUsers: async (userIds) => { + return Promise.resolve( + userIds.map((encodedURIUserId) => { + const fullName = decodeURIComponent(encodedURIUserId); + + return { + id: encodedURIUserId, + username: fullName || t('Anonymous'), + avatarUrl: 'https://i.pravatar.cc/300', + }; + }), + ); + }, tables: { splitCells: true, cellBackgroundColor: true, @@ -152,7 +173,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [collabName, lang, provider, uploadFile, threadStore], ); useHeadings(editor); @@ -170,7 +191,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, [setEditor, editor]); return ( - + {errorAttachment && ( { /> )} - @@ -196,11 +224,17 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }; interface BlockNoteReaderProps { + docId: Doc['id']; initialContent: Y.XmlFragment; } -export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { +export const BlockNoteReader = ({ + docId, + initialContent, +}: BlockNoteReaderProps) => { + const { user } = useAuth(); const { setEditor } = useEditorStore(); + const threadStore = useComments(docId, false, user); const { t } = useTranslation(); const editor = useCreateBlockNote( { @@ -213,6 +247,10 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { provider: undefined, }, schema: blockNoteSchema, + comments: { threadStore }, + resolveUsers: async () => { + return Promise.resolve([]); + }, }, [initialContent], ); @@ -228,14 +266,21 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { useHeadings(editor); return ( - + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index f7310d6c..1562070e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -117,6 +117,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => { initialContent={provider.document.getXmlFragment( 'document-store', )} + docId={doc.id} /> ) : ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx new file mode 100644 index 00000000..f09c20b2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx @@ -0,0 +1,569 @@ +import { CommentBody, ThreadStore } from '@blocknote/core/comments'; +import type { Awareness } from 'y-protocols/awareness'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { Doc } from '@/features/docs/doc-management'; + +import { useEditorStore } from '../../stores'; + +import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; +import { + ClientCommentData, + ClientThreadData, + ServerComment, + ServerReaction, + ServerThread, +} from './types'; + +type ServerThreadListResponse = APIList; + +export class DocsThreadStore extends ThreadStore { + protected static COMMENTS_PING = 'commentsPing'; + protected threads: Map = new Map(); + private subscribers = new Set< + (threads: Map) => void + >(); + private awareness?: Awareness; + private lastPingAt = 0; + private pingTimer?: ReturnType; + + constructor( + protected docId: Doc['id'], + awareness: Awareness | undefined, + protected docAuth: DocsThreadStoreAuth, + ) { + super(docAuth); + + if (docAuth.canSee) { + this.awareness = awareness; + this.awareness?.on('update', this.onAwarenessUpdate); + void this.refreshThreads(); + } + } + + public destroy() { + this.awareness?.off('update', this.onAwarenessUpdate); + if (this.pingTimer) { + clearTimeout(this.pingTimer); + } + } + + private onAwarenessUpdate = async ({ + added, + updated, + }: { + added: number[]; + updated: number[]; + }) => { + if (!this.awareness) { + return; + } + const states = this.awareness.getStates(); + const listClientIds = [...added, ...updated]; + for (const clientId of listClientIds) { + // Skip our own client ID + if (clientId === this.awareness.clientID) { + continue; + } + + const state = states.get(clientId) as + | { + [DocsThreadStore.COMMENTS_PING]?: { + at: number; + docId: string; + isResolving: boolean; + threadId: string; + }; + } + | undefined; + + const ping = state?.commentsPing; + + // Skip if no ping information is available + if (!ping) { + continue; + } + + // Skip if the document ID doesn't match + if (ping.docId !== this.docId) { + continue; + } + + // Skip if the ping timestamp is past + if (ping.at <= this.lastPingAt) { + continue; + } + + this.lastPingAt = ping.at; + + // If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh. + if (ping.threadId) { + await this.refreshThread(ping.threadId); + } else { + await this.refreshThreads(); + } + } + }; + + /** + * To ping the other clients for updates on a specific thread + * @param threadId + */ + private ping(threadId?: string) { + this.awareness?.setLocalStateField(DocsThreadStore.COMMENTS_PING, { + at: Date.now(), + docId: this.docId, + threadId, + }); + } + + /** + * Notifies all subscribers about the current thread state + */ + private notifySubscribers() { + // Always emit a new Map reference to help consumers detect changes + const threads = new Map(this.threads); + this.subscribers.forEach((cb) => { + try { + cb(threads); + } catch (e) { + console.warn('DocsThreadStore subscriber threw', e); + } + }); + } + + private upsertClientThreadData(thread: ClientThreadData) { + const next = new Map(this.threads); + next.set(thread.id, thread); + this.threads = next; + } + + private removeThread(threadId: string) { + const next = new Map(this.threads); + next.delete(threadId); + this.threads = next; + } + + /** + * To subscribe to thread updates + * @param cb + * @returns + */ + public subscribe(cb: (threads: Map) => void) { + if (!this.docAuth.canSee) { + return () => {}; + } + + this.subscribers.add(cb); + + // Emit initial state asynchronously to avoid running during editor init + setTimeout(() => { + if (this.subscribers.has(cb)) { + cb(this.getThreads()); + } + }, 0); + + return () => { + this.subscribers.delete(cb); + }; + } + + public addThreadToDocument = (options: { + threadId: string; + selection: { + prosemirror: { + head: number; + anchor: number; + }; + yjs: { + head: unknown; + anchor: unknown; + }; + }; + }) => { + const { threadId } = options; + const { editor } = useEditorStore.getState(); + + // Should not happen + if (!editor) { + console.warn('Editor to add thread not ready'); + return Promise.resolve(); + } + + editor._tiptapEditor + .chain() + .focus?.() + .setMark?.('comment', { orphan: false, threadId }) + .run?.(); + + return Promise.resolve(); + }; + + public createThread = async (options: { + initialComment: { + body: CommentBody; + metadata?: unknown; + }; + metadata?: unknown; + }) => { + const response = await fetchAPI(`documents/${this.docId}/threads/`, { + method: 'POST', + body: JSON.stringify({ + body: options.initialComment.body, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to create thread in document', + await errorCauses(response), + ); + } + + const thread = (await response.json()) as ServerThread; + const threadData: ClientThreadData = serverThreadToClientThread(thread); + this.upsertClientThreadData(threadData); + this.notifySubscribers(); + this.ping(threadData.id); + return threadData; + }; + + public getThread(threadId: string) { + const thread = this.threads.get(threadId); + if (!thread) { + throw new Error('Thread not found'); + } + + return thread; + } + + public getThreads(): Map { + if (!this.docAuth.canSee) { + return new Map(); + } + + return this.threads; + } + + public async refreshThread(threadId: string) { + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/`, + { method: 'GET' }, + ); + + // If not OK and 404, the thread might have been deleted but the + // thread modal is still open, so we close it to avoid side effects + if (response.status === 404) { + // use escape key event to close the thread modal + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + bubbles: true, + cancelable: true, + }), + ); + + await this.refreshThreads(); + return; + } + + if (!response.ok) { + throw new APIError( + `Failed to fetch thread ${threadId}`, + await errorCauses(response), + ); + } + + const serverThread = (await response.json()) as ServerThread; + + const clientThread = serverThreadToClientThread(serverThread); + this.upsertClientThreadData(clientThread); + this.notifySubscribers(); + } + + public async refreshThreads(): Promise { + const response = await fetchAPI(`documents/${this.docId}/threads/`, { + method: 'GET', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to get threads in document', + await errorCauses(response), + ); + } + + const threads = (await response.json()) as ServerThreadListResponse; + const next = new Map(); + threads.results.forEach((thread) => { + const threadData: ClientThreadData = serverThreadToClientThread(thread); + next.set(thread.id, threadData); + }); + this.threads = next; + this.notifySubscribers(); + } + + public addComment = async (options: { + comment: { + body: CommentBody; + metadata?: unknown; + }; + threadId: string; + }) => { + const { threadId } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/`, + { + method: 'POST', + body: JSON.stringify({ + body: options.comment.body, + }), + }, + ); + + if (!response.ok) { + throw new APIError('Failed to add comment ', await errorCauses(response)); + } + + const comment = (await response.json()) as ServerComment; + + // Optimistically update local thread with new comment + const existing = this.threads.get(threadId); + if (existing) { + const updated: ClientThreadData = { + ...existing, + updatedAt: new Date(comment.updated_at || comment.created_at), + comments: [...existing.comments, serverCommentToClientComment(comment)], + }; + this.upsertClientThreadData(updated); + this.notifySubscribers(); + } else { + // Fallback to fetching the thread if we don't have it locally + await this.refreshThread(threadId); + } + this.ping(threadId); + return serverCommentToClientComment(comment); + }; + + public updateComment = async (options: { + comment: { + body: CommentBody; + metadata?: unknown; + }; + threadId: string; + commentId: string; + }) => { + const { threadId, commentId, comment } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/${commentId}/`, + { + method: 'PUT', + body: JSON.stringify({ + body: comment.body, + }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to add thread to document', + await errorCauses(response), + ); + } + + await this.refreshThread(threadId); + this.ping(threadId); + + return; + }; + + public deleteComment = async (options: { + threadId: string; + commentId: string; + softDelete?: boolean; + }) => { + const { threadId, commentId } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/${commentId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete comment', + await errorCauses(response), + ); + } + + // Optimistically remove the comment locally if we have the thread + const existing = this.threads.get(threadId); + if (existing) { + const updated: ClientThreadData = { + ...existing, + updatedAt: new Date(), + comments: existing.comments.filter((c) => c.id !== commentId), + }; + this.upsertClientThreadData(updated); + this.notifySubscribers(); + } else { + // Fallback to fetching the thread + await this.refreshThread(threadId); + } + this.ping(threadId); + }; + + /** + * UI not implemented + * @param _options + */ + public deleteThread = async (_options: { threadId: string }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${_options.threadId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete thread', + await errorCauses(response), + ); + } + + // Remove locally and notify; no need to refetch everything + this.removeThread(_options.threadId); + this.notifySubscribers(); + this.ping(_options.threadId); + }; + + public resolveThread = async (_options: { threadId: string }) => { + const { threadId } = _options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/resolve/`, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to resolve thread', + await errorCauses(response), + ); + } + + await this.refreshThreads(); + this.ping(threadId); + }; + + /** + * Todo: Not implemented backend side + * @returns + * @throws + */ + public unresolveThread = async (_options: { threadId: string }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${_options.threadId}/unresolve/`, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to unresolve thread', + await errorCauses(response), + ); + } + + await this.refreshThread(_options.threadId); + this.ping(_options.threadId); + }; + + public addReaction = async (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`, + { + method: 'POST', + body: JSON.stringify({ emoji: options.emoji }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to add reaction to comment', + await errorCauses(response), + ); + } + + await this.refreshThread(options.threadId); + this.notifySubscribers(); + this.ping(options.threadId); + }; + + public deleteReaction = async (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`, + { method: 'DELETE', body: JSON.stringify({ emoji: options.emoji }) }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete reaction from comment', + await errorCauses(response), + ); + } + + await this.refreshThread(options.threadId); + this.notifySubscribers(); + this.ping(options.threadId); + }; +} + +const serverReactionToReactionData = (r: ServerReaction) => { + return { + emoji: r.emoji, + createdAt: new Date(r.created_at), + userIds: r.users?.map((user) => + encodeURIComponent(user.full_name || ''), + ) || [''], + }; +}; + +const serverCommentToClientComment = (c: ServerComment): ClientCommentData => ({ + type: 'comment', + id: c.id, + userId: encodeURIComponent(c.user?.full_name || ''), + body: c.body, + createdAt: new Date(c.created_at), + updatedAt: new Date(c.updated_at), + reactions: (c.reactions ?? []).map(serverReactionToReactionData), + metadata: { abilities: c.abilities }, +}); + +const serverThreadToClientThread = (t: ServerThread): ClientThreadData => ({ + type: 'thread', + id: t.id, + createdAt: new Date(t.created_at), + updatedAt: new Date(t.updated_at), + comments: (t.comments ?? []).map(serverCommentToClientComment), + resolved: t.resolved, + resolvedUpdatedAt: t.resolved_updated_at + ? new Date(t.resolved_updated_at) + : undefined, + resolvedBy: t.resolved_by || undefined, + metadata: { abilities: t.abilities, metadata: t.metadata }, +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx new file mode 100644 index 00000000..57f61481 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx @@ -0,0 +1,94 @@ +import { ThreadStoreAuth } from '@blocknote/core/comments'; + +import { ClientCommentData, ClientThreadData } from './types'; + +export class DocsThreadStoreAuth extends ThreadStoreAuth { + constructor( + private readonly userId: string, + public canSee: boolean, + ) { + super(); + } + + canCreateThread(): boolean { + return true; + } + + canAddComment(_thread: ClientThreadData): boolean { + return true; + } + + canUpdateComment(comment: ClientCommentData): boolean { + if ( + comment.metadata.abilities.partial_update && + comment.userId === this.userId + ) { + return true; + } + + return false; + } + + canDeleteComment(comment: ClientCommentData): boolean { + if (comment.metadata.abilities.destroy) { + return true; + } + + return false; + } + + canDeleteThread(thread: ClientThreadData): boolean { + if (thread.metadata.abilities.destroy) { + return true; + } + + return false; + } + + canResolveThread(thread: ClientThreadData): boolean { + if (thread.metadata.abilities.resolve) { + return true; + } + + return false; + } + + /** + * Not implemented backend side + * @param _thread + * @returns + */ + canUnresolveThread(_thread: ClientThreadData): boolean { + return false; + } + + canAddReaction(comment: ClientCommentData, emoji?: string): boolean { + if (!comment.metadata.abilities.reactions) { + return false; + } + + if (!emoji) { + return true; + } + + return !comment.reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.userIds.includes(this.userId), + ); + } + + canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean { + if (!comment.metadata.abilities.reactions) { + return false; + } + + if (!emoji) { + return true; + } + + return comment.reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.userIds.includes(this.userId), + ); + } +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts new file mode 100644 index 00000000..28c0870b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts @@ -0,0 +1,2 @@ +export * from './styles'; +export * from './useComments'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx new file mode 100644 index 00000000..58eb6eb4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx @@ -0,0 +1,198 @@ +import { css } from 'styled-components'; + +export const cssComments = (canSeeComment: boolean) => css` + & .--docs--main-editor, + & .--docs--main-editor .ProseMirror { + // Comments marks in the editor + .bn-editor { + .bn-thread-mark:not([data-orphan='true']), + .bn-thread-mark-selected:not([data-orphan='true']) { + background: ${canSeeComment ? '#EDB40066' : 'transparent'}; + color: var(--c--theme--colors--greyscale-700); + } + } + + em-emoji-picker { + box-shadow: 0px 6px 18px 0px #00001229; + min-height: 420px; + } + + // Thread modal + .bn-thread { + width: 400px; + padding: 8px; + box-shadow: 0px 6px 18px 0px #00001229; + margin-left: 20px; + gap: 0; + overflow: auto; + max-height: 500px; + + .bn-default-styles { + font-family: var(--c--theme--font--families--base); + } + + .bn-block { + font-size: 14px; + } + + .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before { + font-style: normal; + font-size: 14px; + } + + // Remove tooltip + *[role='tooltip'] { + display: none; + } + + .bn-thread-comment { + padding: 8px; + + & .bn-editor { + padding-left: 32px; + .bn-inline-content { + color: var(--c--theme--colors--greyscale-700); + } + } + + // Emoji + & .bn-badge-group { + padding-left: 32px; + .bn-badge label { + padding: 0 4px; + background: none; + border: 1px solid var(--c--theme--colors--greyscale-300); + border-radius: 4px; + height: 24px; + } + } + + // Top bar (Name / Date / Actions) when actions displayed + &:has(.bn-comment-actions) { + & > .mantine-Group-root { + max-width: 70%; + right: 0.3rem !important; + top: 0.3rem !important; + } + + .bn-menu-dropdown { + box-shadow: 0px 0px 6px 0px #0000911a; + } + } + + // Top bar (Name / Date / Actions) + & > .mantine-Group-root { + flex-wrap: nowrap; + max-width: 100%; + gap: 0.5rem; + + // Date + span.mantine-focus-auto { + display: inline-block; + } + + .bn-comment-actions { + background: transparent; + border: none; + + .mantine-Button-root { + background-color: transparent; + + &:hover { + background-color: var(--c--theme--colors--greyscale-100); + } + } + + button[role='menuitem'] svg { + color: var(--c--theme--colors--greyscale-600); + } + } + + & svg { + color: var(--c--theme--colors--info-600); + } + } + + // Actions button edit comment + .bn-container + .bn-comment-actions-wrapper { + .bn-comment-actions { + flex-direction: row-reverse; + background: none; + border: none; + gap: 0.4rem !important; + + & > button { + height: 24px; + padding-inline: 4px; + + &[data-test='save'] { + border: 1px solid var(--c--theme--colors--info-600); + background: var(--c--theme--colors--info-600); + color: white; + } + + &[data-test='cancel'] { + background: white; + border: 1px solid var(--c--theme--colors--greyscale-300); + color: var(--c--theme--colors--info-600); + } + } + } + } + } + + // Input to add a new comment + .bn-thread-composer, + &:has(> .bn-comment-editor + .bn-comment-actions-wrapper) { + padding: 0.5rem 8px; + flex-direction: row; + gap: 10px; + + .bn-container.bn-comment-editor { + min-width: 0; + } + } + + // Actions button send comment + .bn-thread-composer .bn-comment-actions-wrapper, + &:not(.selected) .bn-comment-actions-wrapper { + flex-basis: fit-content; + + .bn-action-toolbar.bn-comment-actions { + border: none; + + button { + font-size: 0; + background: var(--c--theme--colors--info-600); + width: 24px; + height: 24px; + padding: 0; + + &:disabled { + background: var(--c--theme--colors--greyscale-300); + } + + & .mantine-Button-label::before { + content: '๐Ÿกก'; + font-size: 13px; + color: var(--c--theme--colors--greyscale-100); + } + } + } + } + + // Input first comment + &:not(.selected) { + gap: 0.5rem; + + .bn-container.bn-comment-editor { + min-width: 0; + + .ProseMirror.bn-editor { + cursor: text; + } + } + } + } + } +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts new file mode 100644 index 00000000..be478165 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts @@ -0,0 +1,55 @@ +import { CommentData, ThreadData } from '@blocknote/core/comments'; + +import { UserLight } from '@/features/auth'; + +export interface CommentAbilities { + destroy: boolean; + update: boolean; + partial_update: boolean; + retrieve: boolean; + reactions: boolean; +} +export interface ThreadAbilities { + destroy: boolean; + update: boolean; + partial_update: boolean; + retrieve: boolean; + resolve: boolean; +} + +export interface ServerReaction { + emoji: string; + created_at: string; + users: UserLight[] | null; +} + +export interface ServerComment { + id: string; + user: UserLight | null; + body: unknown; + created_at: string; + updated_at: string; + reactions: ServerReaction[]; + abilities: CommentAbilities; +} + +export interface ServerThread { + id: string; + created_at: string; + updated_at: string; + user: UserLight | null; + resolved: boolean; + resolved_updated_at: string | null; + resolved_by: string | null; + metadata: unknown; + comments: ServerComment[]; + abilities: ThreadAbilities; +} + +export type ClientCommentData = Omit & { + metadata: { abilities: CommentAbilities }; +}; + +export type ClientThreadData = Omit & { + metadata: { abilities: ThreadAbilities; metadata: unknown }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts new file mode 100644 index 00000000..99be3acf --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts @@ -0,0 +1,33 @@ +import { useEffect, useMemo } from 'react'; + +import { User } from '@/features/auth'; +import { Doc, useProviderStore } from '@/features/docs/doc-management'; + +import { DocsThreadStore } from './DocsThreadStore'; +import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; + +export function useComments( + docId: Doc['id'], + canComment: boolean, + user: User | null | undefined, +) { + const { provider } = useProviderStore(); + const threadStore = useMemo(() => { + return new DocsThreadStore( + docId, + provider?.awareness ?? undefined, + new DocsThreadStoreAuth( + encodeURIComponent(user?.full_name || ''), + canComment, + ), + ); + }, [docId, canComment, provider?.awareness, user?.full_name]); + + useEffect(() => { + return () => { + threadStore?.destroy(); + }; + }, [threadStore]); + + return threadStore; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index f1c5d721..64640f9e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -80,6 +80,7 @@ export interface Doc { children_create: boolean; children_list: boolean; collaboration_auth: boolean; + comment: boolean; destroy: boolean; duplicate: boolean; favorite: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx index a7574a44..fc1f76f9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx @@ -77,7 +77,9 @@ export const DocVersionEditor = ({ return ( } - docEditor={} + docEditor={ + + } isDeletedDoc={false} readOnly={true} /> diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index e84415a7..2fe68881 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -188,6 +188,7 @@ export class ApiPlugin implements WorkboxPlugin { children_create: true, children_list: true, collaboration_auth: true, + comment: true, destroy: true, duplicate: true, favorite: true,