diff --git a/cms/settings.py b/cms/settings.py index e0c0b52..4d26517 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -86,6 +86,7 @@ MAX_MEDIA_PER_PLAYLIST = 70 UPLOAD_MAX_SIZE = 800 * 1024 * 1000 * 5 MAX_CHARS_FOR_COMMENT = 10000 # so that it doesn't end up huge +ALLOW_MENTION_IN_COMMENTS = False # allowing to mention other users with @ in the comments # valid options: content, author RELATED_MEDIA_STRATEGY = "content" diff --git a/docs/images/Mention1.png b/docs/images/Mention1.png new file mode 100644 index 0000000..ee65331 Binary files /dev/null and b/docs/images/Mention1.png differ diff --git a/docs/images/Mention2.png b/docs/images/Mention2.png new file mode 100644 index 0000000..19f3a57 Binary files /dev/null and b/docs/images/Mention2.png differ diff --git a/docs/images/Mention3.png b/docs/images/Mention3.png new file mode 100644 index 0000000..c37c54f Binary files /dev/null and b/docs/images/Mention3.png differ diff --git a/docs/images/Mention4.png b/docs/images/Mention4.png new file mode 100644 index 0000000..c6cb6b5 Binary files /dev/null and b/docs/images/Mention4.png differ diff --git a/docs/user_docs.md b/docs/user_docs.md index ec90996..44d1de6 100644 --- a/docs/user_docs.md +++ b/docs/user_docs.md @@ -5,7 +5,8 @@ - [Downloading media](#downloading-media) - [Adding captions/subtitles](#adding-captionssubtitles) - [Search media](#search-media) -- [Using Timestamps for sharing](#using-timestamps-for-sharing) +- [Using Timestamps for sharing](#-using-timestamps-for-sharing) +- [Mentionning users in comments](#Mentionning-users-in-comments) - [Share media](#share-media) - [Embed media](#embed-media) - [Customize my profile options](#customize-my-profile-options) @@ -220,6 +221,19 @@ Comments can also include timestamps. They are automatically detected upon posti

+## Mentionning users in comments + +Comments can also mention other users by tagging with '@'. This will open suggestion box showing usernames, and the selection will refine as the user continues typing. + +Comments send with mentions will contain a link to the user page, and can be setup to send a mail to the mentionned user. + +

+ + + + +

+ ## Search media How search can be used diff --git a/files/context_processors.py b/files/context_processors.py index 997f979..19817ed 100644 --- a/files/context_processors.py +++ b/files/context_processors.py @@ -13,6 +13,7 @@ def stuff(request): ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED ret["CAN_REGISTER"] = settings.REGISTER_ALLOWED ret["CAN_UPLOAD_MEDIA"] = settings.UPLOAD_MEDIA_ALLOWED + ret["CAN_MENTION_IN_COMMENTS"] = settings.ALLOW_MENTION_IN_COMMENTS ret["CAN_LIKE_MEDIA"] = settings.CAN_LIKE_MEDIA ret["CAN_DISLIKE_MEDIA"] = settings.CAN_DISLIKE_MEDIA ret["CAN_REPORT_MEDIA"] = settings.CAN_REPORT_MEDIA diff --git a/files/methods.py b/files/methods.py index 1cf796c..60c599d 100644 --- a/files/methods.py +++ b/files/methods.py @@ -4,6 +4,7 @@ import itertools import logging import random +import re from datetime import datetime from django.conf import settings @@ -324,8 +325,6 @@ def update_user_ratings(user, media, user_ratings): def notify_user_on_comment(friendly_token): """Notify users through email, for a set of actions""" - - media = None media = models.Media.objects.filter(friendly_token=friendly_token).first() if not media: return False @@ -347,6 +346,55 @@ View it on %s return True +def notify_user_on_mention(friendly_token, user_mentioned, cleaned_comment): + from users.models import User + + media = models.Media.objects.filter(friendly_token=friendly_token).first() + if not media: + return False + + user = User.objects.filter(username=user_mentioned).first() + media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url() + + if user.notification_on_comments: + title = "[{}] - You were mentioned in a comment".format(settings.PORTAL_NAME) + msg = """ +You were mentioned in a comment on %s . +View it on %s + +Comment : %s + """ % ( + media.title, + media_url, + cleaned_comment, + ) + email = EmailMessage(title, msg, settings.DEFAULT_FROM_EMAIL, [user.email]) + email.send(fail_silently=True) + return True + + +def check_comment_for_mention(friendly_token, comment_text): + """Check the comment for any mentions, and notify each mentioned users""" + cleaned_comment = '' + + matches = re.findall('@\\(_(.+?)_\\)', comment_text) + if matches: + cleaned_comment = clean_comment(comment_text) + + for match in list(dict.fromkeys(matches)): + notify_user_on_mention(friendly_token, match, cleaned_comment) + + +def clean_comment(raw_comment): + """Clean the comment fromn ID and username Mentions for preview purposes""" + + cleaned_comment = re.sub('@\\(_(.+?)_\\)', '', raw_comment) + cleaned_comment = cleaned_comment.replace("[_", '') + cleaned_comment = cleaned_comment.replace("_]", '') + + return cleaned_comment + + def list_tasks(): """Lists celery tasks To be used in an admin dashboard diff --git a/files/views.py b/files/views.py index b5a81b3..b3f2cc8 100644 --- a/files/views.py +++ b/files/views.py @@ -32,6 +32,7 @@ from users.models import User from .forms import ContactForm, MediaForm, SubtitleForm from .helpers import clean_query, produce_ffmpeg_commands from .methods import ( + check_comment_for_mention, get_user_or_session, is_mediacms_editor, is_mediacms_manager, @@ -1277,6 +1278,9 @@ class CommentDetail(APIView): serializer.save(user=request.user, media=media) if request.user != media.user: notify_user_on_comment(friendly_token=media.friendly_token) + # here forward the comment to check if a user was mentioned + if settings.ALLOW_MENTION_IN_COMMENTS: + check_comment_for_mention(friendly_token=media.friendly_token, comment_text=serializer.data['text']) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/frontend/package.json b/frontend/package.json index c8f7826..396bd6b 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "normalize.css": "^8.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-mentions": "^4.3.1", "sortablejs": "^1.13.0", "timeago.js": "^4.0.2", "url-parse": "^1.5.1" diff --git a/frontend/src/static/css/config/_dark_theme.scss b/frontend/src/static/css/config/_dark_theme.scss index 2596bea..2cf8786 100755 --- a/frontend/src/static/css/config/_dark_theme.scss +++ b/frontend/src/static/css/config/_dark_theme.scss @@ -94,6 +94,7 @@ body.dark_theme { --comment-date-hover-text-color: #fff; --comment-text-color: rgba(255, 255, 255, 0.88); + --comment-text-mentions-background-color-highlight:#006622; --comment-actions-material-icon-text-color: rgba(255, 255, 255, 0.74); diff --git a/frontend/src/static/css/config/_light_theme.scss b/frontend/src/static/css/config/_light_theme.scss index 88c8e0d..3011401 100755 --- a/frontend/src/static/css/config/_light_theme.scss +++ b/frontend/src/static/css/config/_light_theme.scss @@ -94,6 +94,7 @@ body { --comment-date-hover-text-color: #0a0a0a; --comment-text-color: #111; + --comment-text-mentions-background-color-highlight:#00cc44; --comment-actions-material-icon-text-color: rgba(17, 17, 17, 0.8); diff --git a/frontend/src/static/js/components/comments/Comments.jsx b/frontend/src/static/js/components/comments/Comments.jsx index f59fff9..f5816db 100644 --- a/frontend/src/static/js/components/comments/Comments.jsx +++ b/frontend/src/static/js/components/comments/Comments.jsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; +import { MentionsInput, Mention } from 'react-mentions'; import PropTypes from 'prop-types'; import { format } from 'timeago.js'; import { usePopup } from '../../utils/hooks/'; @@ -25,6 +26,7 @@ function CommentForm(props) { const [madeChanges, setMadeChanges] = useState(false); const [textareaFocused, setTextareaFocused] = useState(false); const [textareaLineHeight, setTextareaLineHeight] = useState(-1); + const [userList, setUsersList] = useState(''); const [loginUrl] = useState( !MemberContext._currentValue.is.anonymous @@ -42,6 +44,17 @@ function CommentForm(props) { setTextareaFocused(false); } + function onUsersLoad() + { + const userList =[...MediaPageStore.get('users')]; + const cleanList = [] + userList.forEach(user => { + cleanList.push({id : user.username, display : user.name}); + }); + + setUsersList(cleanList); + } + function onCommentSubmit() { textareaRef.current.style.height = ''; @@ -61,6 +74,21 @@ function CommentForm(props) { setMadeChanges(false); } + function onChangeWithMention(event, newValue, newPlainTextValue, mentions) { + textareaRef.current.style.height = ''; + + setValue(newValue); + setMadeChanges(true); + + const contentHeight = textareaRef.current.scrollHeight; + const contentLineHeight = + 0 < textareaLineHeight ? textareaLineHeight : parseFloat(window.getComputedStyle(textareaRef.current).lineHeight); + setTextareaLineHeight(contentLineHeight); + + textareaRef.current.style.height = + Math.max(20, textareaLineHeight * Math.ceil(contentHeight / contentLineHeight)) + 'px'; + } + function onChange(event) { textareaRef.current.style.height = ''; @@ -81,7 +109,7 @@ function CommentForm(props) { return; } - const val = textareaRef.current.value.trim(); + const val = value.trim(); if ('' !== val) { MediaPageActions.submitComment(val); @@ -91,10 +119,18 @@ function CommentForm(props) { useEffect(() => { MediaPageStore.on('comment_submit', onCommentSubmit); MediaPageStore.on('comment_submit_fail', onCommentSubmitFail); + if (MediaCMS.features.media.actions.comment_mention === true) + { + MediaPageStore.on('users_load', onUsersLoad); + } return () => { MediaPageStore.removeListener('comment_submit', onCommentSubmit); MediaPageStore.removeListener('comment_submit_fail', onCommentSubmitFail); + if (MediaCMS.features.media.actions.comment_mention === true) + { + MediaPageStore.removeListener('users_load', onUsersLoad); + } }; }); @@ -104,16 +140,33 @@ function CommentForm(props) {
- + { MediaCMS.features.media.actions.comment_mention ? + + + + : + + }