mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(core): reply actions (#13071)
fix AF-2717, AF-2716 #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Introduced full editing, replying, and deletion capabilities for individual replies within comments, with consistent UI and state management. * Added support for editing drafts for both comments and replies, allowing users to start, commit, or dismiss edits. * Improved editor focus behavior for a more seamless editing experience. * Added permission checks for comment and reply creation, editing, deletion, and resolution, controlling UI elements accordingly. * Refactored reply rendering into dedicated components with enhanced permission-aware interactions. * **Bug Fixes** * Enhanced comment normalization to handle cases where content may be missing, preventing potential errors. * **Style** * Updated comment and reply UI styles for clearer editing and action states, including new hover and visibility behaviors for reply actions. * **Chores** * Removed unnecessary debugging statements from reply configuration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #13071** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -15,6 +15,10 @@ query getDocRolePermissions($workspaceId: String!, $docId: String!) {
|
||||
Doc_Update
|
||||
Doc_Users_Manage
|
||||
Doc_Users_Read
|
||||
Doc_Comments_Create
|
||||
Doc_Comments_Delete
|
||||
Doc_Comments_Read
|
||||
Doc_Comments_Resolve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,6 +1323,10 @@ export const getDocRolePermissionsQuery = {
|
||||
Doc_Update
|
||||
Doc_Users_Manage
|
||||
Doc_Users_Read
|
||||
Doc_Comments_Create
|
||||
Doc_Comments_Delete
|
||||
Doc_Comments_Read
|
||||
Doc_Comments_Resolve
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4427,6 +4427,10 @@ export type GetDocRolePermissionsQuery = {
|
||||
Doc_Update: boolean;
|
||||
Doc_Users_Manage: boolean;
|
||||
Doc_Users_Read: boolean;
|
||||
Doc_Comments_Create: boolean;
|
||||
Doc_Comments_Delete: boolean;
|
||||
Doc_Comments_Read: boolean;
|
||||
Doc_Comments_Resolve: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
|
||||
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
|
||||
import { focusTextModel, type RichText } from '@blocksuite/affine/rich-text';
|
||||
import { type RichText, selectTextModel } 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 type { TextSelection } from '@blocksuite/std';
|
||||
import { useFramework, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||
import { getCommentEditorViewManager } from './specs';
|
||||
import * as styles from './style.css';
|
||||
|
||||
@@ -46,6 +48,7 @@ interface CommentEditorProps {
|
||||
|
||||
export interface CommentEditorRef {
|
||||
getSnapshot: () => DocSnapshot | null | undefined;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
// todo: get rid of circular data changes
|
||||
@@ -95,6 +98,32 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
|
||||
const [empty, setEmpty] = useState(true);
|
||||
|
||||
const focusEditor = useAsyncCallback(async () => {
|
||||
if (editorRef.current) {
|
||||
const selectionService = editorRef.current.std.selection;
|
||||
const selection = selectionService.value.at(-1) as TextSelection;
|
||||
editorRef.current.std.event.active = true;
|
||||
await editorRef.current.host?.updateComplete;
|
||||
if (selection) {
|
||||
selectTextModel(
|
||||
editorRef.current.std,
|
||||
selection.blockId,
|
||||
selection.from.index,
|
||||
selection.from.length
|
||||
);
|
||||
} else {
|
||||
const richTexts = Array.from(
|
||||
editorRef.current?.querySelectorAll('rich-text') ?? []
|
||||
) as unknown as RichText[];
|
||||
|
||||
const lastRichText = richTexts.at(-1);
|
||||
if (lastRichText) {
|
||||
lastRichText.inlineEditor?.focusEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editorRef]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -104,19 +133,11 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
}
|
||||
return snapshotHelper.getSnapshot(doc);
|
||||
},
|
||||
focus: focusEditor,
|
||||
}),
|
||||
[doc, snapshotHelper]
|
||||
[doc, focusEditor, snapshotHelper]
|
||||
);
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const lastChild = editorRef.current.std.store.root?.lastChild();
|
||||
if (lastChild) {
|
||||
focusTextModel(editorRef.current.std, lastChild.id);
|
||||
}
|
||||
}
|
||||
}, [editorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false;
|
||||
if (autoFocus && editorRef.current && doc) {
|
||||
@@ -195,7 +216,9 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
data-readonly={!!readonly}
|
||||
className={clsx(styles.container, 'comment-editor-viewport')}
|
||||
>
|
||||
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
|
||||
{doc && (
|
||||
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
|
||||
)}
|
||||
{!readonly && (
|
||||
<div className={styles.footer}>
|
||||
<button
|
||||
|
||||
@@ -104,7 +104,6 @@ export const createCommentLinkedWidgetConfig = (
|
||||
|
||||
const items = computed<LinkedMenuItem[]>(() => {
|
||||
const members = memberSearchService.result$.signal.value;
|
||||
console.log('members', members);
|
||||
|
||||
if (query.length === 0) {
|
||||
return members
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
notify,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { useGuard } from '@affine/core/components/guard';
|
||||
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 type {
|
||||
DocComment,
|
||||
DocCommentReply,
|
||||
} 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';
|
||||
@@ -29,7 +33,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
|
||||
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||
import { CommentEditor } from '../comment-editor';
|
||||
import { CommentEditor, type CommentEditorRef } from '../comment-editor';
|
||||
import * as styles from './style.css';
|
||||
|
||||
interface CommentFilterState {
|
||||
@@ -132,6 +136,11 @@ const CommentItem = ({
|
||||
const session = useService(AuthService).session;
|
||||
const account = useLiveData(session.account$);
|
||||
|
||||
const docId = entity.props.docId;
|
||||
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
|
||||
const canResolveComment = useGuard('Doc_Comments_Resolve', docId);
|
||||
|
||||
const pendingReply = useLiveData(entity.pendingReply$);
|
||||
// Check if the pending reply belongs to this comment
|
||||
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
|
||||
@@ -141,6 +150,13 @@ const CommentItem = ({
|
||||
// Loading state for any async operation
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
|
||||
const editingDraft = useLiveData(entity.editingDraft$);
|
||||
const isEditing =
|
||||
editingDraft?.type === 'comment' && editingDraft.id === comment.id;
|
||||
const editingDoc = isEditing ? editingDraft.doc : undefined;
|
||||
|
||||
const [replyEditor, setReplyEditor] = useState<CommentEditorRef | null>(null);
|
||||
|
||||
const handleDelete = useAsyncCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -178,22 +194,26 @@ const CommentItem = ({
|
||||
[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 handleReply = useAsyncCallback(async () => {
|
||||
if (comment.resolved) return;
|
||||
await entity.addReply(comment.id);
|
||||
entity.highlightComment(comment.id);
|
||||
if (replyEditor) {
|
||||
// todo: it seems we need to wait for 1000ms
|
||||
// to ensure the menu closing animation is complete
|
||||
setTimeout(() => {
|
||||
replyEditor.focus();
|
||||
}, 1000);
|
||||
}
|
||||
}, [entity, comment.id, comment.resolved, replyEditor]);
|
||||
|
||||
const handleCopyLink = useAsyncCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Create a URL with the comment ID
|
||||
|
||||
if (!comment.content) return;
|
||||
|
||||
const search = toDocSearchParams({
|
||||
mode: comment.content.mode,
|
||||
commentId: comment.id,
|
||||
@@ -209,7 +229,7 @@ const CommentItem = ({
|
||||
notify.success({ title: t['Copied link to clipboard']() });
|
||||
},
|
||||
[
|
||||
comment.content.mode,
|
||||
comment.content,
|
||||
comment.id,
|
||||
entity.props.docId,
|
||||
serverService.server.baseUrl,
|
||||
@@ -239,7 +259,7 @@ const CommentItem = ({
|
||||
workbench.workbench.openDoc(
|
||||
{
|
||||
docId: entity.props.docId,
|
||||
mode: comment.content.mode,
|
||||
mode: comment.content?.mode,
|
||||
commentId: comment.id,
|
||||
refreshKey: 'comment-' + Date.now(),
|
||||
},
|
||||
@@ -248,7 +268,7 @@ const CommentItem = ({
|
||||
}
|
||||
);
|
||||
entity.highlightComment(comment.id);
|
||||
}, [comment.id, comment.content.mode, entity, workbench.workbench]);
|
||||
}, [comment.id, comment.content?.mode, entity, workbench.workbench]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = entity.commentHighlighted$
|
||||
@@ -293,73 +313,40 @@ const CommentItem = ({
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// When the comment item is rendered the first time, the replies will be collapsed by default
|
||||
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
|
||||
// will be shown
|
||||
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
(comment.replies?.length ?? 0) > 4
|
||||
// Replies handled by ReplyList component; no local collapsed logic here.
|
||||
|
||||
const handleStartEdit = useAsyncCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (comment.resolved || !comment.content) return;
|
||||
await entity.startEdit(comment.id, 'comment', comment.content.snapshot);
|
||||
},
|
||||
[entity, comment.id, comment.content, comment.resolved]
|
||||
);
|
||||
|
||||
const renderedReplies = useMemo(() => {
|
||||
// Sort replies ascending by createdAt
|
||||
const sortedReplies =
|
||||
comment.replies?.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
|
||||
if (sortedReplies.length === 0) return null;
|
||||
|
||||
// If not collapsed or replies are fewer than threshold, render all
|
||||
if (!collapsed || sortedReplies.length <= 4) {
|
||||
return sortedReplies.map(reply => (
|
||||
<ReadonlyCommentRenderer
|
||||
key={reply.id}
|
||||
avatarUrl={reply.user.avatarUrl}
|
||||
name={reply.user.name}
|
||||
time={reply.createdAt}
|
||||
snapshot={reply.content.snapshot}
|
||||
/>
|
||||
));
|
||||
const handleCommitEdit = useAsyncCallback(async () => {
|
||||
setIsMutating(true);
|
||||
try {
|
||||
await entity.commitEditing();
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
// Collapsed state: first reply + collapsed indicator + last two replies
|
||||
const firstReply = sortedReplies[0];
|
||||
const tailReplies = sortedReplies.slice(-2);
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
entity.dismissDraftEditing();
|
||||
}, [entity]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{firstReply && (
|
||||
<ReadonlyCommentRenderer
|
||||
key={firstReply.id}
|
||||
avatarUrl={firstReply.user.avatarUrl}
|
||||
name={firstReply.user.name}
|
||||
time={firstReply.createdAt}
|
||||
snapshot={firstReply.content.snapshot}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.collapsedReplies}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.collapsedRepliesTitle}>
|
||||
{t['com.affine.comment.reply.show-more']({
|
||||
count: (sortedReplies.length - 4).toString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{tailReplies.map(reply => (
|
||||
<ReadonlyCommentRenderer
|
||||
key={reply.id}
|
||||
avatarUrl={reply.user.avatarUrl}
|
||||
name={reply.user.name}
|
||||
time={reply.createdAt}
|
||||
snapshot={reply.content.snapshot}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, [collapsed, comment.replies, t]);
|
||||
const isMyComment = account && account.id === comment.user.id;
|
||||
const canReply = canCreateComment;
|
||||
const canEdit = isMyComment && canCreateComment;
|
||||
const canDelete =
|
||||
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
|
||||
|
||||
// invalid comment, should not happen
|
||||
if (!comment.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -370,13 +357,19 @@ const CommentItem = ({
|
||||
className={styles.commentItem}
|
||||
ref={commentRef}
|
||||
>
|
||||
<div className={styles.commentActions} data-menu-open={menuOpen}>
|
||||
<IconButton
|
||||
variant="solid"
|
||||
onClick={handleResolve}
|
||||
icon={<DoneIcon />}
|
||||
disabled={isMutating}
|
||||
/>
|
||||
<div
|
||||
className={styles.commentActions}
|
||||
data-menu-open={menuOpen}
|
||||
data-editing={isEditing}
|
||||
>
|
||||
{!comment.resolved && canResolveComment && (
|
||||
<IconButton
|
||||
variant="solid"
|
||||
onClick={handleResolve}
|
||||
icon={<DoneIcon />}
|
||||
disabled={isMutating}
|
||||
/>
|
||||
)}
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open: menuOpen,
|
||||
@@ -386,22 +379,34 @@ const CommentItem = ({
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={handleReply}
|
||||
disabled={isMutating || comment.resolved}
|
||||
>
|
||||
{t['com.affine.comment.reply']()}
|
||||
</MenuItem>
|
||||
{canReply ? (
|
||||
<MenuItem
|
||||
onClick={handleReply}
|
||||
disabled={isMutating || comment.resolved}
|
||||
>
|
||||
{t['com.affine.comment.reply']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<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>
|
||||
{canEdit ? (
|
||||
<MenuItem
|
||||
onClick={handleStartEdit}
|
||||
disabled={isMutating || comment.resolved}
|
||||
>
|
||||
{t['Edit']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={isMutating}
|
||||
style={{ color: 'var(--affine-error-color)' }}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -412,31 +417,57 @@ const CommentItem = ({
|
||||
/>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className={styles.previewContainer}>{comment.content.preview}</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}
|
||||
/>
|
||||
{renderedReplies}
|
||||
{isEditing && editingDoc ? (
|
||||
<div className={styles.commentInputContainer}>
|
||||
<div className={styles.userContainer}>
|
||||
<Avatar url={account?.avatar} size={24} />
|
||||
</div>
|
||||
<CommentEditor
|
||||
doc={editingDoc}
|
||||
autoFocus
|
||||
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ReadonlyCommentRenderer
|
||||
avatarUrl={comment.user.avatarUrl}
|
||||
name={comment.user.name}
|
||||
time={comment.createdAt}
|
||||
snapshot={comment.content.snapshot}
|
||||
/>
|
||||
)}
|
||||
{comment.replies && comment.replies.length > 0 && (
|
||||
<ReplyList
|
||||
replies={comment.replies}
|
||||
parentComment={comment}
|
||||
entity={entity}
|
||||
replyEditor={replyEditor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{highlighting &&
|
||||
{!editingDraft &&
|
||||
highlighting &&
|
||||
isReplyingToThisComment &&
|
||||
pendingReply &&
|
||||
account &&
|
||||
!comment.resolved && (
|
||||
!comment.resolved &&
|
||||
canCreateComment && (
|
||||
<div className={styles.commentInputContainer}>
|
||||
<div className={styles.userContainer}>
|
||||
<Avatar url={account.avatar} size={24} />
|
||||
</div>
|
||||
<CommentEditor
|
||||
ref={setReplyEditor}
|
||||
autoFocus
|
||||
doc={pendingReply.doc}
|
||||
onCommit={isMutating ? undefined : handleCommitReply}
|
||||
onCommit={
|
||||
isMutating || !canCreateComment ? undefined : handleCommitReply
|
||||
}
|
||||
onCancel={isMutating ? undefined : handleCancelReply}
|
||||
/>
|
||||
</div>
|
||||
@@ -508,7 +539,7 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
|
||||
if (filterState.onlyCurrentMode) {
|
||||
filteredComments = filteredComments.filter(comment => {
|
||||
return (
|
||||
!comment.content.mode || !docMode || comment.content.mode === docMode
|
||||
!comment.content?.mode || !docMode || comment.content.mode === docMode
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -568,6 +599,9 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
||||
const pendingPreview = newPendingComment?.preview;
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
|
||||
const docId = entity.props.docId;
|
||||
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||
|
||||
const handleCommit = useAsyncCallback(async () => {
|
||||
if (!newPendingComment?.id) return;
|
||||
setIsMutating(true);
|
||||
@@ -587,7 +621,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
||||
const session = useService(AuthService).session;
|
||||
const account = useLiveData(session.account$);
|
||||
|
||||
if (!newPendingComment || !account) {
|
||||
if (!newPendingComment || !account || !canCreateComment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -603,7 +637,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
||||
<CommentEditor
|
||||
autoFocus
|
||||
doc={newPendingComment.doc}
|
||||
onCommit={isMutating ? undefined : handleCommit}
|
||||
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
|
||||
onCancel={isMutating ? undefined : handleCancel}
|
||||
/>
|
||||
</div>
|
||||
@@ -611,6 +645,253 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface ReplyItemProps {
|
||||
reply: DocCommentReply;
|
||||
parentComment: DocComment;
|
||||
entity: DocCommentEntity;
|
||||
replyEditor: CommentEditorRef | null;
|
||||
}
|
||||
|
||||
const ReplyItem = ({
|
||||
reply,
|
||||
parentComment,
|
||||
entity,
|
||||
replyEditor,
|
||||
}: ReplyItemProps) => {
|
||||
const t = useI18n();
|
||||
const session = useService(AuthService).session;
|
||||
const account = useLiveData(session.account$);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const editingDraft = useLiveData(entity.editingDraft$);
|
||||
const isEditingThisReply =
|
||||
editingDraft?.type === 'reply' && editingDraft.id === reply.id;
|
||||
const editingDoc = isEditingThisReply ? editingDraft.doc : undefined;
|
||||
|
||||
const docId = entity.props.docId;
|
||||
const canCreateComment = useGuard('Doc_Comments_Create', docId);
|
||||
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
|
||||
|
||||
const handleStartEdit = useAsyncCallback(async () => {
|
||||
if (parentComment.resolved || !reply.content) return;
|
||||
await entity.startEdit(reply.id, 'reply', reply.content.snapshot);
|
||||
}, [entity, parentComment.resolved, reply.id, reply.content]);
|
||||
|
||||
const handleCommitEdit = useAsyncCallback(async () => {
|
||||
setIsMutating(true);
|
||||
try {
|
||||
await entity.commitEditing();
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
const handleCancelEdit = useCallback(
|
||||
() => entity.dismissDraftEditing(),
|
||||
[entity]
|
||||
);
|
||||
|
||||
const handleDelete = useAsyncCallback(async () => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.comment.reply.delete.confirm.title'](),
|
||||
description: t['com.affine.comment.reply.delete.confirm.description'](),
|
||||
confirmText: t['Delete'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
setIsMutating(true);
|
||||
try {
|
||||
await entity.deleteReply(reply.id);
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [openConfirmModal, t, entity, reply.id]);
|
||||
|
||||
const handleReply = useAsyncCallback(async () => {
|
||||
if (parentComment.resolved) return;
|
||||
await entity.addReply(parentComment.id, reply);
|
||||
entity.highlightComment(parentComment.id);
|
||||
if (replyEditor) {
|
||||
// todo: find out why we need to wait for 100ms
|
||||
setTimeout(() => {
|
||||
replyEditor.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [entity, parentComment.id, parentComment.resolved, reply, replyEditor]);
|
||||
|
||||
const isMyReply = account && account.id === reply.user.id;
|
||||
const canReply = canCreateComment;
|
||||
const canEdit = isMyReply && canCreateComment;
|
||||
const canDelete =
|
||||
(isMyReply && canCreateComment) || (!isMyReply && canDeleteComment);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// invalid reply, should not happen
|
||||
if (!reply.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.replyItem} data-reply-id={reply.id}>
|
||||
<div
|
||||
className={styles.replyActions}
|
||||
data-menu-open={isMenuOpen}
|
||||
data-editing={isEditingThisReply}
|
||||
>
|
||||
<Menu
|
||||
rootOptions={{
|
||||
onOpenChange: setIsMenuOpen,
|
||||
open: isMenuOpen,
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
{canReply ? (
|
||||
<MenuItem
|
||||
onClick={handleReply}
|
||||
disabled={isMutating || parentComment.resolved}
|
||||
>
|
||||
{t['com.affine.comment.reply']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<MenuItem
|
||||
onClick={handleStartEdit}
|
||||
disabled={isMutating || parentComment.resolved}
|
||||
>
|
||||
{t['Edit']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
style={{ color: 'var(--affine-error-color)' }}
|
||||
disabled={isMutating}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
icon={<MoreHorizontalIcon />}
|
||||
variant="solid"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{isEditingThisReply && editingDoc ? (
|
||||
<div className={styles.commentInputContainer}>
|
||||
<div className={styles.userContainer}>
|
||||
<Avatar url={account?.avatar} size={24} />
|
||||
</div>
|
||||
<CommentEditor
|
||||
doc={editingDoc}
|
||||
autoFocus
|
||||
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ReadonlyCommentRenderer
|
||||
avatarUrl={reply.user.avatarUrl}
|
||||
name={reply.user.name}
|
||||
time={reply.createdAt}
|
||||
snapshot={reply.content.snapshot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ReplyListProps {
|
||||
replies: DocCommentReply[];
|
||||
parentComment: DocComment;
|
||||
entity: DocCommentEntity;
|
||||
replyEditor: CommentEditorRef | null;
|
||||
}
|
||||
|
||||
const ReplyList = ({
|
||||
replies,
|
||||
parentComment,
|
||||
entity,
|
||||
replyEditor,
|
||||
}: ReplyListProps) => {
|
||||
const t = useI18n();
|
||||
// When the comment item is rendered the first time, the replies will be collapsed by default
|
||||
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
|
||||
// will be shown
|
||||
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
|
||||
const [collapsed, setCollapsed] = useState((replies.length ?? 0) > 4);
|
||||
|
||||
const renderedReplies = useMemo(() => {
|
||||
// Sort replies ascending by createdAt
|
||||
const sortedReplies =
|
||||
replies.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
|
||||
if (sortedReplies.length === 0) return null;
|
||||
|
||||
// If not collapsed or replies are fewer than threshold, render all
|
||||
if (!collapsed || sortedReplies.length <= 4) {
|
||||
return sortedReplies.map(reply => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
parentComment={parentComment}
|
||||
entity={entity}
|
||||
replyEditor={replyEditor}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
// Collapsed state: first reply + collapsed indicator + last two replies
|
||||
const firstReply = sortedReplies[0];
|
||||
const tailReplies = sortedReplies.slice(-2);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReplyItem
|
||||
key={firstReply.id}
|
||||
reply={firstReply}
|
||||
parentComment={parentComment}
|
||||
entity={entity}
|
||||
replyEditor={replyEditor}
|
||||
/>
|
||||
<div
|
||||
className={styles.collapsedReplies}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.collapsedRepliesTitle}>
|
||||
{t['com.affine.comment.reply.show-more']({
|
||||
count: (sortedReplies.length - 4).toString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{tailReplies.map(reply => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
parentComment={parentComment}
|
||||
entity={entity}
|
||||
replyEditor={replyEditor}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}, [collapsed, replies, t, entity, parentComment, replyEditor]);
|
||||
|
||||
return <div className={styles.repliesContainer}>{renderedReplies}</div>;
|
||||
};
|
||||
|
||||
const useCommentEntity = (docId: string | undefined) => {
|
||||
const docCommentManager = useService(DocCommentManagerService);
|
||||
const commentPanelService = useService(CommentPanelService);
|
||||
@@ -652,12 +933,14 @@ export const CommentSidebar = () => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
entity?.highlightComment(null);
|
||||
entity?.dismissDraftEditing();
|
||||
}
|
||||
};
|
||||
const handleContainerClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('[data-comment-id]')) {
|
||||
entity?.highlightComment(null);
|
||||
entity?.dismissDraftEditing();
|
||||
}
|
||||
// if creating a new comment, dismiss the comment input
|
||||
if (
|
||||
|
||||
@@ -151,6 +151,9 @@ export const commentActions = style({
|
||||
'&[data-menu-open="true"]': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-editing="true"]': {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,6 +162,7 @@ export const readonlyCommentContainer = style({
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
paddingLeft: '8px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const userContainer = style({
|
||||
@@ -210,3 +214,32 @@ export const collapsedRepliesTitle = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const replyItem = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const replyActions = style({
|
||||
display: 'flex',
|
||||
opacity: 0,
|
||||
gap: '8px',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
selectors: {
|
||||
[`${replyItem}:hover &`]: {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
'&[data-menu-open="true"]': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-editing="true"]': {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
|
||||
|
||||
const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||
id: comment.id,
|
||||
content: comment.content as DocCommentContent,
|
||||
content: comment.content ? (comment.content as DocCommentContent) : undefined,
|
||||
resolved: comment.resolved,
|
||||
createdAt: new Date(comment.createdAt).getTime(),
|
||||
updatedAt: new Date(comment.updatedAt).getTime(),
|
||||
@@ -60,7 +60,9 @@ const normalizeComment = (comment: GQLCommentType): DocComment => ({
|
||||
name: '',
|
||||
avatarUrl: '',
|
||||
},
|
||||
mentions: findMentions(comment.content.snapshot.blocks),
|
||||
mentions: comment.content
|
||||
? findMentions(comment.content.snapshot.blocks)
|
||||
: [],
|
||||
replies: comment.replies?.map(normalizeReply) ?? [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type CommentChangeAction, DocMode } from '@affine/graphql';
|
||||
import type { BaseSelection } from '@blocksuite/affine/store';
|
||||
import type {
|
||||
BaseSelection,
|
||||
DocSnapshot,
|
||||
Store,
|
||||
} from '@blocksuite/affine/store';
|
||||
import {
|
||||
effect,
|
||||
Entity,
|
||||
@@ -63,6 +67,13 @@ export class DocCommentEntity extends Entity<{
|
||||
// Only one pending reply at a time
|
||||
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||
|
||||
// Draft state for editing existing comment or reply (only one at a time)
|
||||
readonly editingDraft$ = new LiveData<{
|
||||
id: CommentId;
|
||||
type: 'comment' | 'reply';
|
||||
doc: Store;
|
||||
} | null>(null);
|
||||
|
||||
private readonly commentAdded$ = new Subject<{
|
||||
id: CommentId;
|
||||
selections: BaseSelection[];
|
||||
@@ -78,13 +89,15 @@ export class DocCommentEntity extends Entity<{
|
||||
selections?: BaseSelection[],
|
||||
preview?: string
|
||||
): Promise<string> {
|
||||
// todo: may need to properly bind the doc to the editor
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
// check if there is a pending comment, reuse it
|
||||
let pendingComment = this.pendingComment$.value;
|
||||
const doc =
|
||||
pendingComment?.doc ?? (await this.snapshotHelper.createStore());
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingComment: PendingComment = {
|
||||
pendingComment = {
|
||||
id,
|
||||
doc,
|
||||
preview,
|
||||
@@ -96,18 +109,35 @@ export class DocCommentEntity extends Entity<{
|
||||
return id;
|
||||
}
|
||||
|
||||
async addReply(commentId: string): Promise<string> {
|
||||
const doc = await this.snapshotHelper.createStore();
|
||||
/**
|
||||
* Add a reply to a comment.
|
||||
* If reply is provided, the content should @mention the user who is replying to.
|
||||
*/
|
||||
async addReply(commentId: string, reply?: DocCommentReply): Promise<string> {
|
||||
// check if there is a pending reply, reuse it
|
||||
let pendingReply = this.pendingReply$.value;
|
||||
const doc = pendingReply?.doc ?? (await this.snapshotHelper.createStore());
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc');
|
||||
}
|
||||
const mention: string | undefined = reply?.user.id;
|
||||
if (mention) {
|
||||
// insert mention at the end of the paragraph
|
||||
const paragraph = doc.getModelsByFlavour('affine:paragraph').at(-1);
|
||||
if (paragraph) {
|
||||
paragraph.text?.insert(' ', paragraph.text.length, {
|
||||
mention: {
|
||||
member: mention,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = nanoid();
|
||||
const pendingReply: PendingComment = {
|
||||
pendingReply = {
|
||||
id,
|
||||
doc,
|
||||
commentId,
|
||||
};
|
||||
|
||||
// Replace any existing pending reply (only one at a time)
|
||||
this.pendingReply$.setValue(pendingReply);
|
||||
return id;
|
||||
@@ -121,6 +151,47 @@ export class DocCommentEntity extends Entity<{
|
||||
this.pendingReply$.setValue(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start editing an existing comment or reply.
|
||||
* This will dismiss any previous editing draft so that only one can exist.
|
||||
*/
|
||||
async startEdit(
|
||||
id: CommentId,
|
||||
type: 'comment' | 'reply',
|
||||
snapshot: DocSnapshot
|
||||
): Promise<void> {
|
||||
const doc = await this.snapshotHelper.createStore(snapshot);
|
||||
if (!doc) {
|
||||
throw new Error('Failed to create doc for editing');
|
||||
}
|
||||
this.editingDraft$.setValue({ id, type, doc });
|
||||
}
|
||||
|
||||
/** Commit current editing draft (if any) */
|
||||
async commitEditing(): Promise<void> {
|
||||
const draft = this.editingDraft$.value;
|
||||
if (!draft) return;
|
||||
|
||||
const snapshot = this.snapshotHelper.getSnapshot(draft.doc);
|
||||
if (!snapshot) {
|
||||
throw new Error('Failed to get snapshot');
|
||||
}
|
||||
|
||||
if (draft.type === 'comment') {
|
||||
await this.updateComment(draft.id, { snapshot });
|
||||
} else {
|
||||
await this.updateReply(draft.id, { snapshot });
|
||||
}
|
||||
|
||||
this.editingDraft$.setValue(null);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
/** Dismiss current editing draft without saving */
|
||||
dismissDraftEditing(): void {
|
||||
this.editingDraft$.setValue(null);
|
||||
}
|
||||
|
||||
get docMode$() {
|
||||
return this.framework.get(GlobalContextService).globalContext.docMode.$;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export type CommentId = string;
|
||||
|
||||
export interface BaseComment {
|
||||
id: CommentId;
|
||||
content: DocCommentContent;
|
||||
content?: DocCommentContent;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
user: PublicUserType;
|
||||
|
||||
Reference in New Issue
Block a user