mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
feat: Add ability to mention users in comments (#1162)
This commit is contained in:
parent
eb2a3a2875
commit
c0b0436851
20 changed files with 357 additions and 42 deletions
45
client/package-lock.json
generated
45
client/package-lock.json
generated
|
@ -69,6 +69,7 @@
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-input-mask": "^2.0.4",
|
"react-input-mask": "^2.0.4",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
|
"react-mentions": "^4.4.10",
|
||||||
"react-photoswipe-gallery": "^2.2.7",
|
"react-photoswipe-gallery": "^2.2.7",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-router-dom": "^6.30.0",
|
"react-router-dom": "^6.30.0",
|
||||||
|
@ -12296,6 +12297,31 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-mentions": {
|
||||||
|
"version": "4.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-mentions/-/react-mentions-4.4.10.tgz",
|
||||||
|
"integrity": "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "7.4.5",
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"prop-types": "^15.5.8",
|
||||||
|
"substyle": "^9.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.3",
|
||||||
|
"react-dom": ">=16.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-mentions/node_modules/@babel/runtime": {
|
||||||
|
"version": "7.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
|
||||||
|
"integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": "^0.13.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-onclickoutside": {
|
"node_modules/react-onclickoutside": {
|
||||||
"version": "6.13.2",
|
"version": "6.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.2.tgz",
|
||||||
|
@ -12677,6 +12703,12 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/regexp-match-indices": {
|
"node_modules/regexp-match-indices": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz",
|
||||||
|
@ -14183,6 +14215,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="
|
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/substyle": {
|
||||||
|
"version": "9.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/substyle/-/substyle-9.4.1.tgz",
|
||||||
|
"integrity": "sha512-VOngeq/W1/UkxiGzeqVvDbGDPM8XgUyJVWjrqeh+GgKqspEPiLYndK+XRcsKUHM5Muz/++1ctJ1QCF/OqRiKWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.3.4",
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
|
|
@ -140,6 +140,7 @@
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-input-mask": "^2.0.4",
|
"react-input-mask": "^2.0.4",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
|
"react-mentions": "^4.4.10",
|
||||||
"react-photoswipe-gallery": "^2.2.7",
|
"react-photoswipe-gallery": "^2.2.7",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-router-dom": "^6.30.0",
|
"react-router-dom": "^6.30.0",
|
||||||
|
|
|
@ -3,16 +3,18 @@
|
||||||
* 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 } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import { Mention, MentionsInput } from 'react-mentions';
|
||||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
import { Button, Form } from 'semantic-ui-react';
|
||||||
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
|
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||||
|
|
||||||
|
import selectors from '../../../selectors';
|
||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
|
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
|
||||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||||
|
import UserAvatar from '../../users/UserAvatar';
|
||||||
|
|
||||||
import styles from './Add.module.scss';
|
import styles from './Add.module.scss';
|
||||||
|
|
||||||
|
@ -21,13 +23,16 @@ const DEFAULT_DATA = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const Add = React.memo(() => {
|
const Add = React.memo(() => {
|
||||||
|
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
|
const [data, , setData] = useForm(DEFAULT_DATA);
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
|
||||||
const [selectTextFieldState, selectTextField] = useToggle();
|
const [selectTextFieldState, selectTextField] = useToggle();
|
||||||
|
|
||||||
const [textFieldRef, handleTextFieldRef] = useNestedRef();
|
const mentionsInputRef = useRef(null);
|
||||||
|
const textFieldRef = useRef(null);
|
||||||
const [buttonRef, handleButtonRef] = useNestedRef();
|
const [buttonRef, handleButtonRef] = useNestedRef();
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
|
@ -47,6 +52,11 @@ 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]);
|
||||||
|
@ -62,6 +72,15 @@ const Add = React.memo(() => {
|
||||||
setIsOpened(true);
|
setIsOpened(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(_, text) => {
|
||||||
|
setData({
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setData],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isModifierKeyPressed(event) && event.key === 'Enter') {
|
if (isModifierKeyPressed(event) && event.key === 'Enter') {
|
||||||
|
@ -85,6 +104,16 @@ const Add = React.memo(() => {
|
||||||
handleClickAwayCancel,
|
handleClickAwayCancel,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const suggestionRenderer = useCallback(
|
||||||
|
(entry, _, highlightedDisplay) => (
|
||||||
|
<div className={styles.suggestion}>
|
||||||
|
<UserAvatar id={entry.id} size="tiny" />
|
||||||
|
{highlightedDisplay}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useDidUpdate(() => {
|
useDidUpdate(() => {
|
||||||
if (isOpened) {
|
if (isOpened) {
|
||||||
activateEscapeInterceptor();
|
activateEscapeInterceptor();
|
||||||
|
@ -99,21 +128,39 @@ const Add = React.memo(() => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<TextArea
|
<div className={styles.field}>
|
||||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
<MentionsInput
|
||||||
ref={handleTextFieldRef}
|
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||||
as={TextareaAutosize}
|
allowSpaceInQuery
|
||||||
name="text"
|
allowSuggestionsAboveCursor
|
||||||
value={data.text}
|
ref={mentionsInputRef}
|
||||||
placeholder={t('common.writeComment')}
|
inputRef={textFieldRef}
|
||||||
maxLength={1048576}
|
value={data.text}
|
||||||
minRows={isOpened ? 3 : 1}
|
placeholder={t('common.writeComment')}
|
||||||
spellCheck={false}
|
maxLength={1048576}
|
||||||
className={styles.field}
|
rows={isOpened ? 3 : 1}
|
||||||
onFocus={handleFieldFocus}
|
className="mentions-input"
|
||||||
onKeyDown={handleFieldKeyDown}
|
style={{
|
||||||
onChange={handleFieldChange}
|
control: {
|
||||||
/>
|
minHeight: isOpened ? '79px' : '37px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onFocus={handleFieldFocus}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
onKeyDown={handleFieldKeyDown}
|
||||||
|
>
|
||||||
|
<Mention
|
||||||
|
appendSpaceOnAdd
|
||||||
|
data={boardMemberships.map(({ user }) => ({
|
||||||
|
id: user.id,
|
||||||
|
display: user.username || user.name,
|
||||||
|
}))}
|
||||||
|
displayTransform={(_, display) => `@${display}`}
|
||||||
|
renderSuggestion={suggestionRenderer}
|
||||||
|
className={styles.mention}
|
||||||
|
/>
|
||||||
|
</MentionsInput>
|
||||||
|
</div>
|
||||||
{isOpened && (
|
{isOpened && (
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -17,19 +17,35 @@
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 0;
|
margin-bottom: 8px !important;
|
||||||
box-sizing: border-box;
|
|
||||||
color: #333;
|
|
||||||
display: block;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 8px 12px;
|
|
||||||
resize: none;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:focus {
|
textarea {
|
||||||
outline: none;
|
background: #fff;
|
||||||
|
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #333;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px 12px;
|
||||||
|
resize: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
background-color: #f1f8ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
resize: none;
|
resize: none;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { Button } from 'semantic-ui-react';
|
||||||
|
|
||||||
import selectors from '../../../selectors';
|
import selectors from '../../../selectors';
|
||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
|
import { formatTextWithMentions } from '../../../utils/formatters';
|
||||||
import Paths from '../../../constants/Paths';
|
import Paths from '../../../constants/Paths';
|
||||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||||
import { NotificationTypes } from '../../../constants/Enums';
|
import { NotificationTypes } from '../../../constants/Enums';
|
||||||
|
@ -83,7 +84,7 @@ const Item = React.memo(({ id, onClose }) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case NotificationTypes.COMMENT_CARD: {
|
case NotificationTypes.COMMENT_CARD: {
|
||||||
const commentText = truncate(notification.data.text);
|
const commentText = truncate(formatTextWithMentions(notification.data.text));
|
||||||
|
|
||||||
contentNode = (
|
contentNode = (
|
||||||
<Trans
|
<Trans
|
||||||
|
@ -122,6 +123,28 @@ const Item = React.memo(({ id, onClose }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
case NotificationTypes.MENTION_IN_COMMENT: {
|
||||||
|
const commentText = truncate(formatTextWithMentions(notification.data.text));
|
||||||
|
|
||||||
|
contentNode = (
|
||||||
|
<Trans
|
||||||
|
i18nKey="common.userMentionedYouInCommentOnCard"
|
||||||
|
values={{
|
||||||
|
user: creatorUserName,
|
||||||
|
comment: commentText,
|
||||||
|
card: cardName,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.author}>{creatorUserName}</span>
|
||||||
|
{` mentioned you in «${commentText}» on `}
|
||||||
|
<Link to={Paths.CARDS.replace(':id', notification.cardId)} onClick={onClose}>
|
||||||
|
{cardName}
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
contentNode = null;
|
contentNode = null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { emojiDefs } from '@gravity-ui/markdown-editor/_/bundle/emoji';
|
||||||
/* eslint-enable import/no-unresolved */
|
/* eslint-enable import/no-unresolved */
|
||||||
|
|
||||||
import link from './link';
|
import link from './link';
|
||||||
|
import mention from './mention';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
ins,
|
ins,
|
||||||
|
@ -41,4 +42,5 @@ export default [
|
||||||
meta,
|
meta,
|
||||||
deflist,
|
deflist,
|
||||||
link,
|
link,
|
||||||
|
mention,
|
||||||
];
|
];
|
||||||
|
|
61
client/src/configs/markdown-plugins/mention.js
Normal file
61
client/src/configs/markdown-plugins/mention.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MENTION_REGEX = /@\[(.*?)\]\((.*?)\)/g;
|
||||||
|
|
||||||
|
export default (md) => {
|
||||||
|
md.core.ruler.push('mention', ({ tokens }) => {
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
if (token.type === 'inline' && token.content) {
|
||||||
|
const matches = [...token.content.matchAll(MENTION_REGEX)];
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const newChildren = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
matches.forEach((match) => {
|
||||||
|
// Add text before the mention
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
content: token.content.slice(lastIndex, match.index),
|
||||||
|
level: token.level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mention token
|
||||||
|
newChildren.push({
|
||||||
|
type: 'mention',
|
||||||
|
meta: {
|
||||||
|
display: match[1],
|
||||||
|
userId: match[2],
|
||||||
|
},
|
||||||
|
level: token.level,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining text after last mention
|
||||||
|
if (lastIndex < token.content.length) {
|
||||||
|
newChildren.push({
|
||||||
|
type: 'text',
|
||||||
|
content: token.content.slice(lastIndex),
|
||||||
|
level: token.level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
token.children = newChildren; // eslint-disable-line no-param-reassign
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
md.renderer.rules.mention = (tokens, index) => {
|
||||||
|
const { display, userId } = tokens[index].meta;
|
||||||
|
return `<span class="mention" data-user-id="${userId}">@${display}</span>`;
|
||||||
|
};
|
||||||
|
};
|
|
@ -100,6 +100,7 @@ export const NotificationTypes = {
|
||||||
MOVE_CARD: 'moveCard',
|
MOVE_CARD: 'moveCard',
|
||||||
COMMENT_CARD: 'commentCard',
|
COMMENT_CARD: 'commentCard',
|
||||||
ADD_MEMBER_TO_CARD: 'addMemberToCard',
|
ADD_MEMBER_TO_CARD: 'addMemberToCard',
|
||||||
|
MENTION_IN_COMMENT: 'mentionInComment',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NotificationServiceFormats = {
|
export const NotificationServiceFormats = {
|
||||||
|
|
|
@ -303,6 +303,8 @@ export default {
|
||||||
userMarkedTaskIncompleteOnCard:
|
userMarkedTaskIncompleteOnCard:
|
||||||
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
|
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
|
||||||
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
|
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
|
||||||
|
userMentionedYouInCommentOnCard:
|
||||||
|
'<0>{{user}}</0> mentioned you in a comment «{{comment}}» on <2>{{card}}</2>',
|
||||||
userMovedCardFromListToList:
|
userMovedCardFromListToList:
|
||||||
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
|
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
|
||||||
userMovedThisCardFromListToList:
|
userMovedThisCardFromListToList:
|
||||||
|
|
|
@ -298,6 +298,8 @@ export default {
|
||||||
userMarkedTaskIncompleteOnCard:
|
userMarkedTaskIncompleteOnCard:
|
||||||
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
|
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
|
||||||
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
|
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
|
||||||
|
userMentionedYouInCommentOnCard:
|
||||||
|
'<0>{{user}}</0> mentioned you in a comment «{{comment}}» on <2>{{card}}</2>',
|
||||||
userMovedCardFromListToList:
|
userMovedCardFromListToList:
|
||||||
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
|
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
|
||||||
userMovedThisCardFromListToList:
|
userMovedThisCardFromListToList:
|
||||||
|
|
|
@ -12,6 +12,31 @@
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mentions-input {
|
||||||
|
&__highlighter {
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__suggestions {
|
||||||
|
border: 1px solid #d4d4d5;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 8px 16px -4px rgba(9, 45, 66, 0.25),
|
||||||
|
0 0 0 1px rgba(9, 45, 66, 0.08);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
|
||||||
|
&--focused {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
color: rgba(0, 0, 0, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.react-datepicker {
|
.react-datepicker {
|
||||||
border: 0;
|
border: 0;
|
||||||
color: #444444;
|
color: #444444;
|
||||||
|
@ -192,6 +217,13 @@
|
||||||
font-size: .85em !important;
|
font-size: .85em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: #0366d6;
|
||||||
|
background-color: #f1f8ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.yfm-clipboard:hover {
|
.yfm-clipboard:hover {
|
||||||
.yfm-clipboard-button {
|
.yfm-clipboard-button {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
|
4
client/src/utils/formatters.js
Normal file
4
client/src/utils/formatters.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
const MENTIONS_REGEX = /@\[(.*?)\]\(.*?\)/g;
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const formatTextWithMentions = (text) => text.replace(MENTIONS_REGEX, '@$1');
|
|
@ -6,10 +6,18 @@
|
||||||
const escapeMarkdown = require('escape-markdown');
|
const escapeMarkdown = require('escape-markdown');
|
||||||
const escapeHtml = require('escape-html');
|
const escapeHtml = require('escape-html');
|
||||||
|
|
||||||
|
const { formatTextWithMentions } = require('../../../utils/formatters');
|
||||||
|
|
||||||
|
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>`;
|
||||||
const commentText = _.truncate(comment.text);
|
const commentText = _.truncate(formatTextWithMentions(comment.text));
|
||||||
|
|
||||||
await sails.helpers.utils.sendNotifications(services, t('New Comment'), {
|
await sails.helpers.utils.sendNotifications(services, t('New Comment'), {
|
||||||
text: `${t(
|
text: `${t(
|
||||||
|
@ -91,6 +99,19 @@ module.exports = {
|
||||||
user: values.user,
|
user: values.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mentionedUserIds = extractMentionedUserIds(values.text);
|
||||||
|
|
||||||
|
if (mentionedUserIds.length > 0) {
|
||||||
|
const boardMemberUserIds = await sails.helpers.boards.getMemberUserIds(inputs.board.id);
|
||||||
|
|
||||||
|
mentionedUserIds = _.difference(
|
||||||
|
_.intersection(mentionedUserIds, boardMemberUserIds),
|
||||||
|
comment.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionedUserIdsSet = new Set(mentionedUserIds);
|
||||||
|
|
||||||
const cardSubscriptionUserIds = await sails.helpers.cards.getSubscriptionUserIds(
|
const cardSubscriptionUserIds = await sails.helpers.cards.getSubscriptionUserIds(
|
||||||
comment.cardId,
|
comment.cardId,
|
||||||
comment.userId,
|
comment.userId,
|
||||||
|
@ -101,7 +122,11 @@ module.exports = {
|
||||||
comment.userId,
|
comment.userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const notifiableUserIds = _.union(cardSubscriptionUserIds, boardSubscriptionUserIds);
|
const notifiableUserIds = _.union(
|
||||||
|
mentionedUserIds,
|
||||||
|
cardSubscriptionUserIds,
|
||||||
|
boardSubscriptionUserIds,
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
notifiableUserIds.map((userId) =>
|
notifiableUserIds.map((userId) =>
|
||||||
|
@ -109,7 +134,9 @@ module.exports = {
|
||||||
values: {
|
values: {
|
||||||
userId,
|
userId,
|
||||||
comment,
|
comment,
|
||||||
type: Notification.Types.COMMENT_CARD,
|
type: mentionedUserIdsSet.has(userId)
|
||||||
|
? Notification.Types.MENTION_IN_COMMENT
|
||||||
|
: Notification.Types.COMMENT_CARD,
|
||||||
data: {
|
data: {
|
||||||
card: _.pick(values.card, ['name']),
|
card: _.pick(values.card, ['name']),
|
||||||
text: comment.text,
|
text: comment.text,
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
const escapeMarkdown = require('escape-markdown');
|
const escapeMarkdown = require('escape-markdown');
|
||||||
const escapeHtml = require('escape-html');
|
const escapeHtml = require('escape-html');
|
||||||
|
|
||||||
|
const { formatTextWithMentions } = require('../../../utils/formatters');
|
||||||
|
|
||||||
const buildTitle = (notification, t) => {
|
const buildTitle = (notification, t) => {
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case Notification.Types.MOVE_CARD:
|
case Notification.Types.MOVE_CARD:
|
||||||
|
@ -14,6 +16,8 @@ const buildTitle = (notification, t) => {
|
||||||
return t('New Comment');
|
return t('New 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');
|
||||||
|
case Notification.Types.MENTION_IN_COMMENT:
|
||||||
|
return t('You Were Mentioned in Comment');
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +60,7 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Notification.Types.COMMENT_CARD: {
|
case Notification.Types.COMMENT_CARD: {
|
||||||
const commentText = _.truncate(notification.data.text);
|
const commentText = _.truncate(formatTextWithMentions(notification.data.text));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: `${t(
|
text: `${t(
|
||||||
|
@ -95,6 +99,30 @@ const buildBodyByFormat = (board, card, notification, actorUser, t) => {
|
||||||
escapeHtml(board.name),
|
escapeHtml(board.name),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case Notification.Types.MENTION_IN_COMMENT: {
|
||||||
|
const commentText = _.truncate(formatTextWithMentions(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>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -147,6 +175,15 @@ const buildAndSendEmail = async (board, card, notification, actorUser, notifiabl
|
||||||
boardLink,
|
boardLink,
|
||||||
)}</p>`;
|
)}</p>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Notification.Types.MENTION_IN_COMMENT:
|
||||||
|
html = `<p>${t(
|
||||||
|
'%s mentioned you in %s on %s',
|
||||||
|
escapeHtml(actorUser.name),
|
||||||
|
cardLink,
|
||||||
|
boardLink,
|
||||||
|
)}</p><p>${escapeHtml(notification.data.text)}</p>`;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
|
@ -186,9 +223,11 @@ module.exports = {
|
||||||
values.userId = values.user.id;
|
values.userId = values.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCommentCard = values.type === Notification.Types.COMMENT_CARD;
|
const isCommentRelated =
|
||||||
|
values.type === Notification.Types.COMMENT_CARD ||
|
||||||
|
values.type === Notification.Types.MENTION_IN_COMMENT;
|
||||||
|
|
||||||
if (isCommentCard) {
|
if (isCommentRelated) {
|
||||||
values.commentId = values.comment.id;
|
values.commentId = values.comment.id;
|
||||||
} else {
|
} else {
|
||||||
values.actionId = values.action.id;
|
values.actionId = values.action.id;
|
||||||
|
@ -217,7 +256,7 @@ module.exports = {
|
||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
lists: [inputs.list],
|
lists: [inputs.list],
|
||||||
cards: [values.card],
|
cards: [values.card],
|
||||||
...(isCommentCard
|
...(isCommentRelated
|
||||||
? {
|
? {
|
||||||
comments: [values.comment],
|
comments: [values.comment],
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ const Types = {
|
||||||
MOVE_CARD: 'moveCard',
|
MOVE_CARD: 'moveCard',
|
||||||
COMMENT_CARD: 'commentCard',
|
COMMENT_CARD: 'commentCard',
|
||||||
ADD_MEMBER_TO_CARD: 'addMemberToCard',
|
ADD_MEMBER_TO_CARD: 'addMemberToCard',
|
||||||
|
MENTION_IN_COMMENT: 'mentionInComment',
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -7,8 +7,10 @@
|
||||||
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
|
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
|
||||||
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
|
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
|
||||||
"You Were Added to Card": "Your Were Added to Card",
|
"You Were Added to Card": "Your Were Added to Card",
|
||||||
|
"You Were Mentioned in Comment": "You Were Mentioned in Comment",
|
||||||
"%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 mentioned you in %s on %s": "%s mentioned you in %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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,10 @@
|
||||||
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
|
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
|
||||||
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
|
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
|
||||||
"You Were Added to Card": "Your Were Added to Card",
|
"You Were Added to Card": "Your Were Added to Card",
|
||||||
|
"You Were Mentioned in Comment": "You Were Mentioned in Comment",
|
||||||
"%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 mentioned you in %s on %s": "%s mentioned you in %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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,10 @@
|
||||||
"This is a *test* **markdown** `message`!": "Это *тестовое* **markdown** `сообщение`!",
|
"This is a *test* **markdown** `message`!": "Это *тестовое* **markdown** `сообщение`!",
|
||||||
"This is a <i>test</i> <b>html</b> <code>message</code>": "Это <i>тестовое</i> <b>html</b> <code>сообщение</code>",
|
"This is a <i>test</i> <b>html</b> <code>message</code>": "Это <i>тестовое</i> <b>html</b> <code>сообщение</code>",
|
||||||
"You Were Added to Card": "Вы были добавлены к карточке",
|
"You Were Added to Card": "Вы были добавлены к карточке",
|
||||||
|
"You Were Mentioned in Comment": "Вы были упомянуты в комментарии",
|
||||||
"%s added you to %s on %s": "%s добавил(а) вас к %s на %s",
|
"%s added you to %s on %s": "%s добавил(а) вас к %s на %s",
|
||||||
"%s created %s in %s on %s": "%s создал(а) %s в %s на %s",
|
"%s created %s in %s on %s": "%s создал(а) %s в %s на %s",
|
||||||
"%s left a new comment to %s on %s": "%s оставил(а) новый комментарий к %s на %s",
|
"%s left a new comment to %s on %s": "%s оставил(а) новый комментарий к %s на %s",
|
||||||
|
"%s mentioned you in %s on %s": "%s упомянул(а) вас в %s на %s",
|
||||||
"%s moved %s from %s to %s on %s": "%s переместил(а) %s из %s в %s на %s"
|
"%s moved %s from %s to %s on %s": "%s переместил(а) %s из %s в %s на %s"
|
||||||
}
|
}
|
||||||
|
|
7
server/utils/formatters.js
Normal file
7
server/utils/formatters.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
const MENTIONS_REGEX = /@\[(.*?)\]\(.*?\)/g;
|
||||||
|
|
||||||
|
const formatTextWithMentions = (text) => text.replace(MENTIONS_REGEX, '@$1');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatTextWithMentions,
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue