✨(frontend) add comments feature
Implemented the comments feature for the document editor. We are now able to add, view, and manage comments within the document editor interface.
This commit is contained in:
289
src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts
Normal file
289
src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -13,3 +13,5 @@ export interface User {
|
||||
short_name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type UserLight = Pick<User, 'full_name' | 'short_name'>;
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<Box ref={refEditorContainer} $css={cssEditor}>
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(canSeeComment)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
@@ -180,12 +207,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={canSeeComment}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
@@ -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 (
|
||||
<Box $css={cssEditor}>
|
||||
<Box
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(false)}
|
||||
`}
|
||||
>
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
editable={false}
|
||||
theme="light"
|
||||
aria-label={t('Document version viewer')}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
comments={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -117,6 +117,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
initialContent={provider.document.getXmlFragment(
|
||||
'document-store',
|
||||
)}
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
|
||||
@@ -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<ServerThread>;
|
||||
|
||||
export class DocsThreadStore extends ThreadStore {
|
||||
protected static COMMENTS_PING = 'commentsPing';
|
||||
protected threads: Map<string, ClientThreadData> = new Map();
|
||||
private subscribers = new Set<
|
||||
(threads: Map<string, ClientThreadData>) => void
|
||||
>();
|
||||
private awareness?: Awareness;
|
||||
private lastPingAt = 0;
|
||||
private pingTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
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<string, ClientThreadData>) => 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<string, ClientThreadData> {
|
||||
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<void> {
|
||||
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<string, ClientThreadData>();
|
||||
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 },
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './styles';
|
||||
export * from './useComments';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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<CommentData, 'metadata'> & {
|
||||
metadata: { abilities: CommentAbilities };
|
||||
};
|
||||
|
||||
export type ClientThreadData = Omit<ThreadData, 'metadata'> & {
|
||||
metadata: { abilities: ThreadAbilities; metadata: unknown };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export interface Doc {
|
||||
children_create: boolean;
|
||||
children_list: boolean;
|
||||
collaboration_auth: boolean;
|
||||
comment: boolean;
|
||||
destroy: boolean;
|
||||
duplicate: boolean;
|
||||
favorite: boolean;
|
||||
|
||||
@@ -77,7 +77,9 @@ export const DocVersionEditor = ({
|
||||
return (
|
||||
<DocEditorContainer
|
||||
docHeader={<DocVersionHeader />}
|
||||
docEditor={<BlockNoteReader initialContent={initialContent} />}
|
||||
docEditor={
|
||||
<BlockNoteReader initialContent={initialContent} docId={version.id} />
|
||||
}
|
||||
isDeletedDoc={false}
|
||||
readOnly={true}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user