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