PLT-914 Add mention notifications for replies on a comment thread (#3130)

* PLT-914 Add mention notifications for replies on a comment thread

* remove useless store method
fix highlighting comments posted before th user write something to thread

* refactor out isCommentMention function after rebase

* change comment bar highlighting to replay icon mention highlighting

* settings and always visible highlight

* fix unit tests for new settings

* change highlight behaviour
- if any message in comment thread generates mention - all thread is highlighted
- remove always visible highlightion

* fix bug about the textarea in the center channel not clearing

* fix default settings value notify_props.comments

* do not highlight own comments if there is no other user's messages in thread

* refactor out ReactDOM.findDOMNode

* refactor out using of UserStore from component
Этот коммит содержится в:
samogot
2016-07-19 15:27:23 +03:00
коммит произвёл Christopher Speller
родитель febe3a01cd
Коммит f31e8e09f5
11 изменённых файлов: 233 добавлений и 23 удалений

Просмотреть файл

@@ -535,9 +535,8 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
return model.SplitRunes[c] return model.SplitRunes[c]
} }
splitMessage := strings.Fields(post.Message) splitMessage := strings.Fields(post.Message)
var userIds []string
for _, word := range splitMessage { for _, word := range splitMessage {
var userIds []string
// Non-case-sensitive check for regular keys // Non-case-sensitive check for regular keys
if ids, match := keywordMap[strings.ToLower(word)]; match { if ids, match := keywordMap[strings.ToLower(word)]; match {
userIds = append(userIds, ids...) userIds = append(userIds, ids...)
@@ -565,16 +564,32 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
} }
} }
} }
}
for _, userId := range userIds { if len(post.RootId) > 0 {
if post.UserId == userId && post.Props["from_webhook"] != "true" { if result := <-Srv.Store.Post().Get(post.RootId); result.Err != nil {
continue l4g.Error(utils.T("api.post.send_notifications_and_forget.comment_thread.error"), post.RootId, result.Err)
return
} else {
list := result.Data.(*model.PostList)
for _, threadPost := range list.Posts {
profile := profileMap[threadPost.UserId]
if profile.NotifyProps["comments"] == "any" || (profile.NotifyProps["comments"] == "root" && threadPost.Id == list.Order[0]) {
userIds = append(userIds, threadPost.UserId)
}
} }
mentionedUserIds[userId] = true
} }
} }
for _, userId := range userIds {
if post.UserId == userId && post.Props["from_webhook"] != "true" {
continue
}
mentionedUserIds[userId] = true
}
for id := range mentionedUserIds { for id := range mentionedUserIds {
go updateMentionCount(post.ChannelId, id) go updateMentionCount(post.ChannelId, id)
} }

Просмотреть файл

@@ -1930,6 +1930,12 @@ func updateUserNotify(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
comments := props["comments"]
if len(comments) == 0 {
c.SetInvalidParam("updateUserNotify", "comments")
return
}
var user *model.User var user *model.User
if result := <-uchan; result.Err != nil { if result := <-uchan; result.Err != nil {
c.Err = result.Err c.Err = result.Err

Просмотреть файл

@@ -1247,6 +1247,7 @@ func TestUserUpdateNotify(t *testing.T) {
data["email"] = "true" data["email"] = "true"
data["desktop"] = "all" data["desktop"] = "all"
data["desktop_sound"] = "false" data["desktop_sound"] = "false"
data["comments"] = "any"
if _, err := Client.UpdateUserNotify(data); err == nil { if _, err := Client.UpdateUserNotify(data); err == nil {
t.Fatal("Should have errored - not logged in") t.Fatal("Should have errored - not logged in")
@@ -1267,6 +1268,9 @@ func TestUserUpdateNotify(t *testing.T) {
if result.Data.(*model.User).NotifyProps["email"] != data["email"] { if result.Data.(*model.User).NotifyProps["email"] != data["email"] {
t.Fatal("NotifyProps did not update properly - email") t.Fatal("NotifyProps did not update properly - email")
} }
if result.Data.(*model.User).NotifyProps["comments"] != data["comments"] {
t.Fatal("NotifyProps did not update properly - comments")
}
} }
if _, err := Client.UpdateUserNotify(nil); err == nil { if _, err := Client.UpdateUserNotify(nil); err == nil {
@@ -1300,6 +1304,12 @@ func TestUserUpdateNotify(t *testing.T) {
if _, err := Client.UpdateUserNotify(data); err == nil { if _, err := Client.UpdateUserNotify(data); err == nil {
t.Fatal("Should have errored - empty email") t.Fatal("Should have errored - empty email")
} }
data["email"] = "true"
data["comments"] = ""
if _, err := Client.UpdateUserNotify(data); err == nil {
t.Fatal("Should have errored - empty comments")
}
} }
func TestFuzzyUserCreate(t *testing.T) { func TestFuzzyUserCreate(t *testing.T) {

Просмотреть файл

@@ -1023,6 +1023,10 @@
"id": "api.post.make_direct_channel_visible.update_pref.error", "id": "api.post.make_direct_channel_visible.update_pref.error",
"translation": "Failed to update direct channel preference user_id=%v other_user_id=%v err=%v" "translation": "Failed to update direct channel preference user_id=%v other_user_id=%v err=%v"
}, },
{
"id": "api.post.send_notifications_and_forget.comment_thread.error",
"translation": "Failed to retrieve comment thread posts in notifications root_post_id=%v, err=%v"
},
{ {
"id": "api.post.send_notifications_and_forget.mention_body", "id": "api.post.send_notifications_and_forget.mention_body",
"translation": "You have one new mention." "translation": "You have one new mention."

Просмотреть файл

@@ -76,6 +76,10 @@ export default class Post extends React.Component {
return true; return true;
} }
if (nextProps.isCommentMention !== this.props.isCommentMention) {
return true;
}
if (nextProps.shouldHighlight !== this.props.shouldHighlight) { if (nextProps.shouldHighlight !== this.props.shouldHighlight) {
return true; return true;
} }
@@ -231,6 +235,7 @@ export default class Post extends React.Component {
post={post} post={post}
sameRoot={this.props.sameRoot} sameRoot={this.props.sameRoot}
commentCount={commentCount} commentCount={commentCount}
isCommentMention={this.props.isCommentMention}
handleCommentClick={this.handleCommentClick} handleCommentClick={this.handleCommentClick}
handleDropdownOpened={this.handleDropdownOpened} handleDropdownOpened={this.handleDropdownOpened}
isLastComment={this.props.isLastComment} isLastComment={this.props.isLastComment}
@@ -274,6 +279,7 @@ Post.propTypes = {
compactDisplay: React.PropTypes.bool, compactDisplay: React.PropTypes.bool,
previewCollapsed: React.PropTypes.string, previewCollapsed: React.PropTypes.string,
commentCount: React.PropTypes.number, commentCount: React.PropTypes.number,
isCommentMention: React.PropTypes.bool,
useMilitaryTime: React.PropTypes.bool.isRequired, useMilitaryTime: React.PropTypes.bool.isRequired,
emojis: React.PropTypes.object.isRequired emojis: React.PropTypes.object.isRequired
}; };

Просмотреть файл

@@ -63,6 +63,7 @@ export default class PostHeader extends React.Component {
<PostInfo <PostInfo
post={post} post={post}
commentCount={this.props.commentCount} commentCount={this.props.commentCount}
isCommentMention={this.props.isCommentMention}
handleCommentClick={this.props.handleCommentClick} handleCommentClick={this.props.handleCommentClick}
handleDropdownOpened={this.props.handleDropdownOpened} handleDropdownOpened={this.props.handleDropdownOpened}
allowReply='true' allowReply='true'
@@ -89,6 +90,7 @@ PostHeader.propTypes = {
user: React.PropTypes.object, user: React.PropTypes.object,
currentUser: React.PropTypes.object.isRequired, currentUser: React.PropTypes.object.isRequired,
commentCount: React.PropTypes.number.isRequired, commentCount: React.PropTypes.number.isRequired,
isCommentMention: React.PropTypes.bool.isRequired,
isLastComment: React.PropTypes.bool.isRequired, isLastComment: React.PropTypes.bool.isRequired,
handleCommentClick: React.PropTypes.func.isRequired, handleCommentClick: React.PropTypes.func.isRequired,
handleDropdownOpened: React.PropTypes.func.isRequired, handleDropdownOpened: React.PropTypes.func.isRequired,

Просмотреть файл

@@ -174,6 +174,7 @@ export default class PostInfo extends React.Component {
var post = this.props.post; var post = this.props.post;
var comments = ''; var comments = '';
var showCommentClass = ''; var showCommentClass = '';
var highlightMentionClass = '';
var commentCountText = this.props.commentCount; var commentCountText = this.props.commentCount;
if (this.props.commentCount >= 1) { if (this.props.commentCount >= 1) {
@@ -182,11 +183,15 @@ export default class PostInfo extends React.Component {
commentCountText = ''; commentCountText = '';
} }
if (this.props.isCommentMention) {
highlightMentionClass = ' mention--highlight';
}
if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) { if (post.state !== Constants.POST_FAILED && post.state !== Constants.POST_LOADING && !Utils.isPostEphemeral(post)) {
comments = ( comments = (
<a <a
href='#' href='#'
className={'comment-icon__container' + showCommentClass} className={'comment-icon__container' + showCommentClass + highlightMentionClass}
onClick={this.props.handleCommentClick} onClick={this.props.handleCommentClick}
> >
<span <span
@@ -234,6 +239,7 @@ PostInfo.defaultProps = {
PostInfo.propTypes = { PostInfo.propTypes = {
post: React.PropTypes.object.isRequired, post: React.PropTypes.object.isRequired,
commentCount: React.PropTypes.number.isRequired, commentCount: React.PropTypes.number.isRequired,
isCommentMention: React.PropTypes.bool.isRequired,
isLastComment: React.PropTypes.bool.isRequired, isLastComment: React.PropTypes.bool.isRequired,
allowReply: React.PropTypes.string.isRequired, allowReply: React.PropTypes.string.isRequired,
handleCommentClick: React.PropTypes.func.isRequired, handleCommentClick: React.PropTypes.func.isRequired,

Просмотреть файл

@@ -251,15 +251,35 @@ export default class PostList extends React.Component {
} }
let commentCount = 0; let commentCount = 0;
let nonOwnCommentsExists = false;
let isCommentMention = false;
let commentRootId; let commentRootId;
if (parentPost) { if (parentPost) {
commentRootId = post.root_id; commentRootId = post.root_id;
} else { } else {
commentRootId = post.id; commentRootId = post.id;
} }
for (const postId in posts) { if (commentRootId) {
if (posts[postId].root_id === commentRootId) { const commentsNotifyLevel = this.props.currentUser.notify_props.comments || 'never';
commentCount += 1; for (const postId in posts) {
if (posts[postId].root_id === commentRootId) {
commentCount += 1;
if (posts[postId].user_id !== this.props.currentUser.id) {
nonOwnCommentsExists = true;
}
if (posts[postId].user_id === this.props.currentUser.id && commentsNotifyLevel === 'any' && !isCommentMention) {
for (const nextPostId in posts) {
if (posts[nextPostId].root_id === commentRootId && posts[nextPostId].user_id !== this.props.currentUser.id &&
posts[postId].create_at < posts[nextPostId].create_at) {
isCommentMention = true;
break;
}
}
}
}
}
if (nonOwnCommentsExists && posts[commentRootId].user_id === this.props.currentUser.id && commentsNotifyLevel !== 'never') {
isCommentMention = true;
} }
} }
@@ -279,6 +299,7 @@ export default class PostList extends React.Component {
currentUser={this.props.currentUser} currentUser={this.props.currentUser}
center={this.props.displayPostsInCenter} center={this.props.displayPostsInCenter}
commentCount={commentCount} commentCount={commentCount}
isCommentMention={isCommentMention}
compactDisplay={this.props.compactDisplay} compactDisplay={this.props.compactDisplay}
previewCollapsed={this.props.previewsCollapsed} previewCollapsed={this.props.previewsCollapsed}
useMilitaryTime={this.props.useMilitaryTime} useMilitaryTime={this.props.useMilitaryTime}

Просмотреть файл

@@ -2,7 +2,6 @@
// See License.txt for license information. // See License.txt for license information.
import $ from 'jquery'; import $ from 'jquery';
import ReactDOM from 'react-dom';
import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMin from '../setting_item_min.jsx';
import SettingItemMax from '../setting_item_max.jsx'; import SettingItemMax from '../setting_item_max.jsx';
@@ -26,6 +25,10 @@ function getNotificationsStateFromStores() {
if (user.notify_props && user.notify_props.desktop) { if (user.notify_props && user.notify_props.desktop) {
desktop = user.notify_props.desktop; desktop = user.notify_props.desktop;
} }
var comments = 'never';
if (user.notify_props && user.notify_props.comments) {
comments = user.notify_props.comments;
}
var email = 'true'; var email = 'true';
if (user.notify_props && user.notify_props.email) { if (user.notify_props && user.notify_props.email) {
email = user.notify_props.email; email = user.notify_props.email;
@@ -82,7 +85,8 @@ function getNotificationsStateFromStores() {
customKeys, customKeys,
customKeysChecked: customKeys.length > 0, customKeysChecked: customKeys.length > 0,
firstNameKey, firstNameKey,
channelKey channelKey,
notifyCommentsLevel: comments
}; };
} }
@@ -103,6 +107,10 @@ const holders = defineMessages({
id: 'user.settings.notifications.wordsTrigger', id: 'user.settings.notifications.wordsTrigger',
defaultMessage: 'Words that trigger mentions' defaultMessage: 'Words that trigger mentions'
}, },
comments: {
id: 'user.settings.notifications.comments',
defaultMessage: 'Comment threads notifications'
},
close: { close: {
id: 'user.settings.notifications.close', id: 'user.settings.notifications.close',
defaultMessage: 'Close' defaultMessage: 'Close'
@@ -140,6 +148,7 @@ class NotificationsTab extends React.Component {
data.desktop_sound = this.state.enableSound; data.desktop_sound = this.state.enableSound;
data.desktop = this.state.notifyLevel; data.desktop = this.state.notifyLevel;
data.push = this.state.notifyPushLevel; data.push = this.state.notifyPushLevel;
data.comments = this.state.notifyCommentsLevel;
var mentionKeys = []; var mentionKeys = [];
if (this.state.usernameKey) { if (this.state.usernameKey) {
@@ -195,21 +204,26 @@ class NotificationsTab extends React.Component {
} }
handleNotifyRadio(notifyLevel) { handleNotifyRadio(notifyLevel) {
this.setState({notifyLevel}); this.setState({notifyLevel});
ReactDOM.findDOMNode(this.refs.wrapper).focus(); this.refs.wrapper.focus();
}
handleNotifyCommentsRadio(notifyCommentsLevel) {
this.setState({notifyCommentsLevel});
this.refs.wrapper.focus();
} }
handlePushRadio(notifyPushLevel) { handlePushRadio(notifyPushLevel) {
this.setState({notifyPushLevel}); this.setState({notifyPushLevel});
ReactDOM.findDOMNode(this.refs.wrapper).focus(); this.refs.wrapper.focus();
} }
handleEmailRadio(enableEmail) { handleEmailRadio(enableEmail) {
this.setState({enableEmail}); this.setState({enableEmail});
ReactDOM.findDOMNode(this.refs.wrapper).focus(); this.refs.wrapper.focus();
} }
handleSoundRadio(enableSound) { handleSoundRadio(enableSound) {
this.setState({enableSound}); this.setState({enableSound});
ReactDOM.findDOMNode(this.refs.wrapper).focus(); this.refs.wrapper.focus();
} }
updateUsernameKey(val) { updateUsernameKey(val) {
this.setState({usernameKey: val}); this.setState({usernameKey: val});
@@ -224,10 +238,10 @@ class NotificationsTab extends React.Component {
this.setState({channelKey: val}); this.setState({channelKey: val});
} }
updateCustomMentionKeys() { updateCustomMentionKeys() {
var checked = ReactDOM.findDOMNode(this.refs.customcheck).checked; var checked = this.refs.customcheck.checked;
if (checked) { if (checked) {
var text = ReactDOM.findDOMNode(this.refs.custommentions).value; var text = this.refs.custommentions.value;
// remove all spaces and split string into individual keys // remove all spaces and split string into individual keys
this.setState({customKeys: text.replace(/ /g, ''), customKeysChecked: true}); this.setState({customKeys: text.replace(/ /g, ''), customKeysChecked: true});
@@ -236,7 +250,7 @@ class NotificationsTab extends React.Component {
} }
} }
onCustomChange() { onCustomChange() {
ReactDOM.findDOMNode(this.refs.customcheck).checked = true; this.refs.customcheck.checked = true;
this.updateCustomMentionKeys(); this.updateCustomMentionKeys();
} }
createPushNotificationSection() { createPushNotificationSection() {
@@ -902,6 +916,126 @@ class NotificationsTab extends React.Component {
); );
} }
var commentsSection;
var handleUpdateCommentsSection;
if (this.props.activeSection === 'comments') {
var commentsActive = [false, false, false];
if (this.state.notifyCommentsLevel === 'never') {
commentsActive[2] = true;
} else if (this.state.notifyCommentsLevel === 'root') {
commentsActive[1] = true;
} else {
commentsActive[0] = true;
}
let inputs = [];
inputs.push(
<div key='userNotificationLevelOption'>
<div className='radio'>
<label>
<input
type='radio'
name='commentsNotificationLevel'
checked={commentsActive[0]}
onChange={this.handleNotifyCommentsRadio.bind(this, 'any')}
/>
<FormattedMessage
id='user.settings.notifications.commentsAny'
defaultMessage='Mention any comments in a thread you participated in (This will include both mentions to your root post and any comments after you commented on a post)'
/>
</label>
<br/>
</div>
<div className='radio'>
<label>
<input
type='radio'
name='commentsNotificationLevel'
checked={commentsActive[1]}
onChange={this.handleNotifyCommentsRadio.bind(this, 'root')}
/>
<FormattedMessage
id='user.settings.notifications.commentsRoot'
defaultMessage='Mention any comments on your post'
/>
</label>
<br/>
</div>
<div className='radio'>
<label>
<input
type='radio'
name='commentsNotificationLevel'
checked={commentsActive[2]}
onChange={this.handleNotifyCommentsRadio.bind(this, 'never')}
/>
<FormattedMessage
id='user.settings.notifications.commentsNever'
defaultMessage='No mentions for comments'
/>
</label>
</div>
</div>
);
const extraInfo = (
<span>
<FormattedMessage
id='user.settings.notifications.commentsInfo'
defaultMessage='Mode of triggering notifications on posts in comment threads you participated in.'
/>
</span>
);
commentsSection = (
<SettingItemMax
title={formatMessage(holders.comments)}
extraInfo={extraInfo}
inputs={inputs}
submit={this.handleSubmit}
server_error={serverError}
updateSection={this.handleCancel}
/>
);
} else {
let describe = '';
if (this.state.notifyCommentsLevel === 'never') {
describe = (
<FormattedMessage
id='user.settings.notifications.commentsNever'
defaultMessage='No mentions for comments'
/>
);
} else if (this.state.notifyCommentsLevel === 'root') {
describe = (
<FormattedMessage
id='user.settings.notifications.commentsRoot'
defaultMessage='Mention any comments on your post'
/>
);
} else {
describe = (
<FormattedMessage
id='user.settings.notifications.commentsAny'
defaultMessage='Mention any comments in a thread you participated in (This will include both mentions to your root post and any comments after you commented on a post)'
/>
);
}
handleUpdateCommentsSection = function updateCommentsSection() {
this.props.updateSection('comments');
}.bind(this);
commentsSection = (
<SettingItemMin
title={formatMessage(holders.comments)}
describe={describe}
updateSection={handleUpdateCommentsSection}
/>
);
}
const pushNotificationSection = this.createPushNotificationSection(); const pushNotificationSection = this.createPushNotificationSection();
return ( return (
@@ -952,6 +1086,8 @@ class NotificationsTab extends React.Component {
{pushNotificationSection} {pushNotificationSection}
<div className='divider-light'/> <div className='divider-light'/>
{keysSection} {keysSection}
<div className='divider-light'/>
{commentsSection}
<div className='divider-dark'/> <div className='divider-dark'/>
</div> </div>
</div> </div>

Просмотреть файл

@@ -1610,6 +1610,11 @@
"user.settings.notification.soundConfig": "Please configure notification sounds in your browser settings", "user.settings.notification.soundConfig": "Please configure notification sounds in your browser settings",
"user.settings.notifications.channelWide": "Channel-wide mentions \"@channel\", \"@all\"", "user.settings.notifications.channelWide": "Channel-wide mentions \"@channel\", \"@all\"",
"user.settings.notifications.close": "Close", "user.settings.notifications.close": "Close",
"user.settings.notifications.comments": "Comment threads notifications",
"user.settings.notifications.commentsAny": "Mention any comments in a thread you participated in (This will include both mentions to your root post and any comments after you commented on a post)",
"user.settings.notifications.commentsInfo": "Mode of triggering notifications on posts in comment threads you participated in.",
"user.settings.notifications.commentsNever": "No mentions for comments",
"user.settings.notifications.commentsRoot": "Mention any comments on your post",
"user.settings.notifications.desktop": "Send desktop notifications", "user.settings.notifications.desktop": "Send desktop notifications",
"user.settings.notifications.desktopSounds": "Desktop notification sounds", "user.settings.notifications.desktopSounds": "Desktop notification sounds",
"user.settings.notifications.emailInfo": "Email notifications are sent for mentions and direct messages after youve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.", "user.settings.notifications.emailInfo": "Email notifications are sent for mentions and direct messages after youve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.",

Просмотреть файл

@@ -560,7 +560,7 @@ body.ios {
.img-div { .img-div {
max-height: 150px; max-height: 150px;
max-width: 150px; max-width: 150px;
} }
p { p {
line-height: inherit; line-height: inherit;
@@ -572,7 +572,7 @@ body.ios {
ol, ol,
ul { ul {
clear: both; clear: both;
padding-left: 20px; padding-left: 20px;
} }
} }
@@ -1070,7 +1070,6 @@ body.ios {
display: inline-block; display: inline-block;
margin-right: 6px; margin-right: 6px;
visibility: hidden; visibility: hidden;
svg { svg {
fill: inherit; fill: inherit;
position: relative; position: relative;