From 6175bde86e82ef5228f683a59ee680f9c8ddb37e Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Mon, 7 Jul 2025 14:27:21 +0800 Subject: [PATCH] fix(core): comment mention filters (#13062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #13062** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Replies in comment threads are now collapsed when there are more than four, with an option to expand and view all replies. * Mentions within comments and replies are automatically detected and tracked. * "Show more replies" indicator is now localized for English users. * **Improvements** * Filtering for "only my replies" now includes replies where you are mentioned. * Enhanced focus behavior in the comment editor for improved usability. * Updated styling for active and collapsed reply states in comment threads. * **Bug Fixes** * Ensured consistent handling of mentions and reply associations in comment data. --- .../comment/comment-editor/index.tsx | 31 +++-- .../comment/comment-editor/style.css.ts | 2 +- .../src/components/comment/sidebar/index.tsx | 108 +++++++++++++++--- .../components/comment/sidebar/style.css.ts | 21 ++++ .../comment/entities/doc-comment-store.ts | 14 ++- .../modules/comment/entities/doc-comment.ts | 53 ++------- .../src/modules/comment/entities/utils.ts | 37 ++++++ .../core/src/modules/comment/types.ts | 6 +- packages/frontend/i18n/src/i18n.gen.ts | 6 + packages/frontend/i18n/src/resources/en.json | 1 + 10 files changed, 196 insertions(+), 83 deletions(-) create mode 100644 packages/frontend/core/src/modules/comment/entities/utils.ts diff --git a/packages/frontend/core/src/components/comment/comment-editor/index.tsx b/packages/frontend/core/src/components/comment/comment-editor/index.tsx index 66b154708..878bc28c3 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/index.tsx +++ b/packages/frontend/core/src/components/comment/comment-editor/index.tsx @@ -108,6 +108,15 @@ export const CommentEditor = forwardRef( [doc, 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) { @@ -119,25 +128,20 @@ export const CommentEditor = forwardRef( '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', }); - - // fixme: the following does not work - inlineEditor?.focusEnd(); + // Finally focus the inline editor + richText.focus(); + focusEditor(); }) .catch(console.error); } return () => { cancel = true; }; - }, [autoFocus, doc]); + }, [autoFocus, doc, focusEditor]); useEffect(() => { if (doc) { @@ -179,14 +183,9 @@ export const CommentEditor = forwardRef( const handleClickEditor = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if (editorRef.current) { - const lastChild = editorRef.current.std.store.root?.lastChild(); - if (lastChild) { - focusTextModel(editorRef.current.std, lastChild.id); - } - } + focusEditor(); }, - [editorRef] + [focusEditor] ); return ( diff --git a/packages/frontend/core/src/components/comment/comment-editor/style.css.ts b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts index 28e226b9a..137e7efcc 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/style.css.ts +++ b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts @@ -12,7 +12,7 @@ export const container = style({ borderRadius: 16, padding: '0 8px', }, - '&[data-readonly="false"]:focus-within': { + '&[data-readonly="false"]:is(:focus-within, :active)': { borderColor: cssVarV2('layer/insideBorder/primaryBorder'), boxShadow: cssVar('activeShadow'), }, diff --git a/packages/frontend/core/src/components/comment/sidebar/index.tsx b/packages/frontend/core/src/components/comment/sidebar/index.tsx index 9623a2374..64c5c1789 100644 --- a/packages/frontend/core/src/components/comment/sidebar/index.tsx +++ b/packages/frontend/core/src/components/comment/sidebar/index.tsx @@ -293,6 +293,74 @@ 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 + ); + + 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 => ( + + )); + } + + // Collapsed state: first reply + collapsed indicator + last two replies + const firstReply = sortedReplies[0]; + const tailReplies = sortedReplies.slice(-2); + + return ( + <> + {firstReply && ( + + )} +
{ + e.stopPropagation(); + setCollapsed(false); + }} + > +
+ {t['com.affine.comment.reply.show-more']({ + count: (sortedReplies.length - 4).toString(), + })} +
+
+ {tailReplies.map(reply => ( + + ))} + + ); + }, [collapsed, comment.replies, t]); + return (
- - {/* unlike comment, replies are sorted by createdAt in ascending order */} - {comment.replies - ?.toSorted((a, b) => a.createdAt - b.createdAt) - .map(reply => ( - - ))} + {renderedReplies}
{highlighting && @@ -421,13 +477,31 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => { } // Filter by only my replies and mentions - if (filterState.onlyMyReplies) { + if (filterState.onlyMyReplies && account) { filteredComments = filteredComments.filter(comment => { return ( - comment.user.id === account?.id || - comment.replies?.some(reply => reply.user.id === account?.id) + comment.user.id === account.id || + comment.mentions.includes(account.id) || + comment.replies?.some(reply => { + return ( + reply.user.id === account.id || + reply.mentions.includes(account.id) + ); + }) ); }); + + filteredComments = filteredComments.map(comment => { + return { + ...comment, + replies: comment.replies?.filter(reply => { + return ( + reply.user.id === account.id || + reply.mentions.includes(account.id) + ); + }), + }; + }); } // Filter by only current mode @@ -444,8 +518,8 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => { filterState.showResolvedComments, filterState.onlyMyReplies, filterState.onlyCurrentMode, - account?.id, docMode, + account, ]); const newPendingComment = useLiveData(entity.pendingComment$); diff --git a/packages/frontend/core/src/components/comment/sidebar/style.css.ts b/packages/frontend/core/src/components/comment/sidebar/style.css.ts index 610b7eec7..3975676dc 100644 --- a/packages/frontend/core/src/components/comment/sidebar/style.css.ts +++ b/packages/frontend/core/src/components/comment/sidebar/style.css.ts @@ -189,3 +189,24 @@ export const time = style({ color: cssVarV2('text/secondary'), fontWeight: '500', }); + +export const collapsedReplies = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + cursor: 'pointer', + height: '28px', + paddingLeft: '42px', + borderRadius: 8, + selectors: { + '&:hover': { + backgroundColor: cssVarV2('layer/background/hoverOverlay'), + }, + }, +}); + +export const collapsedRepliesTitle = style({ + color: cssVarV2('text/emphasis'), + fontSize: cssVar('fontXs'), + fontWeight: '500', +}); diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts index 29bd24735..32767fcc1 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment-store.ts @@ -23,6 +23,7 @@ import type { DocCommentListResult, DocCommentReply, } from '../types'; +import { findMentions } from './utils'; type GQLCommentType = ListCommentsQuery['workspace']['comments']['edges'][number]['node']; @@ -38,10 +39,12 @@ const normalizeUser = (user: GQLUserType) => ({ const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({ id: reply.id, + commentId: reply.commentId, content: reply.content as DocCommentContent, createdAt: new Date(reply.createdAt).getTime(), updatedAt: new Date(reply.updatedAt).getTime(), user: normalizeUser(reply.user), + mentions: findMentions(reply.content.snapshot.blocks), }); const normalizeComment = (comment: GQLCommentType): DocComment => ({ @@ -57,6 +60,7 @@ const normalizeComment = (comment: GQLCommentType): DocComment => ({ name: '', avatarUrl: '', }, + mentions: findMentions(comment.content.snapshot.blocks), replies: comment.replies?.map(normalizeReply) ?? [], }); @@ -172,13 +176,14 @@ export class DocCommentStore extends Entity<{ async createComment(commentInput: { content: DocCommentContent; - mentions?: string[]; }): Promise { const graphql = this.graphqlService; if (!graphql) { throw new Error('GraphQL service not found'); } + const mentions = findMentions(commentInput.content.snapshot.blocks); + const response = await graphql.gql({ query: createCommentMutation, variables: { @@ -188,7 +193,7 @@ export class DocCommentStore extends Entity<{ docMode: this.props.getDocMode(), docTitle: this.props.getDocTitle(), content: commentInput.content, - mentions: commentInput.mentions, + mentions, }, }, }); @@ -257,7 +262,6 @@ export class DocCommentStore extends Entity<{ commentId: string, replyInput: { content: DocCommentContent; - mentions?: string[]; } ): Promise { const graphql = this.graphqlService; @@ -265,6 +269,8 @@ export class DocCommentStore extends Entity<{ throw new Error('GraphQL service not found'); } + const mentions = findMentions(replyInput.content.snapshot.blocks); + const response = await graphql.gql({ query: createReplyMutation, variables: { @@ -273,7 +279,7 @@ export class DocCommentStore extends Entity<{ content: replyInput.content, docMode: this.props.getDocMode(), docTitle: this.props.getDocTitle(), - mentions: replyInput.mentions, + mentions: mentions, }, }, }); diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts index 73d084f4c..8c021e5f9 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -1,10 +1,5 @@ import { type CommentChangeAction, DocMode } from '@affine/graphql'; -import type { - BaseSelection, - BaseTextAttributes, - BlockSnapshot, - DeltaInsert, -} from '@blocksuite/affine/store'; +import type { BaseSelection } from '@blocksuite/affine/store'; import { effect, Entity, @@ -34,19 +29,13 @@ import type { DocCommentChangeListResult, DocCommentContent, DocCommentListResult, + DocCommentReply, PendingComment, } from '../types'; import { DocCommentStore } from './doc-comment-store'; type DisposeCallback = () => void; -const MentionAttribute = 'mention'; -type ExtendedTextAttributes = BaseTextAttributes & { - [MentionAttribute]: { - member: string; - }; -}; - export class DocCommentEntity extends Entity<{ docId: string; }> { @@ -136,31 +125,6 @@ export class DocCommentEntity extends Entity<{ return this.framework.get(GlobalContextService).globalContext.docMode.$; } - findMentions(snapshot: BlockSnapshot): string[] { - const mentionedUserIds = new Set(); - if ( - snapshot.props.type === 'text' && - snapshot.props.text && - 'delta' in (snapshot.props.text as any) - ) { - const delta = (snapshot.props.text as any) - .delta as DeltaInsert[]; - for (const op of delta) { - if (op.attributes?.[MentionAttribute]) { - mentionedUserIds.add(op.attributes[MentionAttribute].member); - } - } - } - - for (const block of snapshot.children) { - this.findMentions(block).forEach(userId => { - mentionedUserIds.add(userId); - }); - } - - return Array.from(mentionedUserIds); - } - async commitComment(id: string): Promise { const pendingComment = this.pendingComment$.value; if (!pendingComment || pendingComment.id !== id) { @@ -172,9 +136,7 @@ export class DocCommentEntity extends Entity<{ if (!snapshot) { throw new Error('Failed to get snapshot'); } - const mentions = this.findMentions(snapshot.blocks); const comment = await this.store.createComment({ - mentions: mentions, content: { snapshot, preview, @@ -207,9 +169,7 @@ export class DocCommentEntity extends Entity<{ throw new Error('Pending reply has no commentId'); } - const mentions = this.findMentions(snapshot.blocks); const reply = await this.store.createReply(pendingReply.commentId, { - mentions, content: { snapshot, }, @@ -434,7 +394,12 @@ export class DocCommentEntity extends Entity<{ if (commentId) { // This is a reply change - handle separately - this.handleReplyChange(currentComments, action, comment, commentId); + const reply = { + ...comment, + id: id, + commentId: commentId, + }; + this.handleReplyChange(currentComments, action, reply, commentId); commentsUpdated = true; } else { // This is a top-level comment change @@ -479,7 +444,7 @@ export class DocCommentEntity extends Entity<{ private handleReplyChange( currentComments: DocComment[], action: CommentChangeAction, - reply: DocComment, + reply: DocCommentReply, parentCommentId: string ): void { const parentIndex = currentComments.findIndex( diff --git a/packages/frontend/core/src/modules/comment/entities/utils.ts b/packages/frontend/core/src/modules/comment/entities/utils.ts new file mode 100644 index 000000000..14c46d3a9 --- /dev/null +++ b/packages/frontend/core/src/modules/comment/entities/utils.ts @@ -0,0 +1,37 @@ +import type { + BaseTextAttributes, + BlockSnapshot, + DeltaInsert, +} from '@blocksuite/affine/store'; + +const MentionAttribute = 'mention'; +type ExtendedTextAttributes = BaseTextAttributes & { + [MentionAttribute]: { + member: string; + }; +}; + +export function findMentions(snapshot: BlockSnapshot): string[] { + const mentionedUserIds = new Set(); + if ( + snapshot.props.type === 'text' && + snapshot.props.text && + 'delta' in (snapshot.props.text as any) + ) { + const delta = (snapshot.props.text as any) + .delta as DeltaInsert[]; + for (const op of delta) { + if (op.attributes?.[MentionAttribute]) { + mentionedUserIds.add(op.attributes[MentionAttribute].member); + } + } + } + + for (const block of snapshot.children) { + findMentions(block).forEach(userId => { + mentionedUserIds.add(userId); + }); + } + + return Array.from(mentionedUserIds); +} diff --git a/packages/frontend/core/src/modules/comment/types.ts b/packages/frontend/core/src/modules/comment/types.ts index 7d6b407a0..3844a7ea6 100644 --- a/packages/frontend/core/src/modules/comment/types.ts +++ b/packages/frontend/core/src/modules/comment/types.ts @@ -18,6 +18,7 @@ export interface BaseComment { export interface DocComment extends BaseComment { resolved: boolean; + mentions: string[]; replies?: DocCommentReply[]; } @@ -29,7 +30,10 @@ export type PendingComment = { commentId?: CommentId; // only for replies, points to the parent comment }; -export type DocCommentReply = BaseComment; +export interface DocCommentReply extends BaseComment { + commentId: CommentId; + mentions: string[]; +} export type DocCommentContent = { snapshot: DocSnapshot; // blocksuite snapshot diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 166fa208c..5c5312ea1 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8254,6 +8254,12 @@ export function useAFFiNEI18N(): { * `Delete this reply? This action cannot be undone.` */ ["com.affine.comment.reply.delete.confirm.description"](): string; + /** + * `Show {{count}} more replies` + */ + ["com.affine.comment.reply.show-more"](options: { + readonly count: string; + }): string; /** * `Show resolved comments` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index bfd57723a..60db1638e 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2071,6 +2071,7 @@ "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.reply.show-more": "Show {{count}} more replies", "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",