feat(core): comment panel (#12989)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a full-featured document comment system with sidebar for
viewing, filtering, replying, resolving, and managing comments and
replies.
* Added a rich text comment editor supporting editable and read-only
modes.
  * Enabled comment-based navigation and highlighting within documents.
* Integrated comment functionality into the workspace sidebar (excluding
local workspaces).
* Added internationalization support and new UI strings for comment
features.
* Added new feature flag `enable_comment` for toggling comment
functionality.
  * Enhanced editor focus to support comment-related selections.
  * Added snapshot and store helpers for comment content management.
* Implemented backend GraphQL support for comment and reply operations.
* Added services for comment entity management and comment panel
behavior.
* Extended comment configuration to support optional framework
providers.
* Added preview generation from user selections when creating comments.
  * Enabled automatic sidebar opening on new pending comments.
  * Added comment-related query parameter support for navigation.
  * Included inline comment module exports for integration.
* Improved comment provider implementation with full lifecycle
management and UI integration.
* Added comment highlight state tracking and refined selection handling
in inline comments.

* **Style**
* Added comprehensive styles for the comment editor and sidebar
components.

* **Chores**
  * Updated language completeness percentages for multiple locales.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
Peng Xiao
2025-07-02 23:47:00 +08:00
committed by GitHub
parent a59448ec4b
commit a21f1c943e
30 changed files with 2572 additions and 68 deletions
+1
View File
@@ -174,6 +174,7 @@
"./inlines/footnote": "./src/inlines/footnote/index.ts",
"./inlines/footnote/view": "./src/inlines/footnote/view.ts",
"./inlines/footnote/store": "./src/inlines/footnote/store.ts",
"./inlines/comment": "./src/inlines/comment/index.ts",
"./inlines/latex": "./src/inlines/latex/index.ts",
"./inlines/latex/store": "./src/inlines/latex/store.ts",
"./inlines/latex/view": "./src/inlines/latex/view.ts",
@@ -0,0 +1 @@
export * from '@blocksuite/affine-inline-comment';
@@ -1 +1,2 @@
export * from './inline-spec';
export * from './utils';
@@ -12,6 +12,7 @@ import {
TextSelection,
} from '@blocksuite/std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
@@ -20,6 +21,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
private readonly _disposables = new DisposableGroup();
private readonly _highlightedCommentId$ = signal<CommentId | null>(null);
private get _provider() {
return this.std.getOptional(CommentProviderIdentifier);
}
@@ -35,6 +38,9 @@ export class InlineCommentManager extends LifeCycleWatcher {
this._disposables.add(
provider.onCommentResolved(this._handleDeleteAndResolve)
);
this._disposables.add(
provider.onCommentHighlighted(this._handleHighlightComment)
);
this._disposables.add(
this.std.selection.slots.changed.subscribe(this._handleSelectionChanged)
);
@@ -131,14 +137,20 @@ export class InlineCommentManager extends LifeCycleWatcher {
});
};
private readonly _handleHighlightComment = (id: CommentId | null) => {
this._highlightedCommentId$.value = id;
};
private readonly _handleSelectionChanged = (selections: BaseSelection[]) => {
const currentHighlightedCommentId = this._highlightedCommentId$.peek();
if (selections.length === 1) {
const selection = selections[0];
// InlineCommentManager only handle text selection
if (!selection.is(TextSelection)) return;
if (!selection.isCollapsed()) {
if (!selection.isCollapsed() && currentHighlightedCommentId !== null) {
this._provider?.highlightComment(null);
return;
}
@@ -156,6 +168,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
if (commentIds.length !== 0) return;
}
this._provider?.highlightComment(null);
if (currentHighlightedCommentId !== null) {
this._provider?.highlightComment(null);
}
};
}
@@ -43,6 +43,7 @@ export const ReferenceParamsSchema = z
databaseId: z.string().optional(),
databaseRowId: z.string().optional(),
xywh: SerializedXYWHSchema.optional(),
commentId: z.string().optional(),
})
.partial();
@@ -21,6 +21,7 @@ export interface BlockSuiteFlags {
enable_table_virtual_scroll: boolean;
enable_turbo_renderer: boolean;
enable_dom_renderer: boolean;
enable_comment: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension {
enable_table_virtual_scroll: false,
enable_turbo_renderer: false,
enable_dom_renderer: false,
enable_comment: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
@@ -109,7 +109,7 @@ const usePatchSpecs = (mode: DocMode) => {
.electron(framework)
.linkPreview(framework)
.codeBlockHtmlPreview(framework)
.comment(enableComment).value;
.comment(enableComment, framework).value;
if (BUILD_CONFIG.isMobileEdition) {
if (mode === 'page') {
@@ -57,7 +57,10 @@ type Configure = {
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
codeBlockHtmlPreview: (framework?: FrameworkProvider) => Configure;
comment: (enableComment?: boolean) => Configure;
comment: (
enableComment?: boolean,
framework?: FrameworkProvider
) => Configure;
value: ViewExtensionManager;
};
@@ -92,6 +95,7 @@ class ViewProvider {
ElectronViewExtension,
AffineLinkPreviewExtension,
AffineDatabaseViewExtension,
CommentViewExtension,
]);
}
@@ -328,8 +332,14 @@ class ViewProvider {
return this.config;
};
private readonly _configureComment = (enableComment?: boolean) => {
this._manager.configure(CommentViewExtension, { enableComment });
private readonly _configureComment = (
enableComment?: boolean,
framework?: FrameworkProvider
) => {
this._manager.configure(CommentViewExtension, {
enableComment,
framework,
});
return this.config;
};
}
@@ -1,14 +1,176 @@
import { noop } from '@blocksuite/affine/global/utils';
import { CommentProviderExtension } from '@blocksuite/affine/shared/services';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { getSelectedBlocksCommand } from '@blocksuite/affine/shared/commands';
import type { CommentProvider } from '@blocksuite/affine/shared/services';
import { CommentProviderIdentifier } from '@blocksuite/affine/shared/services';
import type { BlockStdScope } from '@blocksuite/affine/std';
import { StdIdentifier } from '@blocksuite/affine/std';
import type { BaseSelection, ExtensionType } from '@blocksuite/affine/store';
import { type Container } from '@blocksuite/global/di';
import { BlockSelection, TextSelection } from '@blocksuite/std';
import type { FrameworkProvider } from '@toeverything/infra';
export const AffineCommentProvider = CommentProviderExtension({
addComment: noop,
resolveComment: noop,
highlightComment: noop,
getComments: () => [],
import { DocCommentManagerService } from '../../../modules/comment/services/doc-comment-manager';
onCommentAdded: () => noop,
onCommentResolved: () => noop,
onCommentDeleted: () => noop,
onCommentHighlighted: () => noop,
});
function getPreviewFromSelections(
std: BlockStdScope,
selections: BaseSelection[]
): string {
if (!selections || selections.length === 0) {
return '';
}
const previews: string[] = [];
for (const selection of selections) {
if (selection instanceof TextSelection) {
// Extract text from TextSelection
const textPreview = extractTextFromSelection(std, selection);
if (textPreview) {
previews.push(textPreview);
}
} else if (selection instanceof BlockSelection) {
// Get block flavour for BlockSelection
const block = std.store.getBlock(selection.blockId);
if (block?.model) {
const flavour = block.model.flavour.replace('affine:', '');
previews.push(`<${flavour}>`);
}
} else if (selection.type === 'image') {
// Return <"Image"> for ImageSelection
previews.push('<Image>');
}
// Skip other types
}
return previews.length > 0 ? previews.join(' ') : 'New comment';
}
function extractTextFromSelection(
std: BlockStdScope,
selection: TextSelection
): string | null {
try {
const [_, ctx] = std.command
.chain()
.pipe(getSelectedBlocksCommand, {
currentTextSelection: selection,
types: ['text'],
})
.run();
const blocks = ctx.selectedBlocks;
if (!blocks || blocks.length === 0) return null;
const { from, to } = selection;
const quote = blocks.reduce((acc, block, index) => {
const text = block.model.text;
if (!text) return acc;
if (index === 0) {
// First block: extract from 'from.index' for 'from.length' characters
const startText = text.yText
.toString()
.slice(from.index, from.index + from.length);
return acc + startText;
}
if (index === blocks.length - 1 && to) {
// Last block: extract from start to 'to.index + to.length'
const endText = text.yText.toString().slice(0, to.index + to.length);
return acc + (acc ? ' ' : '') + endText;
}
// Middle blocks: get all text
const blockText = text.yText.toString();
return acc + (acc ? ' ' : '') + blockText;
}, '');
// Trim and limit length for preview
const trimmed = quote.trim();
return trimmed.length > 200 ? trimmed.substring(0, 200) + '...' : trimmed;
} catch (error) {
console.warn('Failed to extract text from selection:', error);
return null;
}
}
class AffineCommentService implements CommentProvider {
private readonly docCommentManager: DocCommentManagerService;
constructor(
private readonly std: BlockStdScope,
private readonly framework: FrameworkProvider
) {
this.docCommentManager = framework.get(DocCommentManagerService);
}
private get currentDocId(): string {
return this.std.store.id;
}
// todo: need to handle resource leak
private get commentEntityRef() {
return this.docCommentManager.get(this.currentDocId);
}
private get commentEntity() {
return this.commentEntityRef.obj;
}
addComment(selections: BaseSelection[]): void {
const workbench = this.framework.get(WorkbenchService).workbench;
workbench.setSidebarOpen(true);
workbench.activeView$.value.activeSidebarTab('comment');
const preview = getPreviewFromSelections(this.std, selections);
this.commentEntity.addComment(selections, preview).catch(console.error);
}
resolveComment(id: string): void {
this.commentEntity.resolveComment(id, true).catch(console.error);
}
highlightComment(id: string | null): void {
if (id !== null) {
const workbench = this.framework.get(WorkbenchService).workbench;
workbench.setSidebarOpen(true);
workbench.activeView$.value.activeSidebarTab('comment');
}
this.commentEntity.highlightComment(id);
}
getComments(): string[] {
return this.commentEntity.getComments();
}
onCommentAdded(callback: (id: string, selections: BaseSelection[]) => void) {
return this.commentEntity.onCommentAdded((id, selections) => {
callback(id, selections);
});
}
onCommentResolved(callback: (id: string) => void) {
return this.commentEntity.onCommentResolved(callback);
}
onCommentDeleted(callback: (id: string) => void) {
return this.commentEntity.onCommentDeleted(callback);
}
onCommentHighlighted(callback: (id: string | null) => void) {
return this.commentEntity.onCommentHighlighted(callback);
}
}
export function AffineCommentProvider(
framework: FrameworkProvider
): ExtensionType {
return {
setup: (di: Container) => {
di.addImpl(
CommentProviderIdentifier,
provider =>
new AffineCommentService(provider.get(StdIdentifier), framework)
);
},
};
}
@@ -2,26 +2,30 @@ import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import z from 'zod';
import { AffineCommentProvider } from './comment-provider';
const optionsSchema = z.object({
enableComment: z.boolean().optional(),
framework: z.instanceof(FrameworkProvider).optional(),
});
export class CommentViewExtension extends ViewExtensionProvider {
type CommentViewOptions = z.infer<typeof optionsSchema>;
export class CommentViewExtension extends ViewExtensionProvider<CommentViewOptions> {
override name = 'comment';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: z.infer<typeof optionsSchema>
) {
override setup(context: ViewExtensionContext, options?: CommentViewOptions) {
super.setup(context, options);
if (!options?.enableComment) return;
context.register([AffineCommentProvider]);
const framework = options.framework;
if (!framework) return;
context.register([AffineCommentProvider(framework)]);
}
}
@@ -0,0 +1,210 @@
import { useConfirmModal, useLitPortalFactory } from '@affine/component';
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
import type { RichText } from '@blocksuite/affine/rich-text';
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
import { type DocSnapshot, Store } from '@blocksuite/affine/store';
import { ArrowUpBigIcon } from '@blocksuite/icons/rc';
import { useFramework, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
const usePatchSpecs = (readonly: boolean) => {
const [reactToLit, portals] = useLitPortalFactory();
const framework = useFramework();
const confirmModal = useConfirmModal();
const patchedSpecs = useMemo(() => {
const manager = getViewManager()
.config.init()
.foundation(framework)
.theme(framework)
.editorConfig(framework)
.editorView({
framework,
reactToLit,
confirmModal,
})
.linkedDoc(framework)
.paragraph(false)
.codeBlockHtmlPreview(framework).value;
return manager
.get(readonly ? 'preview-page' : 'page')
.concat([ViewportElementExtension('.comment-editor-viewport')]);
}, [confirmModal, framework, reactToLit, readonly]);
return [
patchedSpecs,
useMemo(
() => (
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
),
[portals]
),
] as const;
};
interface CommentEditorProps {
readonly?: boolean;
doc?: Store;
defaultSnapshot?: DocSnapshot;
// for performance, we only update the snapshot when the editor blurs
onChange?: (snapshot: DocSnapshot) => void;
onCommit?: () => void;
onCancel?: () => void;
autoFocus?: boolean;
}
export interface CommentEditorRef {
getSnapshot: () => DocSnapshot | null | undefined;
}
// todo: get rid of circular data changes
const useSnapshotDoc = (
defaultSnapshotOrDoc: DocSnapshot | Store,
readonly?: boolean
) => {
const snapshotHelper = useService(SnapshotHelper);
const [doc, setDoc] = useState<Store | undefined>(
defaultSnapshotOrDoc instanceof Store ? defaultSnapshotOrDoc : undefined
);
useEffect(() => {
if (defaultSnapshotOrDoc instanceof Store) {
return;
}
snapshotHelper
.createStore(defaultSnapshotOrDoc)
.then(d => {
if (d) {
setDoc(d);
d.readonly = readonly ?? false;
}
})
.catch(e => {
console.error(e);
});
}, [defaultSnapshotOrDoc, readonly, snapshotHelper]);
return doc;
};
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
function CommentEditor(
{ readonly, defaultSnapshot, doc: userDoc, onChange, onCommit, autoFocus },
ref
) {
const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc;
if (!defaultSnapshotOrDoc) {
throw new Error('Either defaultSnapshot or doc must be provided');
}
const [specs, portals] = usePatchSpecs(!!readonly);
const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly);
const snapshotHelper = useService(SnapshotHelper);
const editorRef = useRef<PageEditor>(null);
useImperativeHandle(
ref,
() => ({
getSnapshot: () => {
if (!doc) {
return null;
}
return snapshotHelper.getSnapshot(doc);
},
}),
[doc, snapshotHelper]
);
useEffect(() => {
let cancel = false;
if (autoFocus && editorRef.current && doc) {
// fixme: the following does not work
// Wait for editor to be fully loaded before focusing
editorRef.current.updateComplete
.then(async () => {
if (cancel) return;
const richText = editorRef.current?.querySelector(
'rich-text'
) as unknown as RichText;
if (!richText) return;
// Finally focus the inline editor
const inlineEditor = richText.inlineEditor;
richText.focus();
richText.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
inlineEditor?.focusEnd();
})
.catch(console.error);
}
return () => {
cancel = true;
};
}, [autoFocus, doc]);
useEffect(() => {
if (doc && onChange) {
const subscription = doc.slots.blockUpdated.subscribe(() => {
const snapshot = snapshotHelper.getSnapshot(doc);
if (snapshot) {
onChange?.(snapshot);
}
});
return () => {
subscription?.unsubscribe();
};
}
return;
}, [doc, onChange, snapshotHelper]);
const handleClickEditor = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (editorRef.current) {
editorRef.current.focus();
}
},
[editorRef]
);
return (
<div
onClick={readonly ? undefined : handleClickEditor}
data-readonly={!!readonly}
className={clsx(styles.container, 'comment-editor-viewport')}
>
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
{portals}
{!readonly && (
<div className={styles.footer}>
<button onClick={onCommit} className={styles.commitButton}>
<ArrowUpBigIcon />
</button>
</div>
)}
</div>
);
}
);
@@ -0,0 +1,60 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
width: '100%',
height: '100%',
border: `1px solid transparent`,
selectors: {
'&[data-readonly="false"]': {
borderColor: cssVarV2('layer/insideBorder/border'),
borderRadius: 16,
padding: '0 8px',
},
'&[data-readonly="false"]:focus-within': {
borderColor: cssVarV2('layer/insideBorder/primaryBorder'),
boxShadow: cssVar('activeShadow'),
},
},
vars: {
'--affine-paragraph-margin': '0',
'--affine-font-base': '14px',
},
});
export const footer = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
paddingBottom: 8,
});
globalStyle(`${container} .affine-page-root-block-container`, {
padding: '8px 0',
minHeight: '32px',
});
globalStyle(`${container} .affine-paragraph-rich-text-wrapper`, {
margin: 0,
});
export const commitButton = style({
background: cssVarV2('button/primary'),
border: 'none',
cursor: 'pointer',
color: cssVarV2('layer/pureWhite'),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
fontSize: 20,
selectors: {
'&[data-disabled="true"]': {
background: cssVarV2('button/disable'),
cursor: 'default',
},
},
});
@@ -0,0 +1,616 @@
import {
Avatar,
IconButton,
Loading,
Menu,
MenuItem,
notify,
useConfirmModal,
} from '@affine/component';
import { ServerService } from '@affine/core/modules/cloud';
import { AuthService } from '@affine/core/modules/cloud/services/auth';
import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc-comment';
import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service';
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
import type { DocComment } from '@affine/core/modules/comment/types';
import { DocService } from '@affine/core/modules/doc';
import { toDocSearchParams } from '@affine/core/modules/navigation';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { i18nTime, useI18n } from '@affine/i18n';
import type { DocSnapshot } from '@blocksuite/affine/store';
import { DoneIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import { CommentEditor } from '../comment-editor';
import * as styles from './style.css';
interface CommentFilterState {
showResolvedComments: boolean;
onlyMyReplies: boolean;
onlyCurrentMode: boolean;
}
const SortFilterButton = ({
filterState,
onFilterChange,
}: {
filterState: CommentFilterState;
onFilterChange: (key: keyof CommentFilterState, value: boolean) => void;
}) => {
const t = useI18n();
return (
<Menu
rootOptions={{ modal: false }}
items={
<>
<MenuItem
checked={filterState.showResolvedComments}
onSelect={() =>
onFilterChange(
'showResolvedComments',
!filterState.showResolvedComments
)
}
>
{t['com.affine.comment.filter.show-resolved']()}
</MenuItem>
<MenuItem
checked={filterState.onlyMyReplies}
onSelect={() =>
onFilterChange('onlyMyReplies', !filterState.onlyMyReplies)
}
>
{t['com.affine.comment.filter.only-my-replies']()}
</MenuItem>
<MenuItem
checked={filterState.onlyCurrentMode}
onSelect={() =>
onFilterChange('onlyCurrentMode', !filterState.onlyCurrentMode)
}
>
{t['com.affine.comment.filter.only-current-mode']()}
</MenuItem>
</>
}
>
<IconButton icon={<FilterIcon />} />
</Menu>
);
};
const ReadonlyCommentRenderer = ({
avatarUrl,
name,
time,
snapshot,
}: {
avatarUrl: string | null;
name: string;
time: number;
snapshot: DocSnapshot;
}) => {
return (
<div data-time={time} className={styles.readonlyCommentContainer}>
<div className={styles.userContainer}>
<Avatar url={avatarUrl} size={24} />
<div className={styles.userName}>{name}</div>
<div className={styles.time}>
{i18nTime(time, {
absolute: { accuracy: 'minute' },
})}
</div>
</div>
<div style={{ marginLeft: '34px' }}>
<CommentEditor readonly defaultSnapshot={snapshot} />
</div>
</div>
);
};
const CommentItem = ({
comment,
entity,
}: {
comment: DocComment;
entity: DocCommentEntity;
}) => {
const workbench = useService(WorkbenchService);
const serverService = useService(ServerService);
const highlighting = useLiveData(entity.commentHighlighted$) === comment.id;
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const pendingReply = useLiveData(entity.pendingReply$);
// Check if the pending reply belongs to this comment
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
const commentRef = useRef<HTMLDivElement>(null);
// Loading state for any async operation
const [isMutating, setIsMutating] = useState(false);
const handleDelete = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
openConfirmModal({
title: t['com.affine.comment.delete.confirm.title'](),
description: t['com.affine.comment.delete.confirm.description'](),
confirmText: t['Delete'](),
cancelText: t['Cancel'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: async () => {
setIsMutating(true);
try {
await entity.deleteComment(comment.id);
} finally {
setIsMutating(false);
}
},
});
},
[entity, comment.id, openConfirmModal, t]
);
const handleResolve = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
setIsMutating(true);
try {
await entity.resolveComment(comment.id, !comment.resolved);
} finally {
setIsMutating(false);
}
},
[entity, comment.id, comment.resolved]
);
const handleReply = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!comment.resolved) {
await entity.addReply(comment.id);
entity.highlightComment(comment.id);
}
},
[entity, comment.id, comment.resolved]
);
const handleCopyLink = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
// Create a URL with the comment ID
const search = toDocSearchParams({
mode: comment.content.mode,
commentId: comment.id,
});
const url = new URL(
workbench.workbench.basename$.value + '/' + entity.props.docId,
serverService.server.baseUrl
);
if (search?.size) url.search = search.toString();
await copyTextToClipboard(url.toString());
notify.success({ title: t['Copied link to clipboard']() });
},
[
comment.content.mode,
comment.id,
entity.props.docId,
serverService.server.baseUrl,
t,
workbench.workbench.basename$.value,
]
);
const handleCommitReply = useAsyncCallback(async () => {
if (!pendingReply?.id) return;
setIsMutating(true);
try {
await entity.commitReply(pendingReply.id);
} finally {
setIsMutating(false);
}
}, [entity, pendingReply]);
const handleCancelReply = useCallback(() => {
if (!pendingReply?.id) return;
entity.dismissDraftReply();
}, [entity, pendingReply]);
const handleClickPreview = useCallback(() => {
workbench.workbench.openDoc(
{
docId: entity.props.docId,
mode: comment.content.mode,
commentId: comment.id,
refreshKey: 'comment-' + Date.now(),
},
{
show: true,
}
);
entity.highlightComment(comment.id);
}, [comment.id, comment.content.mode, entity, workbench.workbench]);
useEffect(() => {
const subscription = entity.commentHighlighted$
.distinctUntilChanged()
.subscribe(id => {
if (id === comment.id && commentRef.current) {
commentRef.current.scrollIntoView({ behavior: 'smooth' });
// Auto-start reply when comment becomes highlighted, but only if not resolved
if (!isReplyingToThisComment && !comment.resolved) {
entity.addReply(comment.id).catch(() => {
// Handle error if adding reply fails
console.error('Failed to add reply for comment:', comment.id);
});
}
} else if (
id !== comment.id &&
isReplyingToThisComment &&
pendingReply
) {
// Cancel reply when comment is no longer highlighted
entity.dismissDraftReply();
}
});
return () => {
subscription.unsubscribe();
};
}, [
comment.id,
comment.resolved,
entity.commentHighlighted$,
isReplyingToThisComment,
pendingReply,
entity,
]);
// Clean up pending reply if comment becomes resolved
useEffect(() => {
if (comment.resolved && isReplyingToThisComment && pendingReply) {
entity.dismissDraftReply();
}
}, [comment.resolved, isReplyingToThisComment, pendingReply, entity]);
const [menuOpen, setMenuOpen] = useState(false);
return (
<div
onClick={handleClickPreview}
data-comment-id={comment.id}
data-resolved={comment.resolved}
data-highlighting={highlighting || menuOpen}
className={styles.commentItem}
ref={commentRef}
>
<div className={styles.commentActions} data-menu-open={menuOpen}>
<IconButton
variant="solid"
onClick={handleResolve}
icon={<DoneIcon />}
disabled={isMutating}
/>
<Menu
rootOptions={{
open: menuOpen,
onOpenChange: v => {
setMenuOpen(v);
},
}}
items={
<>
<MenuItem
onClick={handleReply}
disabled={isMutating || comment.resolved}
>
{t['com.affine.comment.reply']()}
</MenuItem>
<MenuItem onClick={handleCopyLink} disabled={isMutating}>
{t['com.affine.comment.copy-link']()}
</MenuItem>
<MenuItem
onClick={handleDelete}
disabled={isMutating}
style={{ color: 'var(--affine-error-color)' }}
>
{t['Delete']()}
</MenuItem>
</>
}
>
<IconButton
variant="solid"
icon={<MoreHorizontalIcon />}
disabled={isMutating}
/>
</Menu>
</div>
<div className={styles.previewContainer}>{comment.content.preview}</div>
<div className={styles.repliesContainer}>
<ReadonlyCommentRenderer
avatarUrl={comment.user.avatarUrl}
name={comment.user.name}
time={comment.createdAt}
snapshot={comment.content.snapshot}
/>
{/* unlike comment, replies are sorted by createdAt in ascending order */}
{comment.replies
?.toSorted((a, b) => a.createdAt - b.createdAt)
.map(reply => (
<ReadonlyCommentRenderer
key={reply.id}
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
))}
</div>
{highlighting &&
isReplyingToThisComment &&
pendingReply &&
account &&
!comment.resolved && (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
autoFocus
doc={pendingReply.doc}
onCommit={isMutating ? undefined : handleCommitReply}
onCancel={isMutating ? undefined : handleCancelReply}
/>
</div>
)}
</div>
);
};
const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
const comments = useLiveData(entity.comments$);
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const t = useI18n();
const docMode = useLiveData(entity.docMode$);
// Filter state management
const [filterState, setFilterState] = useState<CommentFilterState>({
showResolvedComments: false,
onlyMyReplies: false,
onlyCurrentMode: true,
});
const onFilterChange = useCallback(
(key: keyof CommentFilterState, value: boolean) => {
setFilterState(prev => ({ ...prev, [key]: value }));
},
[]
);
// Filter and sort comments based on filter state
const filteredAndSortedComments = useMemo(() => {
let filteredComments = comments;
// Filter by resolved status
if (!filterState.showResolvedComments) {
filteredComments = filteredComments.filter(comment => !comment.resolved);
}
// Filter by only my replies and mentions
if (filterState.onlyMyReplies) {
filteredComments = filteredComments.filter(comment => {
return (
comment.user.id === account?.id ||
comment.replies?.some(reply => reply.user.id === account?.id)
);
});
}
// Filter by only current mode
if (filterState.onlyCurrentMode) {
filteredComments = filteredComments.filter(comment => {
return (
!comment.content.mode || !docMode || comment.content.mode === docMode
);
});
}
return filteredComments.toSorted((a, b) => b.createdAt - a.createdAt);
}, [
comments,
filterState.showResolvedComments,
filterState.onlyMyReplies,
filterState.onlyCurrentMode,
account?.id,
docMode,
]);
const newPendingComment = useLiveData(entity.pendingComment$);
const loading = useLiveData(entity.loading$);
return (
<>
<div className={styles.header}>
<div className={styles.headerTitle}>
{t['com.affine.comment.comments']()}
</div>
{comments.length > 0 && (
<SortFilterButton
filterState={filterState}
onFilterChange={onFilterChange}
/>
)}
</div>
<CommentInput entity={entity} />
{filteredAndSortedComments.length === 0 &&
!newPendingComment &&
!loading && (
<div className={styles.empty}>
{t['com.affine.comment.no-comments']()}
</div>
)}
{loading &&
filteredAndSortedComments.length === 0 &&
!newPendingComment && (
<div className={styles.loading}>
<Loading size={32} />
</div>
)}
<div className={styles.commentList}>
{filteredAndSortedComments.map(comment => (
<CommentItem key={comment.id} comment={comment} entity={entity} />
))}
</div>
</>
);
};
// handling pending comment
const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
const newPendingComment = useLiveData(entity.pendingComment$);
const pendingPreview = newPendingComment?.preview;
const [isMutating, setIsMutating] = useState(false);
const handleCommit = useAsyncCallback(async () => {
if (!newPendingComment?.id) return;
setIsMutating(true);
try {
await entity.commitComment(newPendingComment.id);
} finally {
setIsMutating(false);
}
}, [entity, newPendingComment]);
const handleCancel = useCallback(() => {
if (!newPendingComment?.id) return;
entity.dismissDraftComment();
}, [entity, newPendingComment]);
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
if (!newPendingComment || !account) {
return null;
}
return (
<div className={styles.pendingComment} data-pending-comment>
{pendingPreview && (
<div className={styles.previewContainer}>{pendingPreview}</div>
)}
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
autoFocus
doc={newPendingComment.doc}
onCommit={isMutating ? undefined : handleCommit}
onCancel={isMutating ? undefined : handleCancel}
/>
</div>
</div>
);
};
const useCommentEntity = (docId: string | undefined) => {
const docCommentManager = useService(DocCommentManagerService);
const commentPanelService = useService(CommentPanelService);
const [entity, setEntity] = useState<DocCommentEntity | null>(null);
useEffect(() => {
if (!docId) {
return;
}
const entityRef = docCommentManager.get(docId);
setEntity(entityRef.obj);
entityRef.obj.start();
entityRef.obj.revalidate();
// Set up pending comment watching to auto-open sidebar
const unwatchPending = commentPanelService.watchForPendingComments(
entityRef.obj
);
return () => {
unwatchPending();
entityRef.release();
};
}, [docCommentManager, commentPanelService, docId]);
return entity;
};
export const CommentSidebar = () => {
const doc = useServiceOptional(DocService)?.doc;
const entity = useCommentEntity(doc?.id);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
// dismiss the highlight when ESC is pressed
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
entity?.highlightComment(null);
}
};
const handleContainerClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('[data-comment-id]')) {
entity?.highlightComment(null);
}
// if creating a new comment, dismiss the comment input
if (
entity?.pendingComment$.value &&
!target.closest('[data-pending-comment]')
) {
entity.dismissDraftComment();
}
};
document.addEventListener('keydown', handleKeyDown);
container?.addEventListener('click', handleContainerClick);
return () => {
document.removeEventListener('keydown', handleKeyDown);
container?.removeEventListener('click', handleContainerClick);
entity?.highlightComment(null);
};
}, [entity]);
if (!entity) {
return null;
}
return (
<div className={styles.container} ref={containerRef}>
<CommentList entity={entity} />
</div>
);
};
@@ -0,0 +1,187 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
paddingTop: '8px',
paddingBottom: '64px',
position: 'relative',
minHeight: '100%',
});
export const header = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 16px',
gap: '8px',
height: '32px',
});
export const headerTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
color: cssVarV2('text/secondary'),
});
export const commentList = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const empty = style({
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('text/secondary'),
});
export const commentItem = style({
padding: '12px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '8px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'&[data-highlighting="true"]:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
position: 'absolute',
top: '0',
left: '0',
},
'&[data-highlighting="true"]': {
backgroundColor: cssVarV2('block/comment/hanelActive'),
},
'&[data-resolved="true"]': {
opacity: 0.5,
},
},
});
export const loading = style({
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const repliesContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const pendingComment = style({
padding: '12px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '8px',
background: cssVarV2('block/comment/hanelActive'),
selectors: {
'&:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
position: 'absolute',
left: '0',
top: '0',
bottom: '0',
},
},
});
export const previewContainer = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
paddingLeft: '10px',
position: 'relative',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '22px',
selectors: {
'&:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
},
},
});
export const commentActions = style({
display: 'flex',
opacity: 0,
gap: '8px',
marginTop: '8px',
position: 'absolute',
right: 12,
top: 4,
zIndex: 1,
pointerEvents: 'none',
selectors: {
[`${commentItem}:hover &`]: {
opacity: 1,
pointerEvents: 'auto',
},
'&[data-menu-open="true"]': {
opacity: 1,
},
},
});
export const readonlyCommentContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '8px',
});
export const userContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
marginTop: '5px',
gap: 10,
});
export const commentInputContainer = style({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: '4px',
paddingLeft: '8px',
});
export const userName = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
fontWeight: '500',
});
export const time = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
fontWeight: '500',
});
@@ -7,6 +7,7 @@ import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
// import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { GlobalPageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { CommentSidebar } from '@affine/core/components/comment/sidebar';
import { useGuard } from '@affine/core/components/guard';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
@@ -37,6 +38,7 @@ import { DisposableGroup } from '@blocksuite/affine/global/disposable';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import {
AiIcon,
CommentIcon,
ExportIcon,
FrameIcon,
PropertyIcon,
@@ -381,6 +383,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</ViewSidebarTab>
)}
{workspace.flavour !== 'local' && (
<ViewSidebarTab tabId="comment" icon={<CommentIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<CommentSidebar />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</ViewSidebarTab>
)}
<GlobalPageHistoryModal />
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
</FrameworkScope>
@@ -0,0 +1,304 @@
import {
createCommentMutation,
createReplyMutation,
deleteCommentMutation,
deleteReplyMutation,
type DocMode,
listCommentChangesQuery,
type ListCommentsQuery,
listCommentsQuery,
resolveCommentMutation,
updateCommentMutation,
updateReplyMutation,
} from '@affine/graphql';
import { Entity } from '@toeverything/infra';
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
import { GraphQLService } from '../../cloud/services/graphql';
import type { WorkspaceService } from '../../workspace';
import type {
DocComment,
DocCommentChangeListResult,
DocCommentContent,
DocCommentListResult,
DocCommentReply,
} from '../types';
type GQLCommentType =
ListCommentsQuery['workspace']['comments']['edges'][number]['node'];
type GQLReplyType = GQLCommentType['replies'][number];
type GQLUserType = GQLCommentType['user'];
// Helper functions for normalizing backend responses
const normalizeUser = (user: GQLUserType) => ({
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
id: reply.id,
content: reply.content as DocCommentContent,
createdAt: new Date(reply.createdAt).getTime(),
updatedAt: new Date(reply.updatedAt).getTime(),
user: normalizeUser(reply.user),
});
const normalizeComment = (comment: GQLCommentType): DocComment => ({
id: comment.id,
content: comment.content as DocCommentContent,
resolved: comment.resolved,
createdAt: new Date(comment.createdAt).getTime(),
updatedAt: new Date(comment.updatedAt).getTime(),
user: comment.user
? normalizeUser(comment.user)
: {
id: '',
name: '',
avatarUrl: '',
},
replies: comment.replies?.map(normalizeReply) ?? [],
});
export class DocCommentStore extends Entity<{
docId: string;
getDocMode: () => DocMode;
getDocTitle: () => string;
}> {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService,
private readonly defaultServerService: DefaultServerService
) {
super();
}
private get serverService() {
return (
this.workspaceServerService.server || this.defaultServerService.server
);
}
private get graphqlService() {
return this.serverService?.scope.get(GraphQLService);
}
private get currentWorkspaceId() {
return this.workspaceService.workspace.id;
}
async listComments({
after,
}: {
after?: string;
}): Promise<DocCommentListResult> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: listCommentsQuery,
variables: {
pagination: {
after,
},
workspaceId: this.currentWorkspaceId,
docId: this.props.docId,
},
});
const comments = response.workspace?.comments;
if (!comments) {
return {
comments: [],
hasNextPage: false,
startCursor: '',
endCursor: '',
};
}
return {
comments: comments.edges.map(edge => normalizeComment(edge.node)),
hasNextPage: comments.pageInfo.hasNextPage,
startCursor: comments.pageInfo.startCursor || '',
endCursor: comments.pageInfo.endCursor || '',
};
}
// pool every 30s
async listCommentChanges({
after,
}: {
after?: string;
}): Promise<DocCommentChangeListResult> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: listCommentChangesQuery,
variables: {
pagination: {
after,
},
workspaceId: this.currentWorkspaceId,
docId: this.props.docId,
},
});
const commentChanges = response.workspace?.commentChanges;
if (!commentChanges) {
return [];
}
return commentChanges.edges.map(edge => ({
id: edge.node.id,
action: edge.node.action,
comment: normalizeComment(edge.node.item),
commentId: edge.node.commentId || undefined,
}));
}
async createComment(commentInput: {
content: DocCommentContent;
}): Promise<DocComment> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: this.currentWorkspaceId,
docId: this.props.docId,
docMode: this.props.getDocMode(),
docTitle: this.props.getDocTitle(),
content: commentInput.content,
},
},
});
const comment = response.createComment;
return normalizeComment(comment);
}
async updateComment(
commentId: string,
commentInput: {
content: DocCommentContent;
}
): Promise<void> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
await graphql.gql({
query: updateCommentMutation,
variables: {
input: {
id: commentId,
content: commentInput.content,
},
},
});
}
async resolveComment(commentId: string, resolved = true): Promise<boolean> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: resolveCommentMutation,
variables: {
input: {
id: commentId,
resolved,
},
},
});
return response.resolveComment;
}
async deleteComment(commentId: string): Promise<boolean> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: deleteCommentMutation,
variables: {
id: commentId,
},
});
return response.deleteComment;
}
async createReply(
commentId: string,
replyInput: {
content: DocCommentContent;
}
): Promise<DocCommentReply> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const response = await graphql.gql({
query: createReplyMutation,
variables: {
input: {
commentId,
content: replyInput.content,
docMode: this.props.getDocMode(),
docTitle: this.props.getDocTitle(),
},
},
});
return normalizeReply(response.createReply);
}
async updateReply(
replyId: string,
replyInput: {
content: DocCommentContent;
}
): Promise<void> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
await graphql.gql({
query: updateReplyMutation,
variables: {
input: {
id: replyId,
content: replyInput.content,
},
},
});
}
async deleteReply(replyId: string): Promise<void> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
await graphql.gql({
query: deleteReplyMutation,
variables: {
id: replyId,
},
});
}
}
@@ -0,0 +1,499 @@
import { type CommentChangeAction, DocMode } from '@affine/graphql';
import type { BaseSelection } from '@blocksuite/affine/store';
import {
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { catchError, of, Subject, switchMap, tap, timer } from 'rxjs';
import { type DocDisplayMetaService } from '../../doc-display-meta';
import { GlobalContextService } from '../../global-context';
import type { SnapshotHelper } from '../services/snapshot-helper';
import type {
CommentId,
DocComment,
DocCommentChangeListResult,
DocCommentContent,
DocCommentListResult,
PendingComment,
} from '../types';
import { DocCommentStore } from './doc-comment-store';
type DisposeCallback = () => void;
export class DocCommentEntity extends Entity<{
docId: string;
}> {
constructor(
private readonly snapshotHelper: SnapshotHelper,
private readonly docDisplayMetaService: DocDisplayMetaService
) {
super();
}
private readonly store = this.framework.createEntity(DocCommentStore, {
docId: this.props.docId,
getDocMode: () =>
this.docMode$.value === 'edgeless' ? DocMode.edgeless : DocMode.page,
getDocTitle: () => {
return this.docDisplayMetaService.title$(this.props.docId).value;
},
});
loading$ = new LiveData<boolean>(false);
comments$ = new LiveData<DocComment[]>([]);
// Only one pending comment at a time (for new comments)
readonly pendingComment$ = new LiveData<PendingComment | null>(null);
// Only one pending reply at a time
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
private readonly commentAdded$ = new Subject<{
id: CommentId;
selections: BaseSelection[];
}>();
private readonly commentResolved$ = new Subject<CommentId>();
private readonly commentDeleted$ = new Subject<CommentId>();
readonly commentHighlighted$ = new LiveData<CommentId | null>(null);
private pollingDisposable?: DisposeCallback;
private startCursor?: string;
async addComment(
selections?: BaseSelection[],
preview?: string
): Promise<string> {
// todo: may need to properly bind the doc to the editor
const doc = await this.snapshotHelper.createStore();
if (!doc) {
throw new Error('Failed to create doc');
}
const id = nanoid();
const pendingComment: PendingComment = {
id,
doc,
preview,
selections,
};
// Replace any existing pending comment (only one at a time)
this.pendingComment$.setValue(pendingComment);
return id;
}
async addReply(commentId: string): Promise<string> {
const doc = await this.snapshotHelper.createStore();
if (!doc) {
throw new Error('Failed to create doc');
}
const id = nanoid();
const pendingReply: PendingComment = {
id,
doc,
commentId,
};
// Replace any existing pending reply (only one at a time)
this.pendingReply$.setValue(pendingReply);
return id;
}
dismissDraftComment(): void {
this.pendingComment$.setValue(null);
}
dismissDraftReply(): void {
this.pendingReply$.setValue(null);
}
get docMode$() {
return this.framework.get(GlobalContextService).globalContext.docMode.$;
}
async commitComment(id: string): Promise<void> {
const pendingComment = this.pendingComment$.value;
if (!pendingComment || pendingComment.id !== id) {
console.warn('Pending comment not found:', id);
return;
}
const { doc, preview } = pendingComment;
const snapshot = this.snapshotHelper.getSnapshot(doc);
if (!snapshot) {
throw new Error('Failed to get snapshot');
}
const comment = await this.store.createComment({
content: {
snapshot,
preview,
mode: this.docMode$.value ?? 'page',
},
});
const currentComments = this.comments$.value;
this.comments$.setValue([...currentComments, comment]);
this.commentAdded$.next({
id: comment.id,
selections: pendingComment.selections || [],
});
this.pendingComment$.setValue(null);
this.revalidate();
}
async commitReply(id: string): Promise<void> {
const pendingReply = this.pendingReply$.value;
if (!pendingReply || pendingReply.id !== id) {
console.warn('Pending reply not found:', id);
return;
}
const { doc } = pendingReply;
const snapshot = this.snapshotHelper.getSnapshot(doc);
if (!snapshot) {
throw new Error('Failed to get snapshot');
}
if (!pendingReply.commentId) {
throw new Error('Pending reply has no commentId');
}
const reply = await this.store.createReply(pendingReply.commentId, {
content: {
snapshot,
},
});
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment =>
comment.id === pendingReply.commentId
? { ...comment, replies: [...(comment.replies || []), reply] }
: comment
);
this.comments$.setValue(updatedComments);
this.pendingReply$.setValue(null);
this.revalidate();
}
async deleteComment(id: string): Promise<void> {
await this.store.deleteComment(id);
const currentComments = this.comments$.value;
this.comments$.setValue(currentComments.filter(c => c.id !== id));
this.commentDeleted$.next(id);
this.revalidate();
}
async deleteReply(id: string): Promise<void> {
await this.store.deleteReply(id);
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment => {
return {
...comment,
replies: comment.replies?.filter(r => r.id !== id),
};
});
this.comments$.setValue(updatedComments);
this.revalidate();
}
async updateComment(id: string, content: DocCommentContent): Promise<void> {
await this.store.updateComment(id, { content });
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment =>
comment.id === id ? { ...comment, content } : comment
);
this.comments$.setValue(updatedComments);
this.revalidate();
}
async updateReply(id: string, content: DocCommentContent): Promise<void> {
await this.store.updateReply(id, { content });
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment =>
comment.id === id ? { ...comment, content } : comment
);
this.comments$.setValue(updatedComments);
this.revalidate();
}
async resolveComment(id: CommentId, resolved: boolean): Promise<void> {
try {
await this.store.resolveComment(id, resolved);
// Update local state
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment =>
comment.id === id ? { ...comment, resolved } : comment
);
this.comments$.setValue(updatedComments);
this.commentResolved$.next(id);
this.revalidate();
} catch (error) {
console.error('Failed to resolve comment:', error);
throw error;
}
}
highlightComment(id: CommentId | null): void {
this.commentHighlighted$.next(id);
}
getComments(): CommentId[] {
return this.comments$.value.map(comment => comment.id);
}
onCommentAdded(
callback: (id: CommentId, selections: BaseSelection[]) => void
): DisposeCallback {
const subscription = this.commentAdded$.subscribe(({ id, selections }) =>
callback(id, selections)
);
return () => subscription.unsubscribe();
}
onCommentResolved(callback: (id: CommentId) => void): DisposeCallback {
const subscription = this.commentResolved$.subscribe(callback);
return () => subscription.unsubscribe();
}
onCommentDeleted(callback: (id: CommentId) => void): DisposeCallback {
const subscription = this.commentDeleted$.subscribe(callback);
return () => subscription.unsubscribe();
}
onCommentHighlighted(
callback: (id: CommentId | null) => void
): DisposeCallback {
const subscription = this.commentHighlighted$.subscribe(callback);
return () => subscription.unsubscribe();
}
// Start polling comments every 30s
// 1. when comments$ is empty, fetch all comments
// 2. when comments$ is not empty, fetch changes (using end cursor)
// 3. loop. when doc is not loaded, skip
start(): void {
if (this.pollingDisposable) {
this.pollingDisposable();
}
// Initial load
this.revalidate();
// Set up polling every 10 seconds
const polling$ = timer(10000, 10000).pipe(
switchMap(() => {
// If we have comments, fetch changes; otherwise fetch all
if (this.comments$.value.length > 0) {
return fromPromise(async () => {
return await this.store.listCommentChanges({
after: this.startCursor,
});
}).pipe(
tap(changes => {
if (changes) {
this.handleCommentChanges(changes);
}
}),
catchError(error => {
console.error('Failed to fetch comment changes:', error);
return of(null);
})
);
} else {
return fromPromise(async () => {
const allComments: DocComment[] = [];
let cursor = '';
let firstResult: DocCommentListResult | null = null;
// Fetch all pages of comments
while (true) {
const result = await this.store.listComments({ after: cursor });
if (!firstResult) {
firstResult = result;
// Store the startCursor from the first page for future polling
this.startCursor = result.startCursor;
}
allComments.push(...result.comments);
cursor = result.endCursor;
if (!result.hasNextPage) {
break;
}
}
// Update state with all comments
this.comments$.setValue(allComments);
return allComments;
}).pipe(
catchError(error => {
console.error('Failed to fetch comments:', error);
return of(null);
})
);
}
})
);
const subscription = polling$.subscribe();
this.pollingDisposable = () => subscription.unsubscribe();
}
stop(): void {
if (this.pollingDisposable) {
this.pollingDisposable();
}
}
private handleCommentChanges(changes: DocCommentChangeListResult): void {
if (!changes || changes.length === 0) {
return;
}
const currentComments = [...this.comments$.value];
let commentsUpdated = false;
for (const change of changes) {
const { id, action, comment, commentId } = change;
if (commentId) {
// This is a reply change - handle separately
this.handleReplyChange(currentComments, action, comment, commentId);
commentsUpdated = true;
} else {
// This is a top-level comment change
switch (action) {
case 'update': {
// Update existing comment or add new comment if it doesn't exist
const updateIndex = currentComments.findIndex(c => c.id === id);
if (updateIndex !== -1) {
// Update existing comment
currentComments[updateIndex] = comment;
commentsUpdated = true;
} else {
// Add new comment if it doesn't exist (create event)
currentComments.push(comment);
commentsUpdated = true;
}
break;
}
case 'delete': {
// Remove comment
const deleteIndex = currentComments.findIndex(c => c.id === id);
if (deleteIndex !== -1) {
currentComments.splice(deleteIndex, 1);
commentsUpdated = true;
}
break;
}
default:
console.warn('Unknown comment change action:', action);
}
}
}
// Update the comments list if any changes were made
if (commentsUpdated) {
this.comments$.setValue(currentComments);
}
}
private handleReplyChange(
currentComments: DocComment[],
action: CommentChangeAction,
reply: DocComment,
parentCommentId: string
): void {
const parentIndex = currentComments.findIndex(
c => c.id === parentCommentId
);
if (parentIndex === -1) {
console.warn('Parent comment not found for reply:', parentCommentId);
return;
}
const parentComment = currentComments[parentIndex];
const replies = [...(parentComment.replies || [])];
switch (action) {
case 'update': {
// Update existing reply or add new reply if it doesn't exist
const updateIndex = replies.findIndex(r => r.id === reply.id);
if (updateIndex !== -1) {
// Update existing reply
replies[updateIndex] = reply;
currentComments[parentIndex] = { ...parentComment, replies };
} else {
// Add new reply if it doesn't exist (create event)
replies.push(reply);
currentComments[parentIndex] = { ...parentComment, replies };
}
break;
}
case 'delete': {
// Remove reply
const deleteIndex = replies.findIndex(r => r.id === reply.id);
if (deleteIndex !== -1) {
replies.splice(deleteIndex, 1);
currentComments[parentIndex] = { ...parentComment, replies };
}
break;
}
default:
console.warn('Unknown reply change action:', action);
}
}
revalidate = effect(
switchMap(() => {
return fromPromise(async () => {
const allComments: DocComment[] = [];
let cursor = '';
let firstResult: DocCommentListResult | null = null;
// Fetch all pages of comments
while (true) {
const result = await this.store.listComments({ after: cursor });
if (!firstResult) {
firstResult = result;
// Store the startCursor from the first page for polling
this.startCursor = result.startCursor;
}
allComments.push(...result.comments);
cursor = result.endCursor;
if (!result.hasNextPage) {
break;
}
}
return allComments;
}).pipe(
tap(allComments => {
// Update state with all comments
this.comments$.setValue(allComments);
}),
onStart(() => this.loading$.setValue(true)),
onComplete(() => this.loading$.setValue(false)),
catchError(error => {
console.error('Failed to fetch comments:', error);
this.loading$.setValue(false);
return of([]);
})
);
})
);
override dispose(): void {
this.stop();
this.commentAdded$.complete();
this.commentResolved$.complete();
this.commentDeleted$.complete();
this.commentHighlighted$.complete();
super.dispose();
}
}
@@ -0,0 +1,29 @@
import type { Framework } from '@toeverything/infra';
import { DefaultServerService, WorkspaceServerService } from '../cloud';
import { DocDisplayMetaService } from '../doc-display-meta';
import { WorkbenchService } from '../workbench';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { DocCommentEntity } from './entities/doc-comment';
import { DocCommentStore } from './entities/doc-comment-store';
import { CommentPanelService } from './services/comment-panel-service';
import { DocCommentManagerService } from './services/doc-comment-manager';
import { SnapshotHelper } from './services/snapshot-helper';
export function configureCommentModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocCommentManagerService)
.service(CommentPanelService, [WorkbenchService])
.service(SnapshotHelper, [
WorkspaceService,
WorkspaceServerService,
DefaultServerService,
])
.entity(DocCommentEntity, [SnapshotHelper, DocDisplayMetaService])
.entity(DocCommentStore, [
WorkspaceService,
WorkspaceServerService,
DefaultServerService,
]);
}
@@ -0,0 +1,54 @@
import { type WorkbenchService } from '@affine/core/modules/workbench';
import { Service } from '@toeverything/infra';
import type { DocCommentEntity } from '../entities/doc-comment';
export class CommentPanelService extends Service {
constructor(private readonly workbenchService: WorkbenchService) {
super();
}
private readonly activePendingWatchers = new Set<() => void>();
/**
* Watch for pending comments on a doc comment entity and open the sidebar automatically
*/
watchForPendingComments(entity: DocCommentEntity): () => void {
const subscription = entity.pendingComment$.subscribe(pendingComment => {
// If we have a new pending comment, open the comment panel
if (pendingComment) {
this.openCommentPanel();
}
});
const dispose = () => {
subscription.unsubscribe();
this.activePendingWatchers.delete(dispose);
};
this.activePendingWatchers.add(dispose);
return dispose;
}
/**
* Open the sidebar and activate the comment tab
*/
openCommentPanel(): void {
const workbench = this.workbenchService.workbench;
const activeView = workbench.activeView$.value;
if (activeView) {
workbench.openSidebar();
activeView.activeSidebarTab('comment');
}
}
override dispose(): void {
// Clean up all active watchers
for (const dispose of this.activePendingWatchers) {
dispose();
}
this.activePendingWatchers.clear();
super.dispose();
}
}
@@ -0,0 +1,29 @@
import { ObjectPool, Service } from '@toeverything/infra';
import { DocCommentEntity } from '../entities/doc-comment';
type DocId = string;
export class DocCommentManagerService extends Service {
constructor() {
super();
}
private readonly pool = new ObjectPool<DocId, DocCommentEntity>({
onDelete: entity => {
entity.dispose();
},
});
get(docId: DocId) {
let commentRef = this.pool.get(docId);
if (!commentRef) {
const comment = this.framework.createEntity(DocCommentEntity, {
docId,
});
commentRef = this.pool.put(docId, comment);
// todo: add LRU cache for the pool?
}
return commentRef;
}
}
@@ -0,0 +1,158 @@
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { Container } from '@blocksuite/affine/global/di';
import {
customImageProxyMiddleware,
MarkdownAdapter,
} from '@blocksuite/affine/shared/adapters';
import {
type DocSnapshot,
nanoid,
type Store,
Text,
Transformer,
} from '@blocksuite/affine/store';
import { Service } from '@toeverything/infra';
import { Doc as YDoc } from 'yjs';
import type { DefaultServerService, WorkspaceServerService } from '../../cloud';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceService,
} from '../../workspace';
import { WorkspaceImpl } from '../../workspace/impls/workspace';
export class SnapshotHelper extends Service {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService,
private readonly defaultServerService: DefaultServerService
) {
super();
}
private get serverService() {
return (
this.workspaceServerService.server || this.defaultServerService.server
);
}
getTempWorkspace() {
const collection = new WorkspaceImpl({
rootDoc: new YDoc({ guid: 'markdownToDoc' + nanoid() }),
blobSource: {
name: 'cloud',
readonly: true,
get: async key => {
const record =
await this.workspaceService.workspace.engine.blob.get(key);
return record ? new Blob([record.data], { type: record.mime }) : null;
},
set() {
return Promise.resolve('');
},
delete() {
return Promise.resolve();
},
list() {
return Promise.resolve([]);
},
},
});
collection.meta.initialize();
return collection;
}
// todo: cache the transformer?
getTransformer() {
const collection = this.getTempWorkspace();
const schema = getAFFiNEWorkspaceSchema();
const imageProxyUrl = new URL(
BUILD_CONFIG.imageProxyUrl,
this.serverService.baseUrl
).toString();
const middlewares = [customImageProxyMiddleware(imageProxyUrl)];
const transformer = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => {
const doc = collection.createDoc(id);
return doc.getStore({ id });
},
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares,
});
return transformer;
}
getMarkdownAdapter() {
const transformer = this.getTransformer();
const extensions = getStoreManager().config.init().value.get('store');
const container = new Container();
extensions.forEach(ext => {
ext.setup(container);
});
const mdAdapter = new MarkdownAdapter(transformer, container.provider());
return mdAdapter;
}
getSnapshot(doc: Store): DocSnapshot | undefined {
const transformer = this.getTransformer();
if (!transformer) {
throw new Error('Markdown transformer not found');
}
const result = transformer.docToSnapshot(doc);
return result;
}
async createStore(snapshot?: DocSnapshot): Promise<Store | undefined> {
if (snapshot) {
const transformer = this.getTransformer();
if (!transformer) {
throw new Error('Markdown transformer not found');
}
return await transformer.snapshotToDoc(snapshot);
} else {
const collection = this.getTempWorkspace();
if (!collection) {
throw new Error('Temp workspace not found');
}
// Create a temporary doc with proper structure
const doc = collection.createDoc();
const store = doc.getStore();
store.load(() => {
// Add root page block with empty title
const rootId = store.addBlock('affine:page', {
title: new Text(''),
});
// Add surface block
store.addBlock('affine:surface', {}, rootId);
// Add note block
const noteId = store.addBlock('affine:note', {}, rootId);
// Add default paragraph block
store.addBlock('affine:paragraph', {}, noteId);
});
// Reset history to prevent initial creation operations from being undone
store.resetHistory();
return store;
}
}
async createEmptySnapshot() {
const store = await this.createStore();
if (store) {
return this.getSnapshot(store);
} else {
return undefined;
}
}
}
@@ -0,0 +1,54 @@
import type { CommentChangeAction, PublicUserType } from '@affine/graphql';
import type { DocMode } from '@blocksuite/affine/model';
import type {
BaseSelection,
DocSnapshot,
Store,
} from '@blocksuite/affine/store';
export type CommentId = string;
export interface BaseComment {
id: CommentId;
content: DocCommentContent;
createdAt: number;
updatedAt: number;
user: PublicUserType;
}
export interface DocComment extends BaseComment {
resolved: boolean;
replies?: DocCommentReply[];
}
export type PendingComment = {
id: CommentId;
doc: Store;
preview?: string;
selections?: BaseSelection[];
commentId?: CommentId; // only for replies, points to the parent comment
};
export type DocCommentReply = BaseComment;
export type DocCommentContent = {
snapshot: DocSnapshot; // blocksuite snapshot
mode?: DocMode;
preview?: string; // text preview of the target
};
export interface DocCommentListResult {
comments: DocComment[];
hasNextPage: boolean;
startCursor: string;
endCursor: string;
}
export interface DocCommentChange {
action: CommentChangeAction;
comment: DocComment;
id: CommentId; // the id of the comment or reply
commentId?: CommentId; // a change with comment id is a reply
}
export type DocCommentChangeListResult = DocCommentChange[];
@@ -3,9 +3,13 @@ import type { DefaultOpenProperty } from '@affine/core/components/properties';
import { PresentTool } from '@blocksuite/affine/blocks/frame';
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
import { findCommentedTexts } from '@blocksuite/affine/inlines/comment';
import type { DocMode, ReferenceParams } from '@blocksuite/affine/model';
import { HighlightSelection } from '@blocksuite/affine/shared/selection';
import { DocModeProvider } from '@blocksuite/affine/shared/services';
import {
DocModeProvider,
findCommentedBlocks,
} from '@blocksuite/affine/shared/services';
import { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
import type { InlineEditor } from '@blocksuite/std/inline';
import { effect } from '@preact/signals-core';
@@ -51,6 +55,7 @@ export class Editor extends Entity {
const selector = get(this.selector$);
const mode = get(this.mode$);
let id = selector?.blockIds?.[0];
let commentId = selector?.commentId;
let key = 'blockIds';
if (mode === 'edgeless') {
@@ -61,9 +66,15 @@ export class Editor extends Entity {
}
}
if (!id) return null;
if (!id && !commentId) return null;
return { id, key, mode, refreshKey: selector?.refreshKey };
return {
id,
key,
mode,
refreshKey: selector?.refreshKey,
commentId: commentId,
};
});
isPresenting$ = new LiveData<boolean>(false);
@@ -183,6 +194,51 @@ export class Editor extends Entity {
};
}
handleFocusAt(focusAt: {
key: string;
mode: DocMode;
id?: string;
commentId?: string;
}) {
const editorContainer = this.editorContainer$.value;
if (!editorContainer) return;
const selection = editorContainer.host?.std.selection;
const { id, key, mode, commentId } = focusAt;
let finalId = id;
let finalKey = key;
// If we have commentId but no blockId, find the block from the comment
if (commentId && !id && editorContainer.host?.std) {
const std = editorContainer.host.std;
// First try to find inline commented texts
const inlineCommentedSelections = findCommentedTexts(std, commentId);
if (inlineCommentedSelections.length > 0) {
const firstSelection = inlineCommentedSelections[0][0];
finalId = firstSelection.from.blockId;
finalKey = 'blockIds';
} else {
// Then try to find block comments
const blockCommentedBlocks = findCommentedBlocks(std.store, commentId);
if (blockCommentedBlocks.length > 0) {
finalId = blockCommentedBlocks[0].id;
finalKey = 'blockIds';
}
}
}
if (mode === this.mode$.value && finalId) {
selection?.setGroup('scene', [
selection?.create(HighlightSelection, {
mode,
[finalKey]: [finalId],
}),
]);
}
}
bindEditorContainer(
editorContainer: AffineEditorContainer,
docTitle?: DocTitle | null,
@@ -228,18 +284,7 @@ export class Editor extends Entity {
title?.inlineEditor?.focusEnd();
}
} else {
const selection = editorContainer.host?.std.selection;
const { id, key, mode } = initialFocusAt;
if (mode === this.mode$.value) {
selection?.setGroup('scene', [
selection?.create(HighlightSelection, {
mode,
[key]: [id],
}),
]);
}
this.handleFocusAt(initialFocusAt);
}
}
@@ -279,18 +324,7 @@ export class Editor extends Entity {
.pipe(skip(1))
.subscribe(anchor => {
if (!anchor) return;
const selection = editorContainer.host?.std.selection;
if (!selection) return;
const { id, key, mode } = anchor;
selection.setGroup('scene', [
selection.create(HighlightSelection, {
mode,
[key]: [id],
}),
]);
this.handleFocusAt(anchor);
});
unsubs.push(subscription.unsubscribe.bind(subscription));
@@ -1,5 +1,6 @@
export type EditorSelector = {
blockIds?: string[];
elementIds?: string[];
commentId?: string;
refreshKey?: string;
};
@@ -265,7 +265,8 @@ export const AFFINE_FLAGS = {
defaultState: false,
},
enable_comment: {
category: 'affine',
category: 'blocksuite',
bsFlag: 'enable_comment',
displayName: 'Enable Comment',
description: 'Enable comment',
configurable: isCanaryBuild,
@@ -13,6 +13,7 @@ import { configureBlobManagementModule } from './blob-management';
import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureCollectionRulesModule } from './collection-rules';
import { configureCommentModule } from './comment';
import { configureWorkspaceDBModule } from './db';
import { configureDialogModule } from './dialogs';
import { configureDndModule } from './dnd';
@@ -118,4 +119,5 @@ export function configureCommonModules(framework: Framework) {
configureWorkspacePropertyModule(framework);
configureCollectionRulesModule(framework);
configureIndexerEmbeddingModule(framework);
configureCommentModule(framework);
}
@@ -155,6 +155,7 @@ export const preprocessParams = (
'databaseId',
'databaseRowId',
'refreshKey',
'commentId',
]);
};
@@ -171,6 +172,7 @@ export const paramsParseOptions: ParseOptions = {
databaseId: 'string',
databaseRowId: 'string',
refreshKey: 'string',
commentId: 'string',
},
};
@@ -1,26 +1,26 @@
{
"ar": 100,
"ar": 99,
"ca": 4,
"da": 4,
"de": 100,
"el-GR": 100,
"de": 99,
"el-GR": 99,
"en": 100,
"es-AR": 100,
"es-AR": 99,
"es-CL": 100,
"es": 100,
"fa": 100,
"fr": 100,
"es": 99,
"fa": 99,
"fr": 99,
"hi": 2,
"it-IT": 100,
"it-IT": 99,
"it": 1,
"ja": 100,
"ja": 99,
"ko": 53,
"pl": 100,
"pt-BR": 100,
"ru": 100,
"sv-SE": 100,
"uk": 100,
"pl": 99,
"pt-BR": 99,
"ru": 99,
"sv-SE": 99,
"uk": 99,
"ur": 2,
"zh-Hans": 100,
"zh-Hant": 100
"zh-Hans": 99,
"zh-Hant": 99
}
+44
View File
@@ -8212,6 +8212,50 @@ export function useAFFiNEI18N(): {
* `Migrate data`
*/
["com.affine.migration-all-docs-notification.button"](): string;
/**
* `Comments`
*/
["com.affine.comment.comments"](): string;
/**
* `No comments yet`
*/
["com.affine.comment.no-comments"](): string;
/**
* `Delete the thread?`
*/
["com.affine.comment.delete.confirm.title"](): string;
/**
* `All comments will also be deleted, and this action cannot be undone.`
*/
["com.affine.comment.delete.confirm.description"](): string;
/**
* `Delete this reply?`
*/
["com.affine.comment.reply.delete.confirm.title"](): string;
/**
* `Delete this reply? This action cannot be undone.`
*/
["com.affine.comment.reply.delete.confirm.description"](): string;
/**
* `Show resolved comments`
*/
["com.affine.comment.filter.show-resolved"](): string;
/**
* `Only my replies and mentions`
*/
["com.affine.comment.filter.only-my-replies"](): string;
/**
* `Only current mode`
*/
["com.affine.comment.filter.only-current-mode"](): string;
/**
* `Reply`
*/
["com.affine.comment.reply"](): string;
/**
* `Copy link`
*/
["com.affine.comment.copy-link"](): string;
/**
* `An internal error occurred.`
*/
@@ -2059,6 +2059,17 @@
"com.affine.migration-all-docs-notification.desc": "We are updating the local data to facilitate the recording and filtering of created by and Last edited by information. Please click the “Migrate Data” button and ensure a stable network connection during the process.",
"com.affine.migration-all-docs-notification.error": "Migration failed: {{errorMessage}}",
"com.affine.migration-all-docs-notification.button": "Migrate data",
"com.affine.comment.comments": "Comments",
"com.affine.comment.no-comments": "No comments yet",
"com.affine.comment.delete.confirm.title": "Delete the thread?",
"com.affine.comment.delete.confirm.description": "All comments will also be deleted, and this action cannot be undone.",
"com.affine.comment.reply.delete.confirm.title": "Delete this reply?",
"com.affine.comment.reply.delete.confirm.description": "Delete this reply? This action cannot be undone.",
"com.affine.comment.filter.show-resolved": "Show resolved comments",
"com.affine.comment.filter.only-my-replies": "Only my replies and mentions",
"com.affine.comment.filter.only-current-mode": "Only current mode",
"com.affine.comment.reply": "Reply",
"com.affine.comment.copy-link": "Copy link",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",