1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

hook with backend

This commit is contained in:
Roman Zavarnitsyn 2025-05-30 00:50:30 +02:00
parent 3f67d9e8bb
commit 946dfea5dd
No known key found for this signature in database
GPG key ID: C00677B27F355C04
8 changed files with 104 additions and 15 deletions

View file

@ -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, { useCallback, useState } from 'react'; import React, { useCallback, useState, useRef } from 'react';
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';
@ -29,7 +29,8 @@ const Add = React.memo(() => {
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle(); const [selectTextFieldState, selectTextField] = useToggle();
const textFieldRef = React.createRef(); const textFieldRef = useRef(null);
const mentionsInputRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef(); const [buttonRef, handleButtonRef] = useNestedRef();
const mentionsInputStyle = { const mentionsInputStyle = {
@ -65,8 +66,12 @@ const Add = React.memo(() => {
}, [dispatch, data, setData, selectTextField, textFieldRef]); }, [dispatch, data, setData, selectTextField, textFieldRef]);
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
if (mentionsInputRef?.current?.isOpened()) {
mentionsInputRef?.current.clearSuggestions();
return;
}
setIsOpened(false); setIsOpened(false);
textFieldRef.current.blur(); textFieldRef.current?.blur();
}, [textFieldRef]); }, [textFieldRef]);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] = const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
@ -89,10 +94,7 @@ const Add = React.memo(() => {
[submit], [submit],
); );
const handleAwayClick = useCallback((event) => { const handleAwayClick = useCallback(() => {
if (event?.target?.closest?.('.mentions-input')) {
return;
}
setIsOpened(false); setIsOpened(false);
}, []); }, []);
@ -134,6 +136,9 @@ const Add = React.memo(() => {
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<div className={styles.field}> <div className={styles.field}>
<MentionsInput <MentionsInput
// eslint-disable-next-line react/jsx-props-no-spreading
{...clickAwayProps}
ref={mentionsInputRef}
inputRef={textFieldRef} inputRef={textFieldRef}
value={data.text} value={data.text}
placeholder={t('common.writeComment')} placeholder={t('common.writeComment')}
@ -142,8 +147,6 @@ const Add = React.memo(() => {
onFocus={handleFieldFocus} onFocus={handleFieldFocus}
onChange={handleFormFieldChange} onChange={handleFormFieldChange}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onMouseDown={clickAwayProps.onMouseDown}
onTouchStart={clickAwayProps.onTouchStart}
allowSpaceInQuery allowSpaceInQuery
singleLine={false} singleLine={false}
rows={isOpened ? 3 : 1} rows={isOpened ? 3 : 1}

View file

@ -21,6 +21,11 @@ import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss'; 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 Item = React.memo(({ id, onClose }) => {
const selectNotificationById = useMemo(() => selectors.makeSelectNotificationById(), []); const selectNotificationById = useMemo(() => selectors.makeSelectNotificationById(), []);
const selectCreatorUserById = useMemo(() => selectors.makeSelectUserById(), []); const selectCreatorUserById = useMemo(() => selectors.makeSelectUserById(), []);
@ -104,6 +109,29 @@ const Item = React.memo(({ id, onClose }) => {
break; break;
} }
case NotificationTypes.COMMENT_MENTION: {
const commentText = truncate(formatMentionText(notification.data.text));
contentNode = (
<Trans
i18nKey="common.userMentionedYouInCard"
values={{
user: creatorUserName,
comment: commentText,
card: cardName,
}}
>
<span className={styles.author}>{creatorUserName}</span>
{` mentioned you in `}
<Link to={Paths.CARDS.replace(':id', notification.cardId)} onClick={onClose}>
{cardName}
</Link>
{`: «${commentText}»`}
</Trans>
);
break;
}
case NotificationTypes.ADD_MEMBER_TO_CARD: case NotificationTypes.ADD_MEMBER_TO_CARD:
contentNode = ( contentNode = (
<Trans <Trans

View file

@ -99,6 +99,7 @@ export const ActivityTypes = {
export const NotificationTypes = { export const NotificationTypes = {
MOVE_CARD: 'moveCard', MOVE_CARD: 'moveCard',
COMMENT_CARD: 'commentCard', COMMENT_CARD: 'commentCard',
COMMENT_MENTION: 'commentMention',
ADD_MEMBER_TO_CARD: 'addMemberToCard', ADD_MEMBER_TO_CARD: 'addMemberToCard',
}; };

View file

@ -293,6 +293,7 @@ export default {
userJoinedThisCard: `<0>{{user}}</0> joined this card`, userJoinedThisCard: `<0>{{user}}</0> joined this card`,
userLeftNewCommentToCard: userLeftNewCommentToCard:
'<0>{{user}}</0> left a new comment «{{comment}}» to <2>{{card}}</2>', '<0>{{user}}</0> left a new comment «{{comment}}» to <2>{{card}}</2>',
userMentionedYouInCard: '<0>{{user}}</0> mentioned you in <2>{{card}}</2>: «{{comment}}»',
userLeftCard: '<0>{{user}}</0> left <2>{{card}}</2>', userLeftCard: '<0>{{user}}</0> left <2>{{card}}</2>',
userLeftThisCard: '<0>{{user}}</0> left this card', userLeftThisCard: '<0>{{user}}</0> left this card',
userMarkedTaskIncompleteOnCard: userMarkedTaskIncompleteOnCard:

View file

@ -6,6 +6,12 @@
const escapeMarkdown = require('escape-markdown'); const escapeMarkdown = require('escape-markdown');
const escapeHtml = require('escape-html'); 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 buildAndSendNotifications = async (services, board, card, comment, actorUser, t) => {
const markdownCardLink = `[${escapeMarkdown(card.name)}](${sails.config.custom.baseUrl}/cards/${card.id})`; const markdownCardLink = `[${escapeMarkdown(card.name)}](${sails.config.custom.baseUrl}/cards/${card.id})`;
const htmlCardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}}">${escapeHtml(card.name)}</a>`; const htmlCardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}}">${escapeHtml(card.name)}</a>`;
@ -101,7 +107,14 @@ module.exports = {
comment.userId, 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( await Promise.all(
notifiableUserIds.map((userId) => notifiableUserIds.map((userId) =>
@ -109,10 +122,13 @@ module.exports = {
values: { values: {
userId, userId,
comment, comment,
type: Notification.Types.COMMENT_CARD, type: mentionedUserIds.includes(userId)
? Notification.Types.COMMENT_MENTION
: Notification.Types.COMMENT_CARD,
data: { data: {
card: _.pick(values.card, ['name']), card: _.pick(values.card, ['name']),
text: comment.text, text: comment.text,
wasMentioned: mentionedUserIds.includes(userId),
}, },
creatorUser: values.user, creatorUser: values.user,
card: values.card, card: values.card,

View file

@ -12,6 +12,8 @@ const buildTitle = (notification, t) => {
return t('Card Moved'); return t('Card Moved');
case Notification.Types.COMMENT_CARD: case Notification.Types.COMMENT_CARD:
return t('New Comment'); return t('New Comment');
case Notification.Types.COMMENT_MENTION:
return t('You Were Mentioned in a Comment');
case Notification.Types.ADD_MEMBER_TO_CARD: case Notification.Types.ADD_MEMBER_TO_CARD:
return t('You Were Added to Card'); return t('You Were Added to Card');
default: default:
@ -79,6 +81,30 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => {
)}:\n\n<i>${escapeHtml(commentText)}</i>`, )}:\n\n<i>${escapeHtml(commentText)}</i>`,
}; };
} }
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<i>${escapeHtml(commentText)}</i>`,
};
}
case Notification.Types.ADD_MEMBER_TO_CARD: case Notification.Types.ADD_MEMBER_TO_CARD:
return { return {
text: t('%s added you to %s on %s', actorUser.name, card.name, board.name), 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, boardLink,
)}</p><p>${escapeHtml(notification.data.text)}</p>`; )}</p><p>${escapeHtml(notification.data.text)}</p>`;
break;
case Notification.Types.COMMENT_MENTION:
html = `<p>${t(
'%s mentioned you in %s on %s',
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p><p>${escapeHtml(notification.data.text)}</p>`;
break; break;
case Notification.Types.ADD_MEMBER_TO_CARD: case Notification.Types.ADD_MEMBER_TO_CARD:
html = `<p>${t( html = `<p>${t(
@ -186,9 +221,11 @@ module.exports = {
values.userId = values.user.id; 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; values.commentId = values.comment.id;
} else { } else {
values.actionId = values.action.id; values.actionId = values.action.id;
@ -217,7 +254,7 @@ module.exports = {
boards: [inputs.board], boards: [inputs.board],
lists: [inputs.list], lists: [inputs.list],
cards: [values.card], cards: [values.card],
...(isCommentCard ...(isCommentNotification
? { ? {
comments: [values.comment], comments: [values.comment],
} }

View file

@ -13,6 +13,7 @@
const Types = { const Types = {
MOVE_CARD: 'moveCard', MOVE_CARD: 'moveCard',
COMMENT_CARD: 'commentCard', COMMENT_CARD: 'commentCard',
COMMENT_MENTION: 'commentMention',
ADD_MEMBER_TO_CARD: 'addMemberToCard', ADD_MEMBER_TO_CARD: 'addMemberToCard',
}; };

View file

@ -2,6 +2,7 @@
"Card Created": "Card Created", "Card Created": "Card Created",
"Card Moved": "Card Moved", "Card Moved": "Card Moved",
"New Comment": "New Comment", "New Comment": "New Comment",
"You Were Mentioned in a Comment": "You Were Mentioned in a Comment",
"Test Title": "Test Title", "Test Title": "Test Title",
"This is a test text message!": "This is a test text message!", "This is a test text message!": "This is a test text message!",
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `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 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 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 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"
} }