feat: add comment reply shortcut and description mentions
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user