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 React, { useCallback, useState, useRef, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Mention, MentionsInput } from 'react-mentions';
@@ -24,7 +25,7 @@ const DEFAULT_DATA = {
text: '',
};
const Add = React.memo(() => {
const Add = React.memo(({ initialText, onInitialTextConsumed }) => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch();
@@ -138,6 +139,19 @@ const Add = React.memo(() => {
textInputRef.current.focus();
}, [selectTextFieldState]);
useDidUpdate(() => {
if (!initialText) {
return;
}
setData({
text: initialText,
});
setIsOpened(true);
selectTextField();
onInitialTextConsumed();
}, [initialText, onInitialTextConsumed, selectTextField, setData]);
return (
<Form onSubmit={handleSubmit}>
<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;
@@ -3,7 +3,7 @@
* 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 { useInView } from 'react-intersection-observer';
import { Comment, Loader } from 'semantic-ui-react';
@@ -45,6 +45,15 @@ const Comments = React.memo(() => {
});
const dispatch = useDispatch();
const [replyText, setReplyText] = useState();
const handleReply = useCallback((username) => {
setReplyText(`@${username} `);
}, []);
const handleReplyTextConsumed = useCallback(() => {
setReplyText(undefined);
}, []);
const [inViewRef] = useInView({
threshold: 1,
@@ -57,11 +66,11 @@ const Comments = React.memo(() => {
return (
<>
{cadAdd && <Add />}
{cadAdd && <Add initialText={replyText} onInitialTextConsumed={handleReplyTextConsumed} />}
<div className={styles.itemsWrapper}>
<Comment.Group className={styles.items}>
{commentIds.map((commentId) => (
<Item key={commentId} id={commentId} />
<Item key={commentId} id={commentId} canReply={cadAdd} onReply={handleReply} />
))}
</Comment.Group>
</div>
@@ -25,7 +25,7 @@ import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const Item = React.memo(({ id, canReply, onReply }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -83,6 +83,12 @@ const Item = React.memo(({ id }) => {
setIsEditOpened(true);
}, []);
const handleReplyClick = useCallback(() => {
if (user.username) {
onReply(user.username);
}
}, [onReply, user.username]);
const handleEditClose = useCallback(() => {
setIsEditOpened(false);
}, []);
@@ -117,8 +123,18 @@ const Item = React.memo(({ id }) => {
<span className={styles.date}>
<TimeAgo date={comment.createdAt} />
</span>
{(canEdit || canDelete) && (
{(canReply || canEdit || canDelete) && (
<span className={styles.actions}>
{canReply && user.username && (
<Comment.Action
as="button"
content={t('action.reply', {
defaultValue: 'Reply',
})}
disabled={!comment.isPersisted}
onClick={handleReplyClick}
/>
)}
{canEdit && (
<Comment.Action
as="button"
@@ -153,6 +169,13 @@ const Item = React.memo(({ id }) => {
Item.propTypes = {
id: PropTypes.string.isRequired,
canReply: PropTypes.bool,
onReply: PropTypes.func,
};
Item.defaultProps = {
canReply: false,
onReply: () => {},
};
export default Item;
@@ -3,8 +3,9 @@
* 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 keyBy from 'lodash/keyBy';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
@@ -13,6 +14,7 @@ import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import { mentionMarkupToText, mentionTextToMarkup } from '../../../utils/mentions';
import MarkdownEditor from '../MarkdownEditor';
import styles from './EditMarkdown.module.scss';
@@ -21,10 +23,20 @@ const MAX_LENGTH = 1048576;
const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => {
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 [t] = useTranslation();
const [value, setValue] = useState(() => draftValue || defaultValue || '');
const [value, setValue] = useState(() => mentionMarkupToText(draftValue || defaultValue || ''));
const fieldRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
@@ -44,14 +56,15 @@ const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }
const isExceeded = value.length > MAX_LENGTH;
const submit = useCallback(() => {
const cleanValue = value.trim() || null;
const valueWithMarkup = mentionTextToMarkup(value, userByUsername);
const cleanValue = valueWithMarkup.trim() || null;
if (!isExceeded && cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
onClose(isExceeded ? cleanValue : null);
}, [onUpdate, onClose, defaultValue, value, isExceeded]);
}, [onUpdate, onClose, defaultValue, value, isExceeded, userByUsername]);
const handleChange = useCallback((nextValue) => {
setValue(nextValue);