diff --git a/client/patches/react-mentions+4.4.10.patch b/client/patches/react-mentions+4.4.10.patch new file mode 100644 index 00000000..304fe3e4 --- /dev/null +++ b/client/patches/react-mentions+4.4.10.patch @@ -0,0 +1,24 @@ +diff --git a/node_modules/react-mentions/dist/react-mentions.esm.js b/node_modules/react-mentions/dist/react-mentions.esm.js +index 2efebba..b244446 100644 +--- a/node_modules/react-mentions/dist/react-mentions.esm.js ++++ b/node_modules/react-mentions/dist/react-mentions.esm.js +@@ -1426,7 +1426,7 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) { + + var mentions = getMentions(newValue, config); + +- if (ev.nativeEvent.isComposing && selectionStart === selectionEnd) { ++ if ((ev.nativeEvent.isComposing || newValue.length < value.length) && selectionStart === selectionEnd) { + _this.updateMentionsQueries(_this.inputElement.value, selectionStart); + } // Propagate change + // let handleChange = this.getOnChange(this.props) || emptyFunction; +@@ -1454,7 +1454,9 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) { + var el = _this.inputElement; + + if (ev.target.selectionStart === ev.target.selectionEnd) { +- _this.updateMentionsQueries(el.value, ev.target.selectionStart); ++ requestAnimationFrame(function () { ++ _this.updateMentionsQueries(el.value, ev.target.selectionStart); ++ }); + } else { + _this.clearSuggestions(); + } // sync highlighters scroll position diff --git a/client/src/components/comments/Comments/Add.jsx b/client/src/components/comments/Comments/Add.jsx index ae863eb9..015d5935 100755 --- a/client/src/components/comments/Comments/Add.jsx +++ b/client/src/components/comments/Comments/Add.jsx @@ -3,7 +3,8 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import React, { useCallback, useState, useRef } from 'react'; +import keyBy from 'lodash/keyBy'; +import React, { useCallback, useState, useRef, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Mention, MentionsInput } from 'react-mentions'; @@ -13,6 +14,7 @@ import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hook import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks'; +import { isUsernameChar, mentionTextToMarkup } from '../../../utils/mentions'; import { isModifierKeyPressed } from '../../../utils/event-helpers'; import UserAvatar from '../../users/UserAvatar'; @@ -36,10 +38,19 @@ const Add = React.memo(() => { const textInputRef = useRef(null); const [buttonRef, handleButtonRef] = useNestedRef(); + const userByUsername = useMemo( + () => + keyBy( + boardMemberships.flatMap(({ user }) => (user.username ? user : [])), + 'username', + ), + [boardMemberships], + ); + const submit = useCallback(() => { const cleanData = { ...data, - text: data.text.trim(), + text: mentionTextToMarkup(data.text.trim(), userByUsername), }; if (!cleanData.text) { @@ -50,7 +61,7 @@ const Add = React.memo(() => { dispatch(entryActions.createCommentInCurrentCard(cleanData)); setData(DEFAULT_DATA); selectTextField(); - }, [dispatch, data, setData, selectTextField]); + }, [dispatch, data, setData, selectTextField, userByUsername]); const handleEscape = useCallback(() => { if (textMentionsRef.current.isOpened()) { @@ -76,10 +87,10 @@ const Add = React.memo(() => { const handleFieldChange = useCallback( (_, text) => { setData({ - text, + text: !isUsernameChar(text.slice(-1)) ? mentionTextToMarkup(text, userByUsername) : text, }); }, - [setData], + [setData, userByUsername], ); const handleFieldKeyDown = useCallback( diff --git a/client/src/components/comments/Comments/Edit.jsx b/client/src/components/comments/Comments/Edit.jsx index 1e4e118e..94fc0300 100755 --- a/client/src/components/comments/Comments/Edit.jsx +++ b/client/src/components/comments/Comments/Edit.jsx @@ -4,6 +4,7 @@ */ import { dequal } from 'dequal'; +import { keyBy } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; @@ -15,6 +16,7 @@ import { useClickAwayListener } from '../../../lib/hooks'; import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useForm, useNestedRef } from '../../../hooks'; +import { isUsernameChar, mentionTextToMarkup } from '../../../utils/mentions'; import { focusEnd } from '../../../utils/element-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers'; import UserAvatar from '../../users/UserAvatar'; @@ -48,10 +50,19 @@ const Edit = React.memo(({ commentId, onClose }) => { const [submitButtonRef, handleSubmitButtonRef] = useNestedRef(); const [cancelButtonRef, handleCancelButtonRef] = useNestedRef(); + const userByUsername = useMemo( + () => + keyBy( + boardMemberships.flatMap(({ user }) => (user.username ? user : [])), + 'username', + ), + [boardMemberships], + ); + const submit = useCallback(() => { const cleanData = { ...data, - text: data.text.trim(), + text: mentionTextToMarkup(data.text.trim(), userByUsername), }; if (cleanData.text && !dequal(cleanData, defaultData)) { @@ -59,7 +70,7 @@ const Edit = React.memo(({ commentId, onClose }) => { } onClose(); - }, [commentId, onClose, dispatch, defaultData, data]); + }, [commentId, onClose, dispatch, defaultData, data, userByUsername]); const handleSubmit = useCallback(() => { submit(); @@ -68,10 +79,10 @@ const Edit = React.memo(({ commentId, onClose }) => { const handleFieldChange = useCallback( (_, text) => { setData({ - text, + text: !isUsernameChar(text.slice(-1)) ? mentionTextToMarkup(text, userByUsername) : text, }); }, - [setData], + [setData, userByUsername], ); const handleFieldKeyDown = useCallback( diff --git a/client/src/components/notifications/NotificationsStep/Item.jsx b/client/src/components/notifications/NotificationsStep/Item.jsx index e5896991..2b4fde7c 100644 --- a/client/src/components/notifications/NotificationsStep/Item.jsx +++ b/client/src/components/notifications/NotificationsStep/Item.jsx @@ -13,7 +13,7 @@ import { Button } from 'semantic-ui-react'; import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; -import { formatTextWithMentions } from '../../../utils/mentions'; +import { mentionMarkupToText } from '../../../utils/mentions'; import Paths from '../../../constants/Paths'; import { StaticUserIds } from '../../../constants/StaticUsers'; import { NotificationTypes } from '../../../constants/Enums'; @@ -84,7 +84,7 @@ const Item = React.memo(({ id, onClose }) => { break; } case NotificationTypes.COMMENT_CARD: { - const commentText = truncate(formatTextWithMentions(notification.data.text)); + const commentText = truncate(mentionMarkupToText(notification.data.text)); contentNode = ( { break; case NotificationTypes.MENTION_IN_COMMENT: { - const commentText = truncate(formatTextWithMentions(notification.data.text)); + const commentText = truncate(mentionMarkupToText(notification.data.text)); contentNode = ( text.replace(MENTION_NAME_REGEX, '@$1'); +const MENTION_TEXT_REGEX = new RegExp( + `(^|[^${USERNAME_CHAR_CLASS}])@([${USERNAME_CHAR_CLASS}]+)`, + 'gi', +); + +const MENTION_MARKUP_REGEX = /@\[(.*?)\]\((.*?)\)/g; + +export const mentionTextToMarkup = (text, userByUsername) => + text.replace(MENTION_TEXT_REGEX, (match, before, username) => { + const user = userByUsername[username.toLowerCase()]; + return user ? `${before}@[${user.username}](${user.id})` : match; + }); + +export const mentionMarkupToText = (markup) => + markup.replace(MENTION_MARKUP_REGEX, (_, username) => `@${username}`); + +export const isUsernameChar = (char) => USERNAME_CHAR_REGEX.test(char); diff --git a/server/api/helpers/comments/create-one.js b/server/api/helpers/comments/create-one.js index 6095e673..ffef0191 100644 --- a/server/api/helpers/comments/create-one.js +++ b/server/api/helpers/comments/create-one.js @@ -6,12 +6,12 @@ const escapeMarkdown = require('escape-markdown'); const escapeHtml = require('escape-html'); -const { extractMentionIds, formatTextWithMentions } = require('../../../utils/mentions'); +const { extractMentionIds, mentionMarkupToText } = require('../../../utils/mentions'); const buildAndSendNotifications = async (services, board, card, comment, actorUser, t) => { const markdownCardLink = `[${escapeMarkdown(card.name)}](${sails.config.custom.baseUrl}/cards/${card.id})`; const htmlCardLink = `${escapeHtml(card.name)}`; - const commentText = _.truncate(formatTextWithMentions(comment.text)); + const commentText = _.truncate(mentionMarkupToText(comment.text)); await sails.helpers.utils.sendNotifications(services, t('New Comment'), { text: `${t( @@ -101,10 +101,9 @@ module.exports = { if (mentionUserIds.length > 0) { const boardMemberUserIds = await sails.helpers.boards.getMemberUserIds(inputs.board.id); - mentionUserIds = _.difference( - _.intersection(mentionUserIds, boardMemberUserIds), + mentionUserIds = _.difference(_.intersection(mentionUserIds, boardMemberUserIds), [ comment.userId, - ); + ]); } const mentionUserIdsSet = new Set(mentionUserIds); diff --git a/server/api/helpers/notifications/create-one.js b/server/api/helpers/notifications/create-one.js index 04d2f873..c273e074 100644 --- a/server/api/helpers/notifications/create-one.js +++ b/server/api/helpers/notifications/create-one.js @@ -6,7 +6,7 @@ const escapeMarkdown = require('escape-markdown'); const escapeHtml = require('escape-html'); -const { formatTextWithMentions } = require('../../../utils/mentions'); +const { mentionMarkupToText } = require('../../../utils/mentions'); const buildTitle = (notification, t) => { switch (notification.type) { @@ -60,7 +60,7 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => { }; } case Notification.Types.COMMENT_CARD: { - const commentText = _.truncate(formatTextWithMentions(notification.data.text)); + const commentText = _.truncate(mentionMarkupToText(notification.data.text)); return { text: `${t( @@ -100,7 +100,7 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => { ), }; case Notification.Types.MENTION_IN_COMMENT: { - const commentText = _.truncate(formatTextWithMentions(notification.data.text)); + const commentText = _.truncate(mentionMarkupToText(notification.data.text)); return { text: `${t( diff --git a/server/utils/mentions.js b/server/utils/mentions.js index cab09dfb..a045ee75 100644 --- a/server/utils/mentions.js +++ b/server/utils/mentions.js @@ -4,19 +4,17 @@ */ const MENTION_ID_REGEX = /@\[.*?\]\((.*?)\)/g; -const MENTION_NAME_REGEX = /@\[(.*?)\]\(.*?\)/g; +const MENTION_USERNAME_REGEX = /@\[(.*?)\]\(.*?\)/g; const extractMentionIds = (text) => { const matches = [...text.matchAll(MENTION_ID_REGEX)]; return matches.map((match) => match[1]); }; -const formatTextWithMentions = (text) => text.replace(MENTION_NAME_REGEX, '@$1'); +const mentionMarkupToText = (markup) => + markup.replace(MENTION_USERNAME_REGEX, (_, username) => `@${username}`); module.exports = { - MENTION_ID_REGEX, - MENTION_NAME_REGEX, - extractMentionIds, - formatTextWithMentions, + mentionMarkupToText, };