diff --git a/client/src/components/cards/CardModal/ProjectContent.jsx b/client/src/components/cards/CardModal/ProjectContent.jsx index 9acf5d42..61680c1c 100644 --- a/client/src/components/cards/CardModal/ProjectContent.jsx +++ b/client/src/components/cards/CardModal/ProjectContent.jsx @@ -3,6 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +import keyBy from 'lodash/keyBy'; import React, { useCallback, useContext, useMemo, useState } from 'react'; import classNames from 'classnames'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; @@ -16,6 +17,7 @@ import entryActions from '../../../entry-actions'; import { usePopupInClosableContext } from '../../../hooks'; import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch'; import { isUsableMarkdownElement } from '../../../utils/element-helpers'; +import { mentionTextToMarkup } from '../../../utils/mentions'; import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { CardTypeIcons } from '../../../constants/Icons'; import { ClosableContext } from '../../../contexts'; @@ -50,6 +52,7 @@ const ProjectContent = React.memo(() => { const card = useSelector(selectors.selectCurrentCard); const board = useSelector(selectors.selectCurrentBoard); + const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard); const userIds = useSelector(selectors.selectUserIdsForCurrentCard); const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard); const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard); @@ -142,6 +145,15 @@ const ProjectContent = React.memo(() => { }, shallowEqual); const dispatch = useDispatch(); + const userByUsername = useMemo( + () => + keyBy( + boardMemberships.flatMap(({ user }) => (user.username ? user : [])), + ({ username }) => username.toLowerCase(), + ), + [boardMemberships], + ); + const [t] = useTranslation(); const [descriptionDraft, setDescriptionDraft] = useState(null); const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false); @@ -169,11 +181,11 @@ const ProjectContent = React.memo(() => { (description) => { dispatch( entryActions.updateCurrentCard({ - description, + description: description && mentionTextToMarkup(description, userByUsername), }), ); }, - [dispatch], + [dispatch, userByUsername], ); const handleDueCompletionChange = useCallback(() => { diff --git a/client/src/components/cards/CardModal/StoryContent.jsx b/client/src/components/cards/CardModal/StoryContent.jsx index 5e6b7b60..0577f48f 100644 --- a/client/src/components/cards/CardModal/StoryContent.jsx +++ b/client/src/components/cards/CardModal/StoryContent.jsx @@ -3,6 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +import keyBy from 'lodash/keyBy'; import React, { useCallback, useContext, useMemo, useState } from 'react'; import classNames from 'classnames'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; @@ -16,6 +17,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { usePopupInClosableContext } from '../../../hooks'; import { isUsableMarkdownElement } from '../../../utils/element-helpers'; +import { mentionTextToMarkup } from '../../../utils/mentions'; import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { CardTypeIcons } from '../../../constants/Icons'; import { ClosableContext } from '../../../contexts'; @@ -46,6 +48,7 @@ const StoryContent = React.memo(() => { const card = useSelector(selectors.selectCurrentCard); const board = useSelector(selectors.selectCurrentBoard); + const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard); const userIds = useSelector(selectors.selectUserIdsForCurrentCard); const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard); const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard); @@ -137,6 +140,15 @@ const StoryContent = React.memo(() => { }, shallowEqual); const dispatch = useDispatch(); + const userByUsername = useMemo( + () => + keyBy( + boardMemberships.flatMap(({ user }) => (user.username ? user : [])), + ({ username }) => username.toLowerCase(), + ), + [boardMemberships], + ); + const [t] = useTranslation(); const [descriptionDraft, setDescriptionDraft] = useState(null); const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false); @@ -164,11 +176,11 @@ const StoryContent = React.memo(() => { (description) => { dispatch( entryActions.updateCurrentCard({ - description, + description: description && mentionTextToMarkup(description, userByUsername), }), ); }, - [dispatch], + [dispatch, userByUsername], ); const handleRestoreClick = useCallback(() => { diff --git a/client/src/components/comments/Comments/Add.jsx b/client/src/components/comments/Comments/Add.jsx index c40768aa..00d9b47b 100755 --- a/client/src/components/comments/Comments/Add.jsx +++ b/client/src/components/comments/Comments/Add.jsx @@ -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 (
@@ -188,4 +202,14 @@ const Add = React.memo(() => { ); }); +Add.propTypes = { + initialText: PropTypes.string, + onInitialTextConsumed: PropTypes.func, +}; + +Add.defaultProps = { + initialText: undefined, + onInitialTextConsumed: () => {}, +}; + export default Add; diff --git a/client/src/components/comments/Comments/Comments.jsx b/client/src/components/comments/Comments/Comments.jsx index 6ee36f24..f64f6708 100755 --- a/client/src/components/comments/Comments/Comments.jsx +++ b/client/src/components/comments/Comments/Comments.jsx @@ -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 && } + {cadAdd && }
{commentIds.map((commentId) => ( - + ))}
diff --git a/client/src/components/comments/Comments/Item.jsx b/client/src/components/comments/Comments/Item.jsx index 56f32139..bd656289 100755 --- a/client/src/components/comments/Comments/Item.jsx +++ b/client/src/components/comments/Comments/Item.jsx @@ -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 }) => { - {(canEdit || canDelete) && ( + {(canReply || canEdit || canDelete) && ( + {canReply && user.username && ( + + )} {canEdit && ( { Item.propTypes = { id: PropTypes.string.isRequired, + canReply: PropTypes.bool, + onReply: PropTypes.func, +}; + +Item.defaultProps = { + canReply: false, + onReply: () => {}, }; export default Item; diff --git a/client/src/components/common/EditMarkdown/EditMarkdown.jsx b/client/src/components/common/EditMarkdown/EditMarkdown.jsx index f5028dfd..c4c586eb 100755 --- a/client/src/components/common/EditMarkdown/EditMarkdown.jsx +++ b/client/src/components/common/EditMarkdown/EditMarkdown.jsx @@ -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);