fix(core): comment mention filters (#13062)

#### PR Dependency Tree


* **PR #13062** 👈

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

* **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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Peng Xiao
2025-07-07 14:27:21 +08:00
committed by GitHub
parent 90b2b33dde
commit 6175bde86e
10 changed files with 196 additions and 83 deletions

View File

@@ -108,6 +108,15 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
[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<CommentEditorRef, CommentEditorProps>(
'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<CommentEditorRef, CommentEditorProps>(
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 (

View File

@@ -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'),
},

View File

@@ -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 => (
<ReadonlyCommentRenderer
key={reply.id}
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
));
}
// Collapsed state: first reply + collapsed indicator + last two replies
const firstReply = sortedReplies[0];
const tailReplies = sortedReplies.slice(-2);
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]);
return (
<div
onClick={handleClickPreview}
@@ -353,19 +421,7 @@ const CommentItem = ({
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}
/>
))}
{renderedReplies}
</div>
{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$);

View File

@@ -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',
});

View File

@@ -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<DocComment> {
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<DocCommentReply> {
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,
},
},
});

View File

@@ -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<string>();
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<ExtendedTextAttributes>[];
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<void> {
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(

View File

@@ -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<string>();
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<ExtendedTextAttributes>[];
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);
}

View File

@@ -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