From 946dfea5ddc41e44086bc4e0fbf07363a9793119 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 30 May 2025 00:50:30 +0200 Subject: [PATCH] hook with backend --- .../src/components/comments/Comments/Add.jsx | 21 +++++---- .../notifications/NotificationsStep/Item.jsx | 28 ++++++++++++ client/src/constants/Enums.js | 1 + client/src/locales/en-US/core.js | 1 + server/api/helpers/comments/create-one.js | 20 ++++++++- .../api/helpers/notifications/create-one.js | 43 +++++++++++++++++-- server/api/models/Notification.js | 1 + server/config/locales/en-US.json | 4 +- 8 files changed, 104 insertions(+), 15 deletions(-) diff --git a/client/src/components/comments/Comments/Add.jsx b/client/src/components/comments/Comments/Add.jsx index cc5f18a0..85923c14 100755 --- a/client/src/components/comments/Comments/Add.jsx +++ b/client/src/components/comments/Comments/Add.jsx @@ -3,7 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Button, Form } from 'semantic-ui-react'; @@ -29,7 +29,8 @@ const Add = React.memo(() => { const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); const [selectTextFieldState, selectTextField] = useToggle(); - const textFieldRef = React.createRef(); + const textFieldRef = useRef(null); + const mentionsInputRef = useRef(null); const [buttonRef, handleButtonRef] = useNestedRef(); const mentionsInputStyle = { @@ -65,8 +66,12 @@ const Add = React.memo(() => { }, [dispatch, data, setData, selectTextField, textFieldRef]); const handleEscape = useCallback(() => { + if (mentionsInputRef?.current?.isOpened()) { + mentionsInputRef?.current.clearSuggestions(); + return; + } setIsOpened(false); - textFieldRef.current.blur(); + textFieldRef.current?.blur(); }, [textFieldRef]); const [activateEscapeInterceptor, deactivateEscapeInterceptor] = @@ -89,10 +94,7 @@ const Add = React.memo(() => { [submit], ); - const handleAwayClick = useCallback((event) => { - if (event?.target?.closest?.('.mentions-input')) { - return; - } + const handleAwayClick = useCallback(() => { setIsOpened(false); }, []); @@ -134,6 +136,9 @@ const Add = React.memo(() => {
{ onFocus={handleFieldFocus} onChange={handleFormFieldChange} onKeyDown={handleFieldKeyDown} - onMouseDown={clickAwayProps.onMouseDown} - onTouchStart={clickAwayProps.onTouchStart} allowSpaceInQuery singleLine={false} rows={isOpened ? 3 : 1} diff --git a/client/src/components/notifications/NotificationsStep/Item.jsx b/client/src/components/notifications/NotificationsStep/Item.jsx index ac424ab2..b6c54647 100644 --- a/client/src/components/notifications/NotificationsStep/Item.jsx +++ b/client/src/components/notifications/NotificationsStep/Item.jsx @@ -21,6 +21,11 @@ import UserAvatar from '../../users/UserAvatar'; import styles from './Item.module.scss'; +const formatMentionText = (text) => { + // Replace @[username](userId) with @username + return text.replace(/@\[(.*?)\]\(.*?\)/g, '@$1'); +}; + const Item = React.memo(({ id, onClose }) => { const selectNotificationById = useMemo(() => selectors.makeSelectNotificationById(), []); const selectCreatorUserById = useMemo(() => selectors.makeSelectUserById(), []); @@ -104,6 +109,29 @@ const Item = React.memo(({ id, onClose }) => { break; } + case NotificationTypes.COMMENT_MENTION: { + const commentText = truncate(formatMentionText(notification.data.text)); + + contentNode = ( + + {creatorUserName} + {` mentioned you in `} + + {cardName} + + {`: «${commentText}»`} + + ); + + break; + } case NotificationTypes.ADD_MEMBER_TO_CARD: contentNode = ( {{user}} joined this card`, userLeftNewCommentToCard: '<0>{{user}} left a new comment «{{comment}}» to <2>{{card}}', + userMentionedYouInCard: '<0>{{user}} mentioned you in <2>{{card}}: «{{comment}}»', userLeftCard: '<0>{{user}} left <2>{{card}}', userLeftThisCard: '<0>{{user}} left this card', userMarkedTaskIncompleteOnCard: diff --git a/server/api/helpers/comments/create-one.js b/server/api/helpers/comments/create-one.js index 5091b80e..96278464 100644 --- a/server/api/helpers/comments/create-one.js +++ b/server/api/helpers/comments/create-one.js @@ -6,6 +6,12 @@ const escapeMarkdown = require('escape-markdown'); const escapeHtml = require('escape-html'); +const extractMentionedUserIds = (text) => { + const mentionRegex = /@\[.*?\]\((.*?)\)/g; + const matches = [...text.matchAll(mentionRegex)]; + return matches.map((match) => match[1]); +}; + 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)}`; @@ -101,7 +107,14 @@ module.exports = { comment.userId, ); - const notifiableUserIds = _.union(cardSubscriptionUserIds, boardSubscriptionUserIds); + const mentionedUserIds = extractMentionedUserIds(values.text); + + // Combine all user IDs, removing duplicates and the comment author + const notifiableUserIds = [ + ...cardSubscriptionUserIds, + ...boardSubscriptionUserIds, + ...mentionedUserIds, + ].filter((id) => id !== comment.userId); await Promise.all( notifiableUserIds.map((userId) => @@ -109,10 +122,13 @@ module.exports = { values: { userId, comment, - type: Notification.Types.COMMENT_CARD, + type: mentionedUserIds.includes(userId) + ? Notification.Types.COMMENT_MENTION + : Notification.Types.COMMENT_CARD, data: { card: _.pick(values.card, ['name']), text: comment.text, + wasMentioned: mentionedUserIds.includes(userId), }, creatorUser: values.user, card: values.card, diff --git a/server/api/helpers/notifications/create-one.js b/server/api/helpers/notifications/create-one.js index 70aedbb9..f6de485c 100644 --- a/server/api/helpers/notifications/create-one.js +++ b/server/api/helpers/notifications/create-one.js @@ -12,6 +12,8 @@ const buildTitle = (notification, t) => { return t('Card Moved'); case Notification.Types.COMMENT_CARD: return t('New Comment'); + case Notification.Types.COMMENT_MENTION: + return t('You Were Mentioned in a Comment'); case Notification.Types.ADD_MEMBER_TO_CARD: return t('You Were Added to Card'); default: @@ -79,6 +81,30 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => { )}:\n\n${escapeHtml(commentText)}`, }; } + case Notification.Types.COMMENT_MENTION: { + const commentText = _.truncate(notification.data.text); + + return { + text: `${t( + '%s mentioned you in %s on %s', + actorUser.name, + card.name, + board.name, + )}:\n${commentText}`, + markdown: `${t( + '%s mentioned you in %s on %s', + escapeMarkdown(actorUser.name), + markdownCardLink, + escapeMarkdown(board.name), + )}:\n\n*${escapeMarkdown(commentText)}*`, + html: `${t( + '%s mentioned you in %s on %s', + escapeHtml(actorUser.name), + htmlCardLink, + escapeHtml(board.name), + )}:\n\n${escapeHtml(commentText)}`, + }; + } case Notification.Types.ADD_MEMBER_TO_CARD: return { text: t('%s added you to %s on %s', actorUser.name, card.name, board.name), @@ -138,6 +164,15 @@ const buildAndSendEmail = async (board, card, notification, actorUser, notifiabl boardLink, )}

${escapeHtml(notification.data.text)}

`; + break; + case Notification.Types.COMMENT_MENTION: + html = `

${t( + '%s mentioned you in %s on %s', + escapeHtml(actorUser.name), + cardLink, + boardLink, + )}

${escapeHtml(notification.data.text)}

`; + break; case Notification.Types.ADD_MEMBER_TO_CARD: html = `

${t( @@ -186,9 +221,11 @@ module.exports = { values.userId = values.user.id; } - const isCommentCard = values.type === Notification.Types.COMMENT_CARD; + const isCommentNotification = + values.type === Notification.Types.COMMENT_CARD || + values.type === Notification.Types.COMMENT_MENTION; - if (isCommentCard) { + if (isCommentNotification) { values.commentId = values.comment.id; } else { values.actionId = values.action.id; @@ -217,7 +254,7 @@ module.exports = { boards: [inputs.board], lists: [inputs.list], cards: [values.card], - ...(isCommentCard + ...(isCommentNotification ? { comments: [values.comment], } diff --git a/server/api/models/Notification.js b/server/api/models/Notification.js index 2fdfde03..2b37966a 100755 --- a/server/api/models/Notification.js +++ b/server/api/models/Notification.js @@ -13,6 +13,7 @@ const Types = { MOVE_CARD: 'moveCard', COMMENT_CARD: 'commentCard', + COMMENT_MENTION: 'commentMention', ADD_MEMBER_TO_CARD: 'addMemberToCard', }; diff --git a/server/config/locales/en-US.json b/server/config/locales/en-US.json index f9b9b376..f0bedb9c 100644 --- a/server/config/locales/en-US.json +++ b/server/config/locales/en-US.json @@ -2,6 +2,7 @@ "Card Created": "Card Created", "Card Moved": "Card Moved", "New Comment": "New Comment", + "You Were Mentioned in a Comment": "You Were Mentioned in a Comment", "Test Title": "Test Title", "This is a test text message!": "This is a test text message!", "This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!", @@ -10,5 +11,6 @@ "%s added you to %s on %s": "%s added you to %s on %s", "%s created %s in %s on %s": "%s created %s in %s on %s", "%s left a new comment to %s on %s": "%s left a new comment to %s on %s", - "%s moved %s from %s to %s on %s": "%s moved %s from %s to %s on %s" + "%s moved %s from %s to %s on %s": "%s moved %s from %s to %s on %s", + "%s mentioned you in %s on %s": "%s mentioned you in %s on %s" }