feat: add comment reply shortcut and description mentions

This commit is contained in:
lustsazeus-lab
2026-03-06 09:48:20 +08:00
parent 2685e5d5fc
commit f23ce6e025
4 changed files with 79 additions and 10 deletions
@@ -5,6 +5,7 @@
import keyBy from 'lodash/keyBy'; import keyBy from 'lodash/keyBy';
import React, { useCallback, useState, useRef, useMemo } from 'react'; import React, { useCallback, useState, useRef, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Mention, MentionsInput } from 'react-mentions'; import { Mention, MentionsInput } from 'react-mentions';
@@ -24,7 +25,7 @@ const DEFAULT_DATA = {
text: '', text: '',
}; };
const Add = React.memo(() => { const Add = React.memo(({ initialText, onInitialTextConsumed }) => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard); const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -138,6 +139,19 @@ const Add = React.memo(() => {
textInputRef.current.focus(); textInputRef.current.focus();
}, [selectTextFieldState]); }, [selectTextFieldState]);
useDidUpdate(() => {
if (!initialText) {
return;
}
setData({
text: initialText,
});
setIsOpened(true);
selectTextField();
onInitialTextConsumed();
}, [initialText, onInitialTextConsumed, selectTextField, setData]);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<div ref={textFieldRef} className={styles.field}> <div ref={textFieldRef} className={styles.field}>
@@ -188,4 +202,14 @@ const Add = React.memo(() => {
); );
}); });
Add.propTypes = {
initialText: PropTypes.string,
onInitialTextConsumed: PropTypes.func,
};
Add.defaultProps = {
initialText: undefined,
onInitialTextConsumed: () => {},
};
export default Add; export default Add;
@@ -3,7 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useMemo } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { Comment, Loader } from 'semantic-ui-react'; import { Comment, Loader } from 'semantic-ui-react';
@@ -45,6 +45,15 @@ const Comments = React.memo(() => {
}); });
const dispatch = useDispatch(); const dispatch = useDispatch();
const [replyText, setReplyText] = useState();
const handleReply = useCallback((username) => {
setReplyText(`@${username} `);
}, []);
const handleReplyTextConsumed = useCallback(() => {
setReplyText(undefined);
}, []);
const [inViewRef] = useInView({ const [inViewRef] = useInView({
threshold: 1, threshold: 1,
@@ -57,11 +66,11 @@ const Comments = React.memo(() => {
return ( return (
<> <>
{cadAdd && <Add />} {cadAdd && <Add initialText={replyText} onInitialTextConsumed={handleReplyTextConsumed} />}
<div className={styles.itemsWrapper}> <div className={styles.itemsWrapper}>
<Comment.Group className={styles.items}> <Comment.Group className={styles.items}>
{commentIds.map((commentId) => ( {commentIds.map((commentId) => (
<Item key={commentId} id={commentId} /> <Item key={commentId} id={commentId} canReply={cadAdd} onReply={handleReply} />
))} ))}
</Comment.Group> </Comment.Group>
</div> </div>
@@ -25,7 +25,7 @@ import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
const Item = React.memo(({ id }) => { const Item = React.memo(({ id, canReply, onReply }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []); const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []); const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -83,6 +83,12 @@ const Item = React.memo(({ id }) => {
setIsEditOpened(true); setIsEditOpened(true);
}, []); }, []);
const handleReplyClick = useCallback(() => {
if (user.username) {
onReply(user.username);
}
}, [onReply, user.username]);
const handleEditClose = useCallback(() => { const handleEditClose = useCallback(() => {
setIsEditOpened(false); setIsEditOpened(false);
}, []); }, []);
@@ -117,8 +123,18 @@ const Item = React.memo(({ id }) => {
<span className={styles.date}> <span className={styles.date}>
<TimeAgo date={comment.createdAt} /> <TimeAgo date={comment.createdAt} />
</span> </span>
{(canEdit || canDelete) && ( {(canReply || canEdit || canDelete) && (
<span className={styles.actions}> <span className={styles.actions}>
{canReply && user.username && (
<Comment.Action
as="button"
content={t('action.reply', {
defaultValue: 'Reply',
})}
disabled={!comment.isPersisted}
onClick={handleReplyClick}
/>
)}
{canEdit && ( {canEdit && (
<Comment.Action <Comment.Action
as="button" as="button"
@@ -153,6 +169,13 @@ const Item = React.memo(({ id }) => {
Item.propTypes = { Item.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
canReply: PropTypes.bool,
onReply: PropTypes.func,
};
Item.defaultProps = {
canReply: false,
onReply: () => {},
}; };
export default Item; export default Item;
@@ -3,8 +3,9 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import keyBy from 'lodash/keyBy';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
@@ -13,6 +14,7 @@ import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks'; import { useNestedRef } from '../../../hooks';
import { mentionMarkupToText, mentionTextToMarkup } from '../../../utils/mentions';
import MarkdownEditor from '../MarkdownEditor'; import MarkdownEditor from '../MarkdownEditor';
import styles from './EditMarkdown.module.scss'; import styles from './EditMarkdown.module.scss';
@@ -21,10 +23,20 @@ const MAX_LENGTH = 1048576;
const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => { const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => {
const defaultMode = useSelector((state) => selectors.selectCurrentUser(state).defaultEditorMode); const defaultMode = useSelector((state) => selectors.selectCurrentUser(state).defaultEditorMode);
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.map(({ user }) => user),
(user) => user.username.toLowerCase(),
),
[boardMemberships],
);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [value, setValue] = useState(() => draftValue || defaultValue || ''); const [value, setValue] = useState(() => mentionMarkupToText(draftValue || defaultValue || ''));
const fieldRef = useRef(null); const fieldRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef(); const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
@@ -44,14 +56,15 @@ const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }
const isExceeded = value.length > MAX_LENGTH; const isExceeded = value.length > MAX_LENGTH;
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanValue = value.trim() || null; const valueWithMarkup = mentionTextToMarkup(value, userByUsername);
const cleanValue = valueWithMarkup.trim() || null;
if (!isExceeded && cleanValue !== defaultValue) { if (!isExceeded && cleanValue !== defaultValue) {
onUpdate(cleanValue); onUpdate(cleanValue);
} }
onClose(isExceeded ? cleanValue : null); onClose(isExceeded ? cleanValue : null);
}, [onUpdate, onClose, defaultValue, value, isExceeded]); }, [onUpdate, onClose, defaultValue, value, isExceeded, userByUsername]);
const handleChange = useCallback((nextValue) => { const handleChange = useCallback((nextValue) => {
setValue(nextValue); setValue(nextValue);