* MM-41565,MM-42473: fixes incorrect unread thread count The infamous "-1 unread threads" bug, was due to incorrectly counting previous unread mentions and replies. When we calculate the previous counts, we didn't take into account if a thread was newly followed one or not. So when a user gets added to a thread via a mention receives a WS event with the previous count values calculated by subtracting "1" from the current count values. In the result the webapp subtracts the previous values for the thread from the total values of all threads for the user. This is wrong since the user just followed the thread those previous values should be 0. Which results into showing a negative value of unread threads to the user. This commit fixes this issue by zeroing the previous count values when the user was not following the thread previously. * Adds test, and addresses comments * Changes test's description * Removes unneeded assertion * Empty commit just to re-build
1466 строки
47 KiB
Go
1466 строки
47 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/mattermost/mattermost-server/v6/model"
|
|
"github.com/mattermost/mattermost-server/v6/shared/i18n"
|
|
"github.com/mattermost/mattermost-server/v6/shared/markdown"
|
|
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
|
"github.com/mattermost/mattermost-server/v6/store"
|
|
)
|
|
|
|
func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) {
|
|
// Do not send notifications in archived channels
|
|
if channel.DeleteAt > 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
isCRTAllowed := a.Config().FeatureFlags.CollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled
|
|
|
|
pchan := make(chan store.StoreResult, 1)
|
|
go func() {
|
|
props, err := a.Srv().Store.User().GetAllProfilesInChannel(context.Background(), channel.Id, true)
|
|
pchan <- store.StoreResult{Data: props, NErr: err}
|
|
close(pchan)
|
|
}()
|
|
|
|
cmnchan := make(chan store.StoreResult, 1)
|
|
go func() {
|
|
props, err := a.Srv().Store.Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true)
|
|
cmnchan <- store.StoreResult{Data: props, NErr: err}
|
|
close(cmnchan)
|
|
}()
|
|
|
|
var gchan chan store.StoreResult
|
|
if a.allowGroupMentions(post) {
|
|
gchan = make(chan store.StoreResult, 1)
|
|
go func() {
|
|
groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team)
|
|
gchan <- store.StoreResult{Data: groupsMap, NErr: err}
|
|
close(gchan)
|
|
}()
|
|
}
|
|
|
|
var fchan chan store.StoreResult
|
|
if len(post.FileIds) != 0 {
|
|
fchan = make(chan store.StoreResult, 1)
|
|
go func() {
|
|
fileInfos, err := a.Srv().Store.FileInfo().GetForPost(post.Id, true, false, true)
|
|
fchan <- store.StoreResult{Data: fileInfos, NErr: err}
|
|
close(fchan)
|
|
}()
|
|
}
|
|
|
|
var tchan chan store.StoreResult
|
|
if isCRTAllowed && post.RootId != "" {
|
|
tchan = make(chan store.StoreResult, 1)
|
|
go func() {
|
|
followers, err := a.Srv().Store.Thread().GetThreadFollowers(post.RootId, true)
|
|
tchan <- store.StoreResult{Data: followers, NErr: err}
|
|
close(tchan)
|
|
}()
|
|
}
|
|
|
|
result := <-pchan
|
|
if result.NErr != nil {
|
|
return nil, result.NErr
|
|
}
|
|
profileMap := result.Data.(map[string]*model.User)
|
|
|
|
result = <-cmnchan
|
|
if result.NErr != nil {
|
|
return nil, result.NErr
|
|
}
|
|
channelMemberNotifyPropsMap := result.Data.(map[string]model.StringMap)
|
|
|
|
followers := make(model.StringArray, 0)
|
|
if tchan != nil {
|
|
result = <-tchan
|
|
if result.NErr != nil {
|
|
return nil, result.NErr
|
|
}
|
|
followers = result.Data.([]string)
|
|
}
|
|
|
|
groups := make(map[string]*model.Group)
|
|
if gchan != nil {
|
|
result = <-gchan
|
|
if result.NErr != nil {
|
|
return nil, result.NErr
|
|
}
|
|
groups = result.Data.(map[string]*model.Group)
|
|
}
|
|
|
|
mentions := &ExplicitMentions{}
|
|
allActivityPushUserIds := []string{}
|
|
var allowChannelMentions bool
|
|
var keywords map[string][]string
|
|
if channel.Type == model.ChannelTypeDirect {
|
|
otherUserId := channel.GetOtherUserIdForDM(post.UserId)
|
|
|
|
_, ok := profileMap[otherUserId]
|
|
if ok {
|
|
mentions.addMention(otherUserId, DMMention)
|
|
}
|
|
|
|
if post.GetProp("from_webhook") == "true" {
|
|
mentions.addMention(post.UserId, DMMention)
|
|
}
|
|
} else {
|
|
allowChannelMentions = a.allowChannelMentions(post, len(profileMap))
|
|
keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap)
|
|
|
|
mentions = getExplicitMentions(post, keywords, groups)
|
|
// Add an implicit mention when a user is added to a channel
|
|
// even if the user has set 'username mentions' to false in account settings.
|
|
if post.Type == model.PostTypeAddToChannel {
|
|
addedUserId, ok := post.GetProp(model.PostPropsAddedUserId).(string)
|
|
if ok {
|
|
mentions.addMention(addedUserId, KeywordMention)
|
|
}
|
|
}
|
|
|
|
// Iterate through all groups that were mentioned and insert group members into the list of mentions or potential mentions
|
|
for _, group := range mentions.GroupMentions {
|
|
anyUsersMentionedByGroup, err := a.insertGroupMentions(group, channel, profileMap, mentions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !anyUsersMentionedByGroup {
|
|
a.sendNoUsersNotifiedByGroupInChannel(sender, post, channel, group)
|
|
}
|
|
}
|
|
|
|
// get users that have comment thread mentions enabled
|
|
if post.RootId != "" && parentPostList != nil {
|
|
for _, threadPost := range parentPostList.Posts {
|
|
profile := profileMap[threadPost.UserId]
|
|
if profile == nil {
|
|
continue
|
|
}
|
|
// If this is the root post and it was posted by an OAuth bot, don't notify the user
|
|
if threadPost.Id == parentPostList.Order[0] && threadPost.IsFromOAuthBot() {
|
|
continue
|
|
}
|
|
if a.IsCRTEnabledForUser(profile.Id) {
|
|
continue
|
|
}
|
|
if profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyAny || (profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyRoot && threadPost.Id == parentPostList.Order[0]) {
|
|
mentionType := ThreadMention
|
|
if threadPost.Id == parentPostList.Order[0] {
|
|
mentionType = CommentMention
|
|
}
|
|
|
|
mentions.addMention(threadPost.UserId, mentionType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// prevent the user from mentioning themselves
|
|
if post.GetProp("from_webhook") != "true" {
|
|
mentions.removeMention(post.UserId)
|
|
}
|
|
|
|
go func() {
|
|
_, err := a.sendOutOfChannelMentions(sender, post, channel, mentions.OtherPotentialMentions)
|
|
if err != nil {
|
|
mlog.Error("Failed to send warning for out of channel mentions", mlog.String("user_id", sender.Id), mlog.String("post_id", post.Id), mlog.Err(err))
|
|
}
|
|
}()
|
|
|
|
// find which users in the channel are set up to always receive mobile notifications
|
|
// excludes CRT users since those should be added in notificationsForCRT
|
|
for _, profile := range profileMap {
|
|
if (profile.NotifyProps[model.PushNotifyProp] == model.UserNotifyAll ||
|
|
channelMemberNotifyPropsMap[profile.Id][model.PushNotifyProp] == model.ChannelNotifyAll) &&
|
|
(post.UserId != profile.Id || post.GetProp("from_webhook") == "true") &&
|
|
!post.IsSystemMessage() &&
|
|
!(a.IsCRTEnabledForUser(profile.Id) && post.RootId != "") {
|
|
allActivityPushUserIds = append(allActivityPushUserIds, profile.Id)
|
|
}
|
|
}
|
|
}
|
|
|
|
mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions))
|
|
mentionAutofollowChans := []chan *model.AppError{}
|
|
threadParticipants := map[string]bool{post.UserId: true}
|
|
newParticipants := map[string]bool{}
|
|
participantMemberships := map[string]*model.ThreadMembership{}
|
|
membershipsMutex := &sync.Mutex{}
|
|
followersMutex := &sync.Mutex{}
|
|
if *a.Config().ServiceSettings.ThreadAutoFollow && post.RootId != "" {
|
|
var rootMentions *ExplicitMentions
|
|
if parentPostList != nil {
|
|
rootPost := parentPostList.Posts[parentPostList.Order[0]]
|
|
if rootPost.GetProp("from_webhook") != "true" {
|
|
threadParticipants[rootPost.UserId] = true
|
|
}
|
|
if channel.Type != model.ChannelTypeDirect {
|
|
rootMentions = getExplicitMentions(rootPost, keywords, groups)
|
|
for id := range rootMentions.Mentions {
|
|
threadParticipants[id] = true
|
|
}
|
|
}
|
|
}
|
|
for id := range mentions.Mentions {
|
|
threadParticipants[id] = true
|
|
}
|
|
|
|
// sema is a counting semaphore to throttle the number of concurrent DB requests.
|
|
// A concurrency of 8 should be sufficient.
|
|
// We don't want to set a higher limit which can bring down the DB.
|
|
sema := make(chan struct{}, 8)
|
|
// for each mention, make sure to update thread autofollow (if enabled) and update increment mention count
|
|
for id := range threadParticipants {
|
|
mac := make(chan *model.AppError, 1)
|
|
// Get token.
|
|
sema <- struct{}{}
|
|
go func(userID string) {
|
|
defer func() {
|
|
close(mac)
|
|
// Release token.
|
|
<-sema
|
|
}()
|
|
mentionType, incrementMentions := mentions.Mentions[userID]
|
|
// if the user was not explicitly mentioned, check if they explicitly unfollowed the thread
|
|
if !incrementMentions {
|
|
membership, err := a.Srv().Store.Thread().GetMembershipForUser(userID, post.RootId)
|
|
var nfErr *store.ErrNotFound
|
|
|
|
if err != nil && !errors.As(err, &nfErr) {
|
|
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if membership != nil && !membership.Following {
|
|
return
|
|
}
|
|
}
|
|
|
|
updateFollowing := *a.Config().ServiceSettings.ThreadAutoFollow
|
|
if mentionType == ThreadMention || mentionType == CommentMention {
|
|
incrementMentions = false
|
|
updateFollowing = false
|
|
}
|
|
opts := store.ThreadMembershipOpts{
|
|
Following: true,
|
|
IncrementMentions: incrementMentions,
|
|
UpdateFollowing: updateFollowing,
|
|
UpdateViewedTimestamp: false,
|
|
UpdateParticipants: userID == post.UserId,
|
|
}
|
|
threadMembership, err := a.Srv().Store.Thread().MaintainMembership(userID, post.RootId, opts)
|
|
if err != nil {
|
|
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
followersMutex.Lock()
|
|
// add new followers to existing followers
|
|
if threadMembership.Following && !followers.Contains(userID) {
|
|
followers = append(followers, userID)
|
|
newParticipants[userID] = true
|
|
}
|
|
followersMutex.Unlock()
|
|
|
|
membershipsMutex.Lock()
|
|
participantMemberships[userID] = threadMembership
|
|
membershipsMutex.Unlock()
|
|
|
|
mac <- nil
|
|
}(id)
|
|
mentionAutofollowChans = append(mentionAutofollowChans, mac)
|
|
}
|
|
}
|
|
for id := range mentions.Mentions {
|
|
mentionedUsersList = append(mentionedUsersList, id)
|
|
}
|
|
|
|
nErr := a.Srv().Store.Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "")
|
|
if nErr != nil {
|
|
mlog.Warn(
|
|
"Failed to update mention count",
|
|
mlog.String("post_id", post.Id),
|
|
mlog.String("channel_id", post.ChannelId),
|
|
mlog.Err(nErr),
|
|
)
|
|
}
|
|
|
|
// Log the problems that might have occurred while auto following the thread
|
|
for _, mac := range mentionAutofollowChans {
|
|
if err := <-mac; err != nil {
|
|
mlog.Warn(
|
|
"Failed to update thread autofollow from mention",
|
|
mlog.String("post_id", post.Id),
|
|
mlog.String("channel_id", post.ChannelId),
|
|
mlog.Err(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
notificationsForCRT := &CRTNotifiers{}
|
|
if isCRTAllowed && post.RootId != "" {
|
|
for _, uid := range followers {
|
|
profile := profileMap[uid]
|
|
if profile == nil || !a.IsCRTEnabledForUser(uid) {
|
|
continue
|
|
}
|
|
|
|
if post.GetProp("from_webhook") != "true" && uid == post.UserId {
|
|
continue
|
|
}
|
|
|
|
// add user id to notificationsForCRT depending on threads notify props
|
|
notificationsForCRT.addFollowerToNotify(profile, mentions, channelMemberNotifyPropsMap[profile.Id], channel)
|
|
}
|
|
}
|
|
|
|
notification := &PostNotification{
|
|
Post: post.Clone(),
|
|
Channel: channel,
|
|
ProfileMap: profileMap,
|
|
Sender: sender,
|
|
}
|
|
|
|
if *a.Config().EmailSettings.SendEmailNotifications {
|
|
emailRecipients := append(mentionedUsersList, notificationsForCRT.Email...)
|
|
emailRecipients = model.RemoveDuplicateStrings(emailRecipients)
|
|
|
|
for _, id := range emailRecipients {
|
|
if profileMap[id] == nil {
|
|
continue
|
|
}
|
|
|
|
//If email verification is required and user email is not verified don't send email.
|
|
if *a.Config().EmailSettings.RequireEmailVerification && !profileMap[id].EmailVerified {
|
|
mlog.Debug("Skipped sending notification email, address not verified.", mlog.String("user_email", profileMap[id].Email), mlog.String("user_id", id))
|
|
continue
|
|
}
|
|
|
|
if a.userAllowsEmail(profileMap[id], channelMemberNotifyPropsMap[id], post) {
|
|
senderProfileImage, _, err := a.GetProfileImage(sender)
|
|
if err != nil {
|
|
a.Log().Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(err))
|
|
}
|
|
if err := a.sendNotificationEmail(notification, profileMap[id], team, senderProfileImage); err != nil {
|
|
mlog.Warn("Unable to send notification email.", mlog.Err(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for channel-wide mentions in channels that have too many members for those to work
|
|
if int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
|
|
T := i18n.GetUserTranslations(sender.Locale)
|
|
|
|
if mentions.HereMentioned {
|
|
a.SendEphemeralPost(
|
|
post.UserId,
|
|
&model.Post{
|
|
ChannelId: post.ChannelId,
|
|
Message: T("api.post.disabled_here", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
|
|
CreateAt: post.CreateAt + 1,
|
|
},
|
|
)
|
|
}
|
|
|
|
if mentions.ChannelMentioned {
|
|
a.SendEphemeralPost(
|
|
post.UserId,
|
|
&model.Post{
|
|
ChannelId: post.ChannelId,
|
|
Message: T("api.post.disabled_channel", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
|
|
CreateAt: post.CreateAt + 1,
|
|
},
|
|
)
|
|
}
|
|
|
|
if mentions.AllMentioned {
|
|
a.SendEphemeralPost(
|
|
post.UserId,
|
|
&model.Post{
|
|
ChannelId: post.ChannelId,
|
|
Message: T("api.post.disabled_all", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
|
|
CreateAt: post.CreateAt + 1,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
sendPushNotifications := false
|
|
if *a.Config().EmailSettings.SendPushNotifications {
|
|
pushServer := *a.Config().EmailSettings.PushNotificationServer
|
|
if license := a.Srv().License(); pushServer == model.MHPNS && (license == nil || !*license.Features.MHPNS) {
|
|
mlog.Warn("Push notifications are disabled. Go to System Console > Notifications > Mobile Push to enable them.")
|
|
sendPushNotifications = false
|
|
} else {
|
|
sendPushNotifications = true
|
|
}
|
|
}
|
|
|
|
if sendPushNotifications {
|
|
for _, id := range mentionedUsersList {
|
|
if profileMap[id] == nil || notificationsForCRT.Push.Contains(id) {
|
|
continue
|
|
}
|
|
|
|
var status *model.Status
|
|
var err *model.AppError
|
|
if status, err = a.GetStatus(id); err != nil {
|
|
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
|
|
}
|
|
|
|
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], true, status, post) {
|
|
mentionType := mentions.Mentions[id]
|
|
|
|
replyToThreadType := ""
|
|
if mentionType == ThreadMention {
|
|
replyToThreadType = model.CommentsNotifyAny
|
|
} else if mentionType == CommentMention {
|
|
replyToThreadType = model.CommentsNotifyRoot
|
|
}
|
|
|
|
a.sendPushNotification(
|
|
notification,
|
|
profileMap[id],
|
|
mentionType == KeywordMention || mentionType == ChannelMention || mentionType == DMMention,
|
|
mentionType == ChannelMention,
|
|
replyToThreadType,
|
|
)
|
|
} else {
|
|
// register that a notification was not sent
|
|
a.NotificationsLog().Debug("Notification not sent",
|
|
mlog.String("ackId", ""),
|
|
mlog.String("type", model.PushTypeMessage),
|
|
mlog.String("userId", id),
|
|
mlog.String("postId", post.Id),
|
|
mlog.String("status", model.PushNotSent),
|
|
)
|
|
}
|
|
}
|
|
|
|
for _, id := range allActivityPushUserIds {
|
|
if profileMap[id] == nil || notificationsForCRT.Push.Contains(id) {
|
|
continue
|
|
}
|
|
|
|
if _, ok := mentions.Mentions[id]; !ok {
|
|
var status *model.Status
|
|
var err *model.AppError
|
|
if status, err = a.GetStatus(id); err != nil {
|
|
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
|
|
}
|
|
|
|
if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post) {
|
|
a.sendPushNotification(
|
|
notification,
|
|
profileMap[id],
|
|
false,
|
|
false,
|
|
"",
|
|
)
|
|
} else {
|
|
// register that a notification was not sent
|
|
a.NotificationsLog().Debug("Notification not sent",
|
|
mlog.String("ackId", ""),
|
|
mlog.String("type", model.PushTypeMessage),
|
|
mlog.String("userId", id),
|
|
mlog.String("postId", post.Id),
|
|
mlog.String("status", model.PushNotSent),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, id := range notificationsForCRT.Push {
|
|
if profileMap[id] == nil {
|
|
continue
|
|
}
|
|
|
|
var status *model.Status
|
|
var err *model.AppError
|
|
if status, err = a.GetStatus(id); err != nil {
|
|
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
|
|
}
|
|
|
|
if DoesStatusAllowPushNotification(profileMap[id].NotifyProps, status, post.ChannelId) {
|
|
a.sendPushNotification(
|
|
notification,
|
|
profileMap[id],
|
|
false,
|
|
false,
|
|
model.CommentsNotifyCRT,
|
|
)
|
|
} else {
|
|
// register that a notification was not sent
|
|
a.NotificationsLog().Debug("Notification not sent",
|
|
mlog.String("ackId", ""),
|
|
mlog.String("type", model.PushTypeMessage),
|
|
mlog.String("userId", id),
|
|
mlog.String("postId", post.Id),
|
|
mlog.String("status", model.PushNotSent),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPosted, "", post.ChannelId, "", nil)
|
|
|
|
// Note that PreparePostForClient should've already been called by this point
|
|
postJSON, jsonErr := post.ToJSON()
|
|
if jsonErr != nil {
|
|
return nil, errors.Wrapf(jsonErr, "failed to encode post to JSON")
|
|
}
|
|
message.Add("post", postJSON)
|
|
|
|
message.Add("channel_type", channel.Type)
|
|
message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, ""))
|
|
message.Add("channel_name", channel.Name)
|
|
message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride))
|
|
message.Add("team_id", team.Id)
|
|
message.Add("set_online", setOnline)
|
|
|
|
if len(post.FileIds) != 0 && fchan != nil {
|
|
message.Add("otherFile", "true")
|
|
|
|
var infos []*model.FileInfo
|
|
if result := <-fchan; result.NErr != nil {
|
|
mlog.Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(result.NErr))
|
|
} else {
|
|
infos = result.Data.([]*model.FileInfo)
|
|
}
|
|
|
|
for _, info := range infos {
|
|
if info.IsImage() {
|
|
message.Add("image", "true")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(mentionedUsersList) != 0 {
|
|
message.Add("mentions", model.ArrayToJSON(mentionedUsersList))
|
|
}
|
|
|
|
if len(notificationsForCRT.Desktop) != 0 {
|
|
message.Add("followers", model.ArrayToJSON(notificationsForCRT.Desktop))
|
|
}
|
|
|
|
published, err := a.publishWebsocketEventForPermalinkPost(post, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !published {
|
|
a.Publish(message)
|
|
}
|
|
|
|
// If this is a reply in a thread, notify participants
|
|
if isCRTAllowed && post.RootId != "" {
|
|
for _, uid := range followers {
|
|
// A user following a thread but had left the channel won't get a notification
|
|
// https://mattermost.atlassian.net/browse/MM-36769
|
|
if profileMap[uid] == nil {
|
|
continue
|
|
}
|
|
if a.IsCRTEnabledForUser(uid) {
|
|
message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", uid, nil)
|
|
threadMembership := participantMemberships[uid]
|
|
if threadMembership == nil {
|
|
tm, err := a.Srv().Store.Thread().GetMembershipForUser(uid, post.RootId)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "Missing thread membership for participant in notifications. user_id=%q thread_id=%q", uid, post.RootId)
|
|
}
|
|
if tm == nil {
|
|
continue
|
|
}
|
|
threadMembership = tm
|
|
}
|
|
userThread, err := a.Srv().Store.Thread().GetThreadForUser(channel.TeamId, threadMembership, true)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid)
|
|
}
|
|
if userThread != nil {
|
|
previousUnreadMentions := int64(0)
|
|
previousUnreadReplies := int64(0)
|
|
|
|
// if it's not a newly followed thread, calculate previous unread values.
|
|
if !newParticipants[uid] {
|
|
previousUnreadMentions = userThread.UnreadMentions
|
|
previousUnreadReplies = max(userThread.UnreadReplies-1, 0)
|
|
|
|
if mentions.isUserMentioned(uid) {
|
|
previousUnreadMentions = max(userThread.UnreadMentions-1, 0)
|
|
}
|
|
}
|
|
|
|
// set LastViewed to now for commenter
|
|
if uid == post.UserId {
|
|
opts := store.ThreadMembershipOpts{
|
|
UpdateViewedTimestamp: true,
|
|
}
|
|
// should set unread mentions, and unread replies to 0
|
|
_, err = a.Srv().Store.Thread().MaintainMembership(uid, post.RootId, opts)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "cannot maintain thread membership %q for user %q", post.RootId, uid)
|
|
}
|
|
userThread.UnreadMentions = 0
|
|
userThread.UnreadReplies = 0
|
|
}
|
|
a.sanitizeProfiles(userThread.Participants, false)
|
|
userThread.Post.SanitizeProps()
|
|
|
|
sanitizedPost, err := a.SanitizePostMetadataForUser(userThread.Post, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userThread.Post = sanitizedPost
|
|
|
|
payload, jsonErr := json.Marshal(userThread)
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode thread to JSON")
|
|
}
|
|
message.Add("thread", string(payload))
|
|
message.Add("previous_unread_mentions", previousUnreadMentions)
|
|
message.Add("previous_unread_replies", previousUnreadReplies)
|
|
|
|
a.Publish(message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return mentionedUsersList, nil
|
|
}
|
|
|
|
func max(a, b int64) int64 {
|
|
if a < b {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
func (a *App) userAllowsEmail(user *model.User, channelMemberNotificationProps model.StringMap, post *model.Post) bool {
|
|
// if user is a bot account, then we do not send email
|
|
if user.IsBot {
|
|
return false
|
|
}
|
|
|
|
userAllowsEmails := user.NotifyProps[model.EmailNotifyProp] != "false"
|
|
|
|
// if CRT is ON for user and the post is a reply disregard the channelEmail setting
|
|
if channelEmail, ok := channelMemberNotificationProps[model.EmailNotifyProp]; ok && !(a.IsCRTEnabledForUser(user.Id) && post.RootId != "") {
|
|
if channelEmail != model.ChannelNotifyDefault {
|
|
userAllowsEmails = channelEmail != "false"
|
|
}
|
|
}
|
|
|
|
// Remove the user as recipient when the user has muted the channel.
|
|
if channelMuted, ok := channelMemberNotificationProps[model.MarkUnreadNotifyProp]; ok {
|
|
if channelMuted == model.ChannelMarkUnreadMention {
|
|
mlog.Debug("Channel muted for user", mlog.String("user_id", user.Id), mlog.String("channel_mute", channelMuted))
|
|
userAllowsEmails = false
|
|
}
|
|
}
|
|
|
|
var status *model.Status
|
|
var err *model.AppError
|
|
if status, err = a.GetStatus(user.Id); err != nil {
|
|
status = &model.Status{
|
|
UserId: user.Id,
|
|
Status: model.StatusOffline,
|
|
Manual: false,
|
|
LastActivityAt: 0,
|
|
ActiveChannel: "",
|
|
}
|
|
}
|
|
|
|
autoResponderRelated := status.Status == model.StatusOutOfOffice || post.Type == model.PostTypeAutoResponder
|
|
emailNotificationsAllowedForStatus := status.Status != model.StatusOnline && status.Status != model.StatusDnd
|
|
|
|
return userAllowsEmails && emailNotificationsAllowedForStatus && user.DeleteAt == 0 && !autoResponderRelated
|
|
}
|
|
|
|
func (a *App) sendNoUsersNotifiedByGroupInChannel(sender *model.User, post *model.Post, channel *model.Channel, group *model.Group) {
|
|
T := i18n.GetUserTranslations(sender.Locale)
|
|
ephemeralPost := &model.Post{
|
|
UserId: sender.Id,
|
|
RootId: post.RootId,
|
|
ChannelId: channel.Id,
|
|
Message: T("api.post.check_for_out_of_channel_group_users.message.none", model.StringInterface{"GroupName": group.Name}),
|
|
}
|
|
a.SendEphemeralPost(post.UserId, ephemeralPost)
|
|
}
|
|
|
|
// sendOutOfChannelMentions sends an ephemeral post to the sender of a post if any of the given potential mentions
|
|
// are outside of the post's channel. Returns whether or not an ephemeral post was sent.
|
|
func (a *App) sendOutOfChannelMentions(sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) (bool, error) {
|
|
outOfChannelUsers, outOfGroupsUsers, err := a.filterOutOfChannelMentions(sender, post, channel, potentialMentions)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if len(outOfChannelUsers) == 0 && len(outOfGroupsUsers) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
a.SendEphemeralPost(post.UserId, makeOutOfChannelMentionPost(sender, post, outOfChannelUsers, outOfGroupsUsers))
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (a *App) FilterUsersByVisible(viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) {
|
|
result := []*model.User{}
|
|
for _, user := range otherUsers {
|
|
canSee, err := a.UserCanSeeOtherUser(viewer.Id, user.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if canSee {
|
|
result = append(result, user)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (a *App) filterOutOfChannelMentions(sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) ([]*model.User, []*model.User, error) {
|
|
if post.IsSystemMessage() {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if channel.TeamId == "" || channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if len(potentialMentions) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
users, err := a.Srv().Store.User().GetProfilesByUsernames(potentialMentions, &model.ViewUsersRestrictions{Teams: []string{channel.TeamId}})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Filter out inactive users and bots
|
|
allUsers := model.UserSlice(users).FilterByActive(true)
|
|
allUsers = allUsers.FilterWithoutBots()
|
|
allUsers, appErr := a.FilterUsersByVisible(sender, allUsers)
|
|
if appErr != nil {
|
|
return nil, nil, appErr
|
|
}
|
|
|
|
if len(allUsers) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Differentiate between users who can and can't be added to the channel
|
|
var outOfChannelUsers model.UserSlice
|
|
var outOfGroupsUsers model.UserSlice
|
|
if channel.IsGroupConstrained() {
|
|
nonMemberIDs, err := a.FilterNonGroupChannelMembers(allUsers.IDs(), channel)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
outOfChannelUsers = allUsers.FilterWithoutID(nonMemberIDs)
|
|
outOfGroupsUsers = allUsers.FilterByID(nonMemberIDs)
|
|
} else {
|
|
outOfChannelUsers = allUsers
|
|
}
|
|
|
|
return outOfChannelUsers, outOfGroupsUsers, nil
|
|
}
|
|
|
|
func makeOutOfChannelMentionPost(sender *model.User, post *model.Post, outOfChannelUsers, outOfGroupsUsers []*model.User) *model.Post {
|
|
allUsers := model.UserSlice(append(outOfChannelUsers, outOfGroupsUsers...))
|
|
|
|
ocUsers := model.UserSlice(outOfChannelUsers)
|
|
ocUsernames := ocUsers.Usernames()
|
|
ocUserIDs := ocUsers.IDs()
|
|
|
|
ogUsers := model.UserSlice(outOfGroupsUsers)
|
|
ogUsernames := ogUsers.Usernames()
|
|
|
|
T := i18n.GetUserTranslations(sender.Locale)
|
|
|
|
ephemeralPostId := model.NewId()
|
|
var message string
|
|
if len(outOfChannelUsers) == 1 {
|
|
message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{
|
|
"Username": ocUsernames[0],
|
|
})
|
|
} else if len(outOfChannelUsers) > 1 {
|
|
preliminary, final := splitAtFinal(ocUsernames)
|
|
|
|
message = T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]interface{}{
|
|
"Usernames": strings.Join(preliminary, ", @"),
|
|
"LastUsername": final,
|
|
})
|
|
}
|
|
|
|
if len(outOfGroupsUsers) == 1 {
|
|
if message != "" {
|
|
message += "\n"
|
|
}
|
|
|
|
message += T("api.post.check_for_out_of_channel_groups_mentions.message.one", map[string]interface{}{
|
|
"Username": ogUsernames[0],
|
|
})
|
|
} else if len(outOfGroupsUsers) > 1 {
|
|
preliminary, final := splitAtFinal(ogUsernames)
|
|
|
|
if message != "" {
|
|
message += "\n"
|
|
}
|
|
|
|
message += T("api.post.check_for_out_of_channel_groups_mentions.message.multiple", map[string]interface{}{
|
|
"Usernames": strings.Join(preliminary, ", @"),
|
|
"LastUsername": final,
|
|
})
|
|
}
|
|
|
|
props := model.StringInterface{
|
|
model.PropsAddChannelMember: model.StringInterface{
|
|
"post_id": ephemeralPostId,
|
|
|
|
"usernames": allUsers.Usernames(), // Kept for backwards compatibility of mobile app.
|
|
"not_in_channel_usernames": ocUsernames,
|
|
|
|
"user_ids": allUsers.IDs(), // Kept for backwards compatibility of mobile app.
|
|
"not_in_channel_user_ids": ocUserIDs,
|
|
|
|
"not_in_groups_usernames": ogUsernames,
|
|
"not_in_groups_user_ids": ogUsers.IDs(),
|
|
},
|
|
}
|
|
|
|
return &model.Post{
|
|
Id: ephemeralPostId,
|
|
RootId: post.RootId,
|
|
ChannelId: post.ChannelId,
|
|
Message: message,
|
|
CreateAt: post.CreateAt + 1,
|
|
Props: props,
|
|
}
|
|
}
|
|
|
|
func splitAtFinal(items []string) (preliminary []string, final string) {
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
preliminary = items[:len(items)-1]
|
|
final = items[len(items)-1]
|
|
return
|
|
}
|
|
|
|
type ExplicitMentions struct {
|
|
// Mentions contains the ID of each user that was mentioned and how they were mentioned.
|
|
Mentions map[string]MentionType
|
|
|
|
// Contains a map of groups that were mentioned
|
|
GroupMentions map[string]*model.Group
|
|
|
|
// OtherPotentialMentions contains a list of strings that looked like mentions, but didn't have
|
|
// a corresponding keyword.
|
|
OtherPotentialMentions []string
|
|
|
|
// HereMentioned is true if the message contained @here.
|
|
HereMentioned bool
|
|
|
|
// AllMentioned is true if the message contained @all.
|
|
AllMentioned bool
|
|
|
|
// ChannelMentioned is true if the message contained @channel.
|
|
ChannelMentioned bool
|
|
}
|
|
|
|
type MentionType int
|
|
|
|
const (
|
|
// Different types of mentions ordered by their priority from lowest to highest
|
|
|
|
// A placeholder that should never be used in practice
|
|
NoMention MentionType = iota
|
|
|
|
// The post is in a thread that the user has commented on
|
|
ThreadMention
|
|
|
|
// The post is a comment on a thread started by the user
|
|
CommentMention
|
|
|
|
// The post contains an at-channel, at-all, or at-here
|
|
ChannelMention
|
|
|
|
// The post is a DM
|
|
DMMention
|
|
|
|
// The post contains an at-mention for the user
|
|
KeywordMention
|
|
|
|
// The post contains a group mention for the user
|
|
GroupMention
|
|
)
|
|
|
|
func (m *ExplicitMentions) isUserMentioned(userID string) bool {
|
|
if _, ok := m.Mentions[userID]; ok {
|
|
return true
|
|
}
|
|
|
|
if _, ok := m.GroupMentions[userID]; ok {
|
|
return true
|
|
}
|
|
|
|
return m.HereMentioned || m.AllMentioned || m.ChannelMentioned
|
|
}
|
|
|
|
func (m *ExplicitMentions) addMention(userID string, mentionType MentionType) {
|
|
if m.Mentions == nil {
|
|
m.Mentions = make(map[string]MentionType)
|
|
}
|
|
|
|
if currentType, ok := m.Mentions[userID]; ok && currentType >= mentionType {
|
|
return
|
|
}
|
|
|
|
m.Mentions[userID] = mentionType
|
|
}
|
|
|
|
func (m *ExplicitMentions) addGroupMention(word string, groups map[string]*model.Group) bool {
|
|
if strings.HasPrefix(word, "@") {
|
|
word = word[1:]
|
|
} else {
|
|
// Only allow group mentions when mentioned directly with @group-name
|
|
return false
|
|
}
|
|
|
|
group, groupFound := groups[word]
|
|
if !groupFound {
|
|
group = groups[strings.ToLower(word)]
|
|
}
|
|
|
|
if group == nil {
|
|
return false
|
|
}
|
|
|
|
if m.GroupMentions == nil {
|
|
m.GroupMentions = make(map[string]*model.Group)
|
|
}
|
|
|
|
if group.Name != nil {
|
|
m.GroupMentions[*group.Name] = group
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (m *ExplicitMentions) addMentions(userIDs []string, mentionType MentionType) {
|
|
for _, userID := range userIDs {
|
|
m.addMention(userID, mentionType)
|
|
}
|
|
}
|
|
|
|
func (m *ExplicitMentions) removeMention(userID string) {
|
|
delete(m.Mentions, userID)
|
|
}
|
|
|
|
// Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned
|
|
// users and a slice of potential mention users not in the channel and whether or not @here was mentioned.
|
|
func getExplicitMentions(post *model.Post, keywords map[string][]string, groups map[string]*model.Group) *ExplicitMentions {
|
|
ret := &ExplicitMentions{}
|
|
|
|
buf := ""
|
|
mentionsEnabledFields := getMentionsEnabledFields(post)
|
|
for _, message := range mentionsEnabledFields {
|
|
markdown.Inspect(message, func(node interface{}) bool {
|
|
text, ok := node.(*markdown.Text)
|
|
if !ok {
|
|
ret.processText(buf, keywords, groups)
|
|
buf = ""
|
|
return true
|
|
}
|
|
buf += text.Text
|
|
return false
|
|
})
|
|
}
|
|
ret.processText(buf, keywords, groups)
|
|
|
|
return ret
|
|
}
|
|
|
|
// Given a post returns the values of the fields in which mentions are possible.
|
|
// post.message, preText and text in the attachment are enabled.
|
|
func getMentionsEnabledFields(post *model.Post) model.StringArray {
|
|
ret := []string{}
|
|
|
|
ret = append(ret, post.Message)
|
|
for _, attachment := range post.Attachments() {
|
|
|
|
if attachment.Pretext != "" {
|
|
ret = append(ret, attachment.Pretext)
|
|
}
|
|
if attachment.Text != "" {
|
|
ret = append(ret, attachment.Text)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// allowChannelMentions returns whether or not the channel mentions are allowed for the given post.
|
|
func (a *App) allowChannelMentions(post *model.Post, numProfiles int) bool {
|
|
if !a.HasPermissionToChannel(post.UserId, post.ChannelId, model.PermissionUseChannelMentions) {
|
|
return false
|
|
}
|
|
|
|
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
|
|
return false
|
|
}
|
|
|
|
if int64(numProfiles) >= *a.Config().TeamSettings.MaxNotificationsPerChannel {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// allowGroupMentions returns whether or not the group mentions are allowed for the given post.
|
|
func (a *App) allowGroupMentions(post *model.Post) bool {
|
|
if license := a.Srv().License(); license == nil || (license.SkuShortName != model.LicenseShortSkuProfessional && license.SkuShortName != model.LicenseShortSkuEnterprise) {
|
|
return false
|
|
}
|
|
|
|
if !a.HasPermissionToChannel(post.UserId, post.ChannelId, model.PermissionUseGroupMentions) {
|
|
return false
|
|
}
|
|
|
|
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// getGroupsAllowedForReferenceInChannel returns a map of groups allowed for reference in a given channel and team.
|
|
func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team *model.Team) (map[string]*model.Group, error) {
|
|
var err error
|
|
groupsMap := make(map[string]*model.Group)
|
|
opts := model.GroupSearchOpts{FilterAllowReference: true, IncludeMemberCount: true}
|
|
|
|
if channel.IsGroupConstrained() || (team != nil && team.IsGroupConstrained()) {
|
|
var groups []*model.GroupWithSchemeAdmin
|
|
if channel.IsGroupConstrained() {
|
|
groups, err = a.Srv().Store.Group().GetGroupsByChannel(channel.Id, opts)
|
|
} else {
|
|
groups, err = a.Srv().Store.Group().GetGroupsByTeam(team.Id, opts)
|
|
}
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "unable to get groups")
|
|
}
|
|
for _, group := range groups {
|
|
if group.Group.Name != nil {
|
|
groupsMap[*group.Group.Name] = &group.Group
|
|
}
|
|
}
|
|
return groupsMap, nil
|
|
}
|
|
|
|
groups, err := a.Srv().Store.Group().GetGroups(0, 0, opts)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "unable to get groups")
|
|
}
|
|
for _, group := range groups {
|
|
if group.Name != nil {
|
|
groupsMap[*group.Name] = group
|
|
}
|
|
}
|
|
|
|
return groupsMap, nil
|
|
}
|
|
|
|
// Given a map of user IDs to profiles, returns a list of mention
|
|
// keywords for all users in the channel.
|
|
func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allowChannelMentions bool, channelMemberNotifyPropsMap map[string]model.StringMap) map[string][]string {
|
|
keywords := make(map[string][]string)
|
|
|
|
for _, profile := range profiles {
|
|
addMentionKeywordsForUser(
|
|
keywords,
|
|
profile,
|
|
channelMemberNotifyPropsMap[profile.Id],
|
|
a.GetStatusFromCache(profile.Id),
|
|
allowChannelMentions,
|
|
)
|
|
}
|
|
|
|
return keywords
|
|
}
|
|
|
|
// insertGroupMentions adds group members in the channel to Mentions, adds group members not in the channel to OtherPotentialMentions
|
|
// returns false if no group members present in the team that the channel belongs to
|
|
func (a *App) insertGroupMentions(group *model.Group, channel *model.Channel, profileMap map[string]*model.User, mentions *ExplicitMentions) (bool, *model.AppError) {
|
|
var err error
|
|
var groupMembers []*model.User
|
|
outOfChannelGroupMembers := []*model.User{}
|
|
isGroupOrDirect := channel.IsGroupOrDirect()
|
|
|
|
if isGroupOrDirect {
|
|
groupMembers, err = a.Srv().Store.Group().GetMemberUsers(group.Id)
|
|
} else {
|
|
groupMembers, err = a.Srv().Store.Group().GetMemberUsersInTeam(group.Id, channel.TeamId)
|
|
}
|
|
|
|
if err != nil {
|
|
return false, model.NewAppError("insertGroupMentions", "app.select_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
if mentions.Mentions == nil {
|
|
mentions.Mentions = make(map[string]MentionType)
|
|
}
|
|
|
|
for _, member := range groupMembers {
|
|
if _, ok := profileMap[member.Id]; ok {
|
|
mentions.Mentions[member.Id] = GroupMention
|
|
} else {
|
|
outOfChannelGroupMembers = append(outOfChannelGroupMembers, member)
|
|
}
|
|
}
|
|
|
|
potentialGroupMembersMentioned := []string{}
|
|
for _, user := range outOfChannelGroupMembers {
|
|
potentialGroupMembersMentioned = append(potentialGroupMembersMentioned, user.Username)
|
|
}
|
|
if mentions.OtherPotentialMentions == nil {
|
|
mentions.OtherPotentialMentions = potentialGroupMembersMentioned
|
|
} else {
|
|
mentions.OtherPotentialMentions = append(mentions.OtherPotentialMentions, potentialGroupMembersMentioned...)
|
|
}
|
|
|
|
return isGroupOrDirect || len(groupMembers) > 0, nil
|
|
}
|
|
|
|
// addMentionKeywordsForUser adds the mention keywords for a given user to the given keyword map. Returns the provided keyword map.
|
|
func addMentionKeywordsForUser(keywords map[string][]string, profile *model.User, channelNotifyProps map[string]string, status *model.Status, allowChannelMentions bool) map[string][]string {
|
|
userMention := "@" + strings.ToLower(profile.Username)
|
|
keywords[userMention] = append(keywords[userMention], profile.Id)
|
|
|
|
// Add all the user's mention keys
|
|
for _, k := range profile.GetMentionKeys() {
|
|
// note that these are made lower case so that we can do a case insensitive check for them
|
|
key := strings.ToLower(k)
|
|
|
|
if key != "" {
|
|
keywords[key] = append(keywords[key], profile.Id)
|
|
}
|
|
}
|
|
|
|
// If turned on, add the user's case sensitive first name
|
|
if profile.NotifyProps[model.FirstNameNotifyProp] == "true" && profile.FirstName != "" {
|
|
keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id)
|
|
}
|
|
|
|
// Add @channel and @all to keywords if user has them turned on and the server allows them
|
|
if allowChannelMentions {
|
|
// Ignore channel mentions if channel is muted and channel mention setting is default
|
|
ignoreChannelMentions := channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsOn || (channelNotifyProps[model.MarkUnreadNotifyProp] == model.UserNotifyMention && channelNotifyProps[model.IgnoreChannelMentionsNotifyProp] == model.IgnoreChannelMentionsDefault)
|
|
|
|
if profile.NotifyProps[model.ChannelMentionsNotifyProp] == "true" && !ignoreChannelMentions {
|
|
keywords["@channel"] = append(keywords["@channel"], profile.Id)
|
|
keywords["@all"] = append(keywords["@all"], profile.Id)
|
|
|
|
if status != nil && status.Status == model.StatusOnline {
|
|
keywords["@here"] = append(keywords["@here"], profile.Id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return keywords
|
|
}
|
|
|
|
// Represents either an email or push notification and contains the fields required to send it to any user.
|
|
type PostNotification struct {
|
|
Channel *model.Channel
|
|
Post *model.Post
|
|
ProfileMap map[string]*model.User
|
|
Sender *model.User
|
|
}
|
|
|
|
// Returns the name of the channel for this notification. For direct messages, this is the sender's name
|
|
// preceded by an at sign. For group messages, this is a comma-separated list of the members of the
|
|
// channel, with an option to exclude the recipient of the message from that list.
|
|
func (n *PostNotification) GetChannelName(userNameFormat, excludeId string) string {
|
|
switch n.Channel.Type {
|
|
case model.ChannelTypeDirect:
|
|
return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@")
|
|
case model.ChannelTypeGroup:
|
|
names := []string{}
|
|
for _, user := range n.ProfileMap {
|
|
if user.Id != excludeId {
|
|
names = append(names, user.GetDisplayName(userNameFormat))
|
|
}
|
|
}
|
|
|
|
sort.Strings(names)
|
|
|
|
return strings.Join(names, ", ")
|
|
default:
|
|
return n.Channel.DisplayName
|
|
}
|
|
}
|
|
|
|
// Returns the name of the sender of this notification, accounting for things like system messages
|
|
// and whether or not the username has been overridden by an integration.
|
|
func (n *PostNotification) GetSenderName(userNameFormat string, overridesAllowed bool) string {
|
|
if n.Post.IsSystemMessage() {
|
|
return i18n.T("system.message.name")
|
|
}
|
|
|
|
if overridesAllowed && n.Channel.Type != model.ChannelTypeDirect {
|
|
if value := n.Post.GetProps()["override_username"]; value != nil && n.Post.GetProp("from_webhook") == "true" {
|
|
if s, ok := value.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
|
|
return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@")
|
|
}
|
|
|
|
// checkForMention checks if there is a mention to a specific user or to the keywords here / channel / all
|
|
func (m *ExplicitMentions) checkForMention(word string, keywords map[string][]string, groups map[string]*model.Group) bool {
|
|
var mentionType MentionType
|
|
|
|
switch strings.ToLower(word) {
|
|
case "@here":
|
|
m.HereMentioned = true
|
|
mentionType = ChannelMention
|
|
case "@channel":
|
|
m.ChannelMentioned = true
|
|
mentionType = ChannelMention
|
|
case "@all":
|
|
m.AllMentioned = true
|
|
mentionType = ChannelMention
|
|
default:
|
|
mentionType = KeywordMention
|
|
}
|
|
|
|
m.addGroupMention(word, groups)
|
|
|
|
if ids, match := keywords[strings.ToLower(word)]; match {
|
|
m.addMentions(ids, mentionType)
|
|
return true
|
|
}
|
|
|
|
// Case-sensitive check for first name
|
|
if ids, match := keywords[word]; match {
|
|
m.addMentions(ids, mentionType)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isKeywordMultibyte checks if a word containing a multibyte character contains a multibyte keyword
|
|
func isKeywordMultibyte(keywords map[string][]string, word string) ([]string, bool) {
|
|
ids := []string{}
|
|
match := false
|
|
var multibyteKeywords []string
|
|
for keyword := range keywords {
|
|
if len(keyword) != utf8.RuneCountInString(keyword) {
|
|
multibyteKeywords = append(multibyteKeywords, keyword)
|
|
}
|
|
}
|
|
|
|
if len(word) != utf8.RuneCountInString(word) {
|
|
for _, key := range multibyteKeywords {
|
|
if strings.Contains(word, key) {
|
|
ids, match = keywords[key]
|
|
}
|
|
}
|
|
}
|
|
return ids, match
|
|
}
|
|
|
|
// Processes text to filter mentioned users and other potential mentions
|
|
func (m *ExplicitMentions) processText(text string, keywords map[string][]string, groups map[string]*model.Group) {
|
|
systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true}
|
|
|
|
for _, word := range strings.FieldsFunc(text, func(c rune) bool {
|
|
// Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern
|
|
return !(c == ':' || c == '.' || c == '-' || c == '_' || c == '@' || unicode.IsLetter(c) || unicode.IsNumber(c))
|
|
}) {
|
|
// skip word with format ':word:' with an assumption that it is an emoji format only
|
|
if word[0] == ':' && word[len(word)-1] == ':' {
|
|
continue
|
|
}
|
|
|
|
word = strings.TrimLeft(word, ":.-_")
|
|
|
|
if m.checkForMention(word, keywords, groups) {
|
|
continue
|
|
}
|
|
|
|
foundWithoutSuffix := false
|
|
wordWithoutSuffix := word
|
|
|
|
for wordWithoutSuffix != "" && strings.LastIndexAny(wordWithoutSuffix, ".-:_") == (len(wordWithoutSuffix)-1) {
|
|
wordWithoutSuffix = wordWithoutSuffix[0 : len(wordWithoutSuffix)-1]
|
|
|
|
if m.checkForMention(wordWithoutSuffix, keywords, groups) {
|
|
foundWithoutSuffix = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if foundWithoutSuffix {
|
|
continue
|
|
}
|
|
|
|
if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") {
|
|
// No need to bother about unicode as we are looking for ASCII characters.
|
|
last := word[len(word)-1]
|
|
switch last {
|
|
// If the word is possibly at the end of a sentence, remove that character.
|
|
case '.', '-', ':':
|
|
word = word[:len(word)-1]
|
|
}
|
|
m.OtherPotentialMentions = append(m.OtherPotentialMentions, word[1:])
|
|
} else if strings.ContainsAny(word, ".-:") {
|
|
// This word contains a character that may be the end of a sentence, so split further
|
|
splitWords := strings.FieldsFunc(word, func(c rune) bool {
|
|
return c == '.' || c == '-' || c == ':'
|
|
})
|
|
|
|
for _, splitWord := range splitWords {
|
|
if m.checkForMention(splitWord, keywords, groups) {
|
|
continue
|
|
}
|
|
if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") {
|
|
m.OtherPotentialMentions = append(m.OtherPotentialMentions, splitWord[1:])
|
|
}
|
|
}
|
|
}
|
|
|
|
if ids, match := isKeywordMultibyte(keywords, word); match {
|
|
m.addMentions(ids, KeywordMention)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) GetNotificationNameFormat(user *model.User) string {
|
|
if !*a.Config().PrivacySettings.ShowFullName {
|
|
return model.ShowUsername
|
|
}
|
|
|
|
data, err := a.Srv().Store.Preference().Get(user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameNameFormat)
|
|
if err != nil {
|
|
return *a.Config().TeamSettings.TeammateNameDisplay
|
|
}
|
|
|
|
return data.Value
|
|
}
|
|
|
|
type CRTNotifiers struct {
|
|
// Desktop contains the user IDs of thread followers to receive desktop notification
|
|
Desktop model.StringArray
|
|
|
|
// Email contains the user IDs of thread followers to receive email notification
|
|
Email model.StringArray
|
|
|
|
// Push contains the user IDs of thread followers to receive push notification
|
|
Push model.StringArray
|
|
}
|
|
|
|
func (c *CRTNotifiers) addFollowerToNotify(user *model.User, mentions *ExplicitMentions, channelMemberNotificationProps model.StringMap, channel *model.Channel) {
|
|
_, userWasMentioned := mentions.Mentions[user.Id]
|
|
notifyDesktop, notifyPush, notifyEmail := shouldUserNotifyCRT(user, userWasMentioned)
|
|
notifyChannelDesktop, notifyChannelPush := shouldChannelMemberNotifyCRT(channelMemberNotificationProps, userWasMentioned)
|
|
|
|
// respect the user global notify props when there are no channel specific ones (default)
|
|
// otherwise respect the channel member's notify props
|
|
if (channelMemberNotificationProps[model.DesktopNotifyProp] == model.ChannelNotifyDefault && notifyDesktop) || notifyChannelDesktop {
|
|
c.Desktop = append(c.Desktop, user.Id)
|
|
}
|
|
|
|
if notifyEmail {
|
|
c.Email = append(c.Email, user.Id)
|
|
}
|
|
|
|
// respect the user global notify props when there are no channel specific ones (default)
|
|
// otherwise respect the channel member's notify props
|
|
if (channelMemberNotificationProps[model.PushNotifyProp] == model.ChannelNotifyDefault && notifyPush) || notifyChannelPush {
|
|
c.Push = append(c.Push, user.Id)
|
|
}
|
|
}
|
|
|
|
// user global settings check for desktop, email, and push notifications
|
|
func shouldUserNotifyCRT(user *model.User, isMentioned bool) (notifyDesktop, notifyPush, notifyEmail bool) {
|
|
notifyDesktop = false
|
|
notifyPush = false
|
|
notifyEmail = false
|
|
|
|
desktop := user.NotifyProps[model.DesktopNotifyProp]
|
|
push := user.NotifyProps[model.PushNotifyProp]
|
|
shouldEmail := user.NotifyProps[model.EmailNotifyProp] == "true"
|
|
|
|
desktopThreads := user.NotifyProps[model.DesktopThreadsNotifyProp]
|
|
emailThreads := user.NotifyProps[model.EmailThreadsNotifyProp]
|
|
pushThreads := user.NotifyProps[model.PushThreadsNotifyProp]
|
|
|
|
// user should be notified via desktop notification in the case the notify prop is not set as no notify
|
|
// and either the user was mentioned or the CRT notify prop for desktop is set to all
|
|
if desktop != model.UserNotifyNone && (isMentioned || desktopThreads == model.UserNotifyAll || desktop == model.UserNotifyAll) {
|
|
notifyDesktop = true
|
|
}
|
|
|
|
// user should be notified via email when emailing is enabled and
|
|
// either the user was mentioned, or the CRT notify prop for email is set to all
|
|
if shouldEmail && (isMentioned || emailThreads == model.UserNotifyAll) {
|
|
notifyEmail = true
|
|
}
|
|
|
|
// user should be notified via push in the case the notify prop is not set as no notify
|
|
// and either the user was mentioned or the CRT push notify prop is set to all
|
|
if push != model.UserNotifyNone && (isMentioned || pushThreads == model.UserNotifyAll || push == model.UserNotifyAll) {
|
|
notifyPush = true
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// channel specific settings check for desktop and push notifications
|
|
func shouldChannelMemberNotifyCRT(notifyProps model.StringMap, isMentioned bool) (notifyDesktop, notifyPush bool) {
|
|
notifyDesktop = false
|
|
notifyPush = false
|
|
|
|
desktop := notifyProps[model.DesktopNotifyProp]
|
|
push := notifyProps[model.PushNotifyProp]
|
|
|
|
desktopThreads := notifyProps[model.DesktopThreadsNotifyProp]
|
|
pushThreads := notifyProps[model.PushThreadsNotifyProp]
|
|
|
|
// user should be notified via desktop notification in the case the notify prop is not set as no notify or default
|
|
// and either the user was mentioned or the CRT notify prop for desktop is set to all
|
|
if desktop != model.ChannelNotifyDefault && desktop != model.ChannelNotifyNone && (isMentioned || desktopThreads == model.ChannelNotifyAll || desktop == model.ChannelNotifyAll) {
|
|
notifyDesktop = true
|
|
}
|
|
|
|
// user should be notified via push in the case the notify prop is not set as no notify or default
|
|
// and either the user was mentioned or the CRT push notify prop is set to all
|
|
if push != model.ChannelNotifyDefault && push != model.ChannelNotifyNone && (isMentioned || pushThreads == model.ChannelNotifyAll || push == model.ChannelNotifyAll) {
|
|
notifyPush = true
|
|
}
|
|
|
|
return
|
|
}
|