diff --git a/server/channels/api4/preference_test.go b/server/channels/api4/preference_test.go index 4c2c15673a..5478da7707 100644 --- a/server/channels/api4/preference_test.go +++ b/server/channels/api4/preference_test.go @@ -50,8 +50,8 @@ func TestGetPreferences(t *testing.T) { prefs, _, err := client.GetPreferences(context.Background(), user1.Id) require.NoError(t, err) - // 5 because we have 2 initial preferences tutorial_step and recommended_next_steps added when creating a new user - require.Equal(t, len(prefs), 5, "received the wrong number of preferences") + // 6 because we have 3 initial preferences tutorial_step, recommended_next_steps and system_notification are added when creating a new user + require.Equal(t, len(prefs), 6, "received the wrong number of preferences") for _, preference := range prefs { require.Equal(t, preference.UserId, th.BasicUser.Id, "user id does not match") diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index 6d1a4a52e2..976644a134 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -387,7 +387,9 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} } - if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], true, status, post) { + isExplicitlyMentioned := mentions.Mentions[id] > GMMention + isGM := channel.Type == model.ChannelTypeGroup + if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], isExplicitlyMentioned, status, post, isGM) { mentionType := mentions.Mentions[id] replyToThreadType := "" @@ -428,7 +430,8 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} } - if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post) { + isGM := channel.Type == model.ChannelTypeGroup + if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post, isGM) { a.sendPushNotification( notification, profileMap[id], @@ -770,6 +773,14 @@ func (a *App) getExplicitMentionsAndKeywords(c request.CTX, post *model.Post, ch keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap) mentions = getExplicitMentions(post, keywords, groups) + + // Add a GM mention to all members of a GM channel + if channel.Type == model.ChannelTypeGroup { + for id := range channelMemberNotifyPropsMap { + mentions.addMention(id, GMMention) + } + } + // 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 { @@ -1063,6 +1074,9 @@ const ( // A placeholder that should never be used in practice NoMention MentionType = iota + // The post is in a GM + GMMention + // The post is in a thread that the user has commented on ThreadMention diff --git a/server/channels/app/notification_push.go b/server/channels/app/notification_push.go index d52868ef38..a928f74376 100644 --- a/server/channels/app/notification_push.go +++ b/server/channels/app/notification_push.go @@ -512,12 +512,12 @@ func (a *App) getMobileAppSessions(userID string) ([]*model.Session, *model.AppE return sessions, nil } -func ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post) bool { - return DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned) && +func ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post, isGM bool) bool { + return DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned, isGM) && DoesStatusAllowPushNotification(user.NotifyProps, status, post.ChannelId) } -func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps model.StringMap, post *model.Post, wasMentioned bool) bool { +func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps model.StringMap, post *model.Post, wasMentioned, isGM bool) bool { userNotifyProps := user.NotifyProps userNotify := userNotifyProps[model.PushNotifyProp] channelNotify, ok := channelNotifyProps[model.PushNotifyProp] @@ -525,6 +525,14 @@ func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps m channelNotify = model.ChannelNotifyDefault } + notify := channelNotify + if channelNotify == model.ChannelNotifyDefault { + notify = userNotify + if isGM && userNotify == model.UserNotifyMention { + notify = model.ChannelNotifyAll + } + } + // If the channel is muted do not send push notifications if channelNotifyProps[model.MarkUnreadNotifyProp] == model.ChannelMarkUnreadMention { return false @@ -534,28 +542,19 @@ func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps m return false } - if channelNotify == model.UserNotifyNone { + if notify == model.ChannelNotifyNone { return false } - if channelNotify == model.ChannelNotifyMention && !wasMentioned { + if notify == model.ChannelNotifyMention && !wasMentioned { return false } - if userNotify == model.UserNotifyMention && channelNotify == model.ChannelNotifyDefault && !wasMentioned { - return false - } - - if (userNotify == model.UserNotifyAll || channelNotify == model.ChannelNotifyAll) && + if (notify == model.ChannelNotifyAll) && (post.UserId != user.Id || post.GetProp("from_webhook") == "true") { return true } - if userNotify == model.UserNotifyNone && - channelNotify == model.ChannelNotifyDefault { - return false - } - return true } diff --git a/server/channels/app/notification_push_test.go b/server/channels/app/notification_push_test.go index 0fd3e8848e..dff3655099 100644 --- a/server/channels/app/notification_push_test.go +++ b/server/channels/app/notification_push_test.go @@ -36,6 +36,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned bool isMuted bool expected bool + isGM bool }{ { name: "When post is a System Message and has no mentions", @@ -45,6 +46,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When post is a System Message and has mentions", @@ -54,6 +56,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, no channel props is set and has no mentions", @@ -63,6 +66,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: true, + isGM: false, }, { name: "When default is ALL, no channel props is set and has mentions", @@ -72,6 +76,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is MENTION, no channel props is set and has no mentions", @@ -81,6 +86,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is MENTION, no channel props is set and has mentions", @@ -90,6 +96,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is NONE, no channel props is set and has no mentions", @@ -99,6 +106,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is NONE, no channel props is set and has mentions", @@ -108,6 +116,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, channel is DEFAULT and has no mentions", @@ -117,6 +126,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: true, + isGM: false, }, { name: "When default is ALL, channel is DEFAULT and has mentions", @@ -126,6 +136,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is MENTION, channel is DEFAULT and has no mentions", @@ -135,6 +146,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is MENTION, channel is DEFAULT and has mentions", @@ -144,6 +156,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is NONE, channel is DEFAULT and has no mentions", @@ -153,6 +166,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is NONE, channel is DEFAULT and has mentions", @@ -162,6 +176,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, channel is ALL and has no mentions", @@ -171,6 +186,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: true, + isGM: false, }, { name: "When default is ALL, channel is ALL and has mentions", @@ -180,6 +196,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is MENTION, channel is ALL and has no mentions", @@ -189,6 +206,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: true, + isGM: false, }, { name: "When default is MENTION, channel is ALL and has mentions", @@ -198,6 +216,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is NONE, channel is ALL and has no mentions", @@ -207,6 +226,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: true, + isGM: false, }, { name: "When default is NONE, channel is ALL and has mentions", @@ -216,6 +236,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is ALL, channel is MENTION and has no mentions", @@ -225,6 +246,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, channel is MENTION and has mentions", @@ -234,6 +256,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is MENTION, channel is MENTION and has no mentions", @@ -243,6 +266,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is MENTION, channel is MENTION and has mentions", @@ -252,6 +276,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is NONE, channel is MENTION and has no mentions", @@ -261,6 +286,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is NONE, channel is MENTION and has mentions", @@ -270,6 +296,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: true, + isGM: false, }, { name: "When default is ALL, channel is NONE and has no mentions", @@ -279,6 +306,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, channel is NONE and has mentions", @@ -288,6 +316,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is MENTION, channel is NONE and has no mentions", @@ -297,6 +326,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is MENTION, channel is NONE and has mentions", @@ -306,6 +336,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is NONE, channel is NONE and has no mentions", @@ -315,6 +346,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: false, expected: false, + isGM: false, }, { name: "When default is NONE, channel is NONE and has mentions", @@ -324,6 +356,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: true, isMuted: false, expected: false, + isGM: false, }, { name: "When default is ALL, and channel is MUTED", @@ -333,6 +366,47 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { wasMentioned: false, isMuted: true, expected: false, + isGM: false, + }, + { + name: "For GM default for NONE is NONE", + userNotifySetting: model.UserNotifyNone, + channelNotifySetting: model.ChannelNotifyDefault, + withSystemPost: false, + wasMentioned: false, + isMuted: false, + expected: false, + isGM: true, + }, + { + name: "For GM, mentioned is only called if explicitly mentioned", + userNotifySetting: model.UserNotifyNone, + channelNotifySetting: model.ChannelNotifyMention, + withSystemPost: false, + wasMentioned: true, + isMuted: false, + expected: true, + isGM: true, + }, + { + name: "For GM default for MENTION is ALL", + userNotifySetting: model.UserNotifyMention, + channelNotifySetting: model.ChannelNotifyDefault, + withSystemPost: false, + wasMentioned: false, + isMuted: false, + expected: true, + isGM: true, + }, + { + name: "For GM, mentioned is only called if explicitly mentioned", + userNotifySetting: model.UserNotifyNone, + channelNotifySetting: model.ChannelNotifyMention, + withSystemPost: false, + wasMentioned: false, + isMuted: false, + expected: false, + isGM: true, }, } @@ -352,7 +426,7 @@ func TestDoesNotifyPropsAllowPushNotification(t *testing.T) { if tc.isMuted { channelNotifyProps[model.MarkUnreadNotifyProp] = model.ChannelMarkUnreadMention } - assert.Equal(t, tc.expected, DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, tc.wasMentioned)) + assert.Equal(t, tc.expected, DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, tc.wasMentioned, tc.isGM)) }) } } diff --git a/server/channels/app/notification_test.go b/server/channels/app/notification_test.go index 6b4f638f6d..983f4a4b00 100644 --- a/server/channels/app/notification_test.go +++ b/server/channels/app/notification_test.go @@ -38,19 +38,21 @@ func TestSendNotifications(t *testing.T) { th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false) - post1, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post1, createPostErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "@" + th.BasicUser2.Username, Type: model.PostTypeAddToChannel, Props: map[string]any{model.PostPropsAddedUserId: "junk"}, }, true, true) - require.Nil(t, appErr) + require.Nil(t, createPostErr) - mentions, err := th.App.SendNotifications(th.Context, post1, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) - require.NoError(t, err) - require.NotNil(t, mentions) - require.True(t, pUtils.Contains(mentions, th.BasicUser2.Id), "mentions", mentions) + t.Run("Basic channel", func(t *testing.T) { + mentions, err := th.App.SendNotifications(th.Context, post1, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) + require.NoError(t, err) + require.NotNil(t, mentions) + require.True(t, pUtils.Contains(mentions, th.BasicUser2.Id), "mentions", mentions) + }) t.Run("license is required for group mention", func(t *testing.T) { group := th.CreateGroup() @@ -70,7 +72,7 @@ func TestSendNotifications(t *testing.T) { groupMentionPost, createPostErr := th.App.CreatePost(th.Context, groupMentionPost, th.BasicChannel, false, true) require.Nil(t, createPostErr) - mentions, err = th.App.SendNotifications(th.Context, groupMentionPost, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) + mentions, err := th.App.SendNotifications(th.Context, groupMentionPost, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) require.NoError(t, err) require.NotNil(t, mentions) require.Len(t, mentions, 0) @@ -83,40 +85,83 @@ func TestSendNotifications(t *testing.T) { require.Len(t, mentions, 1) }) - dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) - require.Nil(t, appErr) + t.Run("message in DM generate mention", func(t *testing.T) { + dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) + require.Nil(t, appErr) - post2, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ - UserId: th.BasicUser.Id, - ChannelId: dm.Id, - Message: "dm message", - }, true, true) - require.Nil(t, appErr) + post2, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: dm.Id, + Message: "dm message", + }, true, true) + require.Nil(t, appErr) - mentions, err = th.App.SendNotifications(th.Context, post2, th.BasicTeam, dm, th.BasicUser, nil, true) - require.NoError(t, err) - require.NotNil(t, mentions) + mentions, err := th.App.SendNotifications(th.Context, post2, th.BasicTeam, dm, th.BasicUser, nil, true) + require.NoError(t, err) + require.NotNil(t, mentions) - _, appErr = th.App.UpdateActive(th.Context, th.BasicUser2, false) - require.Nil(t, appErr) - appErr = th.App.Srv().InvalidateAllCaches() - require.Nil(t, appErr) + _, appErr = th.App.UpdateActive(th.Context, th.BasicUser2, false) + require.Nil(t, appErr) + appErr = th.App.Srv().InvalidateAllCaches() + require.Nil(t, appErr) - post3, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ - UserId: th.BasicUser.Id, - ChannelId: dm.Id, - Message: "dm message", - }, true, true) - require.Nil(t, appErr) + post3, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: dm.Id, + Message: "dm message", + }, true, true) + require.Nil(t, appErr) - mentions, err = th.App.SendNotifications(th.Context, post3, th.BasicTeam, dm, th.BasicUser, nil, true) - require.NoError(t, err) - require.NotNil(t, mentions) + mentions, err = th.App.SendNotifications(th.Context, post3, th.BasicTeam, dm, th.BasicUser, nil, true) + require.NoError(t, err) + require.NotNil(t, mentions) - th.BasicChannel.DeleteAt = 1 - mentions, err = th.App.SendNotifications(th.Context, post1, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) - require.NoError(t, err) - require.Empty(t, mentions) + th.BasicChannel.DeleteAt = 1 + mentions, err = th.App.SendNotifications(th.Context, post1, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) + require.NoError(t, err) + require.Empty(t, mentions) + }) + + t.Run("message in GM generate mention", func(t *testing.T) { + users := []*model.User{} + for i := 0; i < 2; i++ { + user := th.CreateUser() + users = append(users, user) + } + channel := th.CreateGroupChannel(th.Context, users[0], users[1]) + + post2, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + UserId: users[0].Id, + ChannelId: channel.Id, + Message: "gm message", + }, true, true) + require.Nil(t, appErr) + + mentions, err := th.App.SendNotifications(th.Context, post2, th.BasicTeam, channel, users[0], nil, true) + require.NoError(t, err) + require.NotNil(t, mentions) + + _, appErr = th.App.UpdateActive(th.Context, users[1], false) + require.Nil(t, appErr) + appErr = th.App.Srv().InvalidateAllCaches() + require.Nil(t, appErr) + + post3, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + UserId: users[0].Id, + ChannelId: channel.Id, + Message: "gm message", + }, true, true) + require.Nil(t, appErr) + + mentions, err = th.App.SendNotifications(th.Context, post3, th.BasicTeam, channel, users[0], nil, true) + require.NoError(t, err) + require.NotNil(t, mentions) + + th.BasicChannel.DeleteAt = 1 + mentions, err = th.App.SendNotifications(th.Context, post1, th.BasicTeam, th.BasicChannel, users[0], nil, true) + require.NoError(t, err) + require.Empty(t, mentions) + }) t.Run("replies to post created by OAuth bot should not notify user", func(t *testing.T) { th := Setup(t).InitBasic() @@ -129,7 +174,7 @@ func TestSendNotifications(t *testing.T) { Props: model.StringInterface{"from_webhook": "true", "override_username": "a bot"}, } - rootPost, appErr = th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) + rootPost, appErr := th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) require.Nil(t, appErr) childPost := &model.Post{ @@ -145,11 +190,12 @@ func TestSendNotifications(t *testing.T) { Order: []string{rootPost.Id, childPost.Id}, Posts: map[string]*model.Post{rootPost.Id: rootPost, childPost.Id: childPost}, } - mentions, err = th.App.SendNotifications(th.Context, childPost, th.BasicTeam, th.BasicChannel, th.BasicUser2, &postList, true) + mentions, err := th.App.SendNotifications(th.Context, childPost, th.BasicTeam, th.BasicChannel, th.BasicUser2, &postList, true) require.NoError(t, err) require.False(t, pUtils.Contains(mentions, user.Id)) } + var appErr *model.AppError th.BasicUser.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny th.BasicUser, appErr = th.App.UpdateUser(th.Context, th.BasicUser, false) require.Nil(t, appErr) diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 01ef03dec0..c1dc4ae35f 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -179,17 +179,19 @@ func TestPluginAPIGetUserPreferences(t *testing.T) { preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) - assert.Equal(t, 2, len(preferences)) + assert.Equal(t, 3, len(preferences)) assert.Equal(t, user1.Id, preferences[0].UserId) assert.Equal(t, model.PreferenceRecommendedNextSteps, preferences[0].Category) assert.Equal(t, "hide", preferences[0].Name) assert.Equal(t, "false", preferences[0].Value) - assert.Equal(t, user1.Id, preferences[1].UserId) - assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[1].Category) - assert.Equal(t, user1.Id, preferences[1].Name) - assert.Equal(t, "0", preferences[1].Value) + assert.Equal(t, model.PreferenceCategorySystemNotice, preferences[1].Category) + + assert.Equal(t, user1.Id, preferences[2].UserId) + assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[2].Category) + assert.Equal(t, user1.Id, preferences[2].Name) + assert.Equal(t, "0", preferences[2].Value) } func TestPluginAPIDeleteUserPreferences(t *testing.T) { @@ -207,7 +209,7 @@ func TestPluginAPIDeleteUserPreferences(t *testing.T) { preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) - assert.Equal(t, 2, len(preferences)) + assert.Equal(t, 3, len(preferences)) err = api.DeletePreferencesForUser(user1.Id, preferences) require.Nil(t, err) @@ -234,16 +236,16 @@ func TestPluginAPIDeleteUserPreferences(t *testing.T) { preferences, err = api.GetPreferencesForUser(user2.Id) require.Nil(t, err) - assert.Equal(t, 3, len(preferences)) + assert.Equal(t, 4, len(preferences)) err = api.DeletePreferencesForUser(user2.Id, []model.Preference{preference}) require.Nil(t, err) preferences, err = api.GetPreferencesForUser(user2.Id) require.Nil(t, err) - assert.Equal(t, 2, len(preferences)) + assert.Equal(t, 3, len(preferences)) assert.ElementsMatch(t, - []string{model.PreferenceRecommendedNextSteps, model.PreferenceCategoryTutorialSteps}, - []string{preferences[0].Category, preferences[1].Category}, + []string{model.PreferenceRecommendedNextSteps, model.PreferenceCategoryTutorialSteps, model.PreferenceCategorySystemNotice}, + []string{preferences[0].Category, preferences[1].Category, preferences[2].Category}, ) } @@ -262,16 +264,17 @@ func TestPluginAPIUpdateUserPreferences(t *testing.T) { preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) - assert.Equal(t, 2, len(preferences)) + assert.Equal(t, 3, len(preferences)) assert.Equal(t, user1.Id, preferences[0].UserId) assert.Equal(t, model.PreferenceRecommendedNextSteps, preferences[0].Category) assert.Equal(t, "hide", preferences[0].Name) assert.Equal(t, "false", preferences[0].Value) - assert.Equal(t, user1.Id, preferences[1].UserId) - assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[1].Category) - assert.Equal(t, user1.Id, preferences[1].Name) - assert.Equal(t, "0", preferences[1].Value) + assert.Equal(t, model.PreferenceCategorySystemNotice, preferences[1].Category) + assert.Equal(t, user1.Id, preferences[2].UserId) + assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[2].Category) + assert.Equal(t, user1.Id, preferences[2].Name) + assert.Equal(t, "0", preferences[2].Value) preference := model.Preference{ Name: user1.Id, @@ -286,8 +289,8 @@ func TestPluginAPIUpdateUserPreferences(t *testing.T) { preferences, err = api.GetPreferencesForUser(user1.Id) require.Nil(t, err) - assert.Equal(t, 3, len(preferences)) - expectedCategories := []string{model.PreferenceCategoryTutorialSteps, model.PreferenceCategoryTheme, model.PreferenceRecommendedNextSteps} + assert.Equal(t, 4, len(preferences)) + expectedCategories := []string{model.PreferenceCategoryTutorialSteps, model.PreferenceCategoryTheme, model.PreferenceRecommendedNextSteps, model.PreferenceCategorySystemNotice} for _, pref := range preferences { assert.Contains(t, expectedCategories, pref.Category) assert.Equal(t, user1.Id, pref.UserId) diff --git a/server/channels/app/post_persistent_notification.go b/server/channels/app/post_persistent_notification.go index 773756afb2..a8ff30f1fe 100644 --- a/server/channels/app/post_persistent_notification.go +++ b/server/channels/app/post_persistent_notification.go @@ -275,9 +275,9 @@ func (a *App) channelTeamMapsForPosts(posts []*model.Post) (map[string]*model.Ch func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Channel, team *model.Team, mentions *ExplicitMentions, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error { mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions)) - for id := range mentions.Mentions { - // Don't send notification to post owner - if id != post.UserId { + for id, v := range mentions.Mentions { + // Don't send notification to post owner nor GM mentions + if id != post.UserId && v > GMMention { mentionedUsersList = append(mentionedUsersList, id) } } @@ -307,7 +307,8 @@ func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Chann status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} } - if ShouldSendPushNotification(profileMap[userID], channelNotifyProps[channel.Id][userID], true, status, post) { + isGM := channel.Type == model.ChannelTypeGroup + if ShouldSendPushNotification(profileMap[userID], channelNotifyProps[channel.Id][userID], true, status, post, isGM) { a.sendPushNotification( notification, user, diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 3864e4949a..335f6eed31 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -282,8 +282,9 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m recommendedNextStepsPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceRecommendedNextSteps, Name: "hide", Value: "false"} tutorialStepPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceCategoryTutorialSteps, Name: ruser.Id, Value: "0"} + gmASdmPref := model.Preference{UserId: ruser.Id, Category: model.PreferenceCategorySystemNotice, Name: "GMasDM", Value: "true"} - preferences := model.Preferences{recommendedNextStepsPref, tutorialStepPref} + preferences := model.Preferences{recommendedNextStepsPref, tutorialStepPref, gmASdmPref} if err := a.Srv().Store().Preference().Save(preferences); err != nil { c.Logger().Warn("Encountered error saving user preferences", mlog.Err(err)) } diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 282586d8ed..5540af2e54 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -1825,6 +1825,11 @@ func TestCreateUserWithInitialPreferences(t *testing.T) { assert.Equal(t, model.PreferenceRecommendedNextSteps, recommendedNextStepsPref[0].Category) assert.Equal(t, "hide", recommendedNextStepsPref[0].Name) assert.Equal(t, "false", recommendedNextStepsPref[0].Value) + + gmASdmNoticeViewedPref, appErr := th.App.GetPreferenceByCategoryAndNameForUser(testUser.Id, model.PreferenceCategorySystemNotice, "GMasDM") + require.Nil(t, appErr) + assert.Equal(t, "GMasDM", gmASdmNoticeViewedPref.Name) + assert.Equal(t, "true", gmASdmNoticeViewedPref.Value) }) t.Run("successfully create a guest user with initial tutorial and recommended steps preferences", func(t *testing.T) { @@ -1842,6 +1847,11 @@ func TestCreateUserWithInitialPreferences(t *testing.T) { assert.Equal(t, model.PreferenceRecommendedNextSteps, recommendedNextStepsPref[0].Category) assert.Equal(t, "hide", recommendedNextStepsPref[0].Name) assert.Equal(t, "false", recommendedNextStepsPref[0].Value) + + gmASdmNoticeViewedPref, appErr := th.App.GetPreferenceByCategoryAndNameForUser(testUser.Id, model.PreferenceCategorySystemNotice, "GMasDM") + require.Nil(t, appErr) + assert.Equal(t, "GMasDM", gmASdmNoticeViewedPref.Name) + assert.Equal(t, "true", gmASdmNoticeViewedPref.Value) }) } diff --git a/server/public/model/preference.go b/server/public/model/preference.go index a73e19dad6..bd54a88f66 100644 --- a/server/public/model/preference.go +++ b/server/public/model/preference.go @@ -31,6 +31,8 @@ const ( PreferenceNameUseMilitaryTime = "use_military_time" PreferenceRecommendedNextSteps = "recommended_next_steps" + PreferenceCategorySystemNotice = "system_notice" + PreferenceCategoryTheme = "theme" // the name for theme props is the team id diff --git a/webapp/channels/src/actions/notification_actions.jsx b/webapp/channels/src/actions/notification_actions.jsx index bf0be0dc36..2728d7f9e2 100644 --- a/webapp/channels/src/actions/notification_actions.jsx +++ b/webapp/channels/src/actions/notification_actions.jsx @@ -9,6 +9,7 @@ import { getTeammateNameDisplaySetting, isCollapsedThreadsEnabled, } from 'mattermost-redux/selectors/entities/preferences'; +import {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search'; import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; import {isChannelMuted} from 'mattermost-redux/utils/channel_utils'; import {isSystemMessage, isUserAddedInChannel} from 'mattermost-redux/utils/post_utils'; @@ -18,11 +19,13 @@ import {getChannelURL, getPermalinkURL} from 'selectors/urls'; import {isThreadOpen} from 'selectors/views/threads'; import {getHistory} from 'utils/browser_history'; -import Constants, {NotificationLevels, UserStatuses} from 'utils/constants'; +import Constants, {NotificationLevels, UserStatuses, IgnoreChannelMentions} from 'utils/constants'; import {t} from 'utils/i18n'; -import {stripMarkdown} from 'utils/markdown'; +import {stripMarkdown, formatWithRenderer} from 'utils/markdown'; +import MentionableRenderer from 'utils/markdown/mentionable_renderer'; import * as NotificationSounds from 'utils/notification_sounds'; import {showNotification} from 'utils/notifications'; +import {cjkrPattern, escapeRegex} from 'utils/text_formatting'; import {isDesktopApp, isMobileApp, isWindowsApp} from 'utils/user_agent'; import * as Utils from 'utils/utils'; @@ -93,14 +96,94 @@ export function sendDesktopNotification(post, msgProps) { return; } - let notifyLevel = member?.notify_props?.desktop || NotificationLevels.DEFAULT; + const channelNotifyProp = member?.notify_props?.desktop || NotificationLevels.DEFAULT; + let notifyLevel = channelNotifyProp; if (notifyLevel === NotificationLevels.DEFAULT) { notifyLevel = user?.notify_props?.desktop || NotificationLevels.ALL; } + if (channel.type === 'G' && channelNotifyProp === NotificationLevels.DEFAULT && user?.notify_props?.desktop === NotificationLevels.MENTION) { + notifyLevel = NotificationLevels.ALL; + } + if (notifyLevel === NotificationLevels.NONE) { return; + } else if (channel.type === 'G' && notifyLevel === NotificationLevels.MENTION) { + // Compose the whole text in the message, including interactive messages. + let text = post.message; + + // We do this on a try catch block to avoid errors from malformed props + try { + if (post.props && post.props.attachments) { + const attachments = post.props.attachments; + function appendText(toAppend) { + if (toAppend) { + text += `\n${toAppend}`; + } + } + for (const attachment of attachments) { + appendText(attachment.pretext); + appendText(attachment.title); + appendText(attachment.text); + appendText(attachment.footer); + if (attachment.fields) { + for (const field of attachment.fields) { + appendText(field.title); + appendText(field.value); + } + } + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.log('Could not process the whole attachment for mentions', e); + } + + const allMentions = getAllUserMentionKeys(state); + + const ignoreChannelMentionProp = member?.notify_props?.ignore_channel_mentions || IgnoreChannelMentions.DEFAULT; + let ignoreChannelMention = ignoreChannelMentionProp === IgnoreChannelMentions.ON; + if (ignoreChannelMentionProp === IgnoreChannelMentions.DEFAULT) { + ignoreChannelMention = user?.notify_props?.channel === 'false'; + } + + const mentionableText = formatWithRenderer(text, new MentionableRenderer()); + let isExplicitlyMentioned = false; + for (const mention of allMentions) { + if (!mention || !mention.key) { + continue; + } + + if (ignoreChannelMention && ['@all', '@here', '@channel'].includes(mention.key)) { + continue; + } + + let flags = 'g'; + if (!mention.caseSensitive) { + flags += 'i'; + } + + let pattern; + if (cjkrPattern.test(mention.key)) { + // In the case of CJK mention key, even if there's no delimiters (such as spaces) at both ends of a word, it is recognized as a mention key + pattern = new RegExp(`()(${escapeRegex(mention.key)})()`, flags); + } else { + pattern = new RegExp( + `(^|\\W)(${escapeRegex(mention.key)})(\\b|_+\\b)`, + flags, + ); + } + + if (pattern.test(mentionableText)) { + isExplicitlyMentioned = true; + break; + } + } + + if (!isExplicitlyMentioned) { + return; + } } else if (notifyLevel === NotificationLevels.MENTION && mentions.indexOf(user.id) === -1 && msgProps.channel_type !== Constants.DM_CHANNEL) { return; } else if (isCrtReply && notifyLevel === NotificationLevels.ALL && followers.indexOf(currentUserId) === -1) { diff --git a/webapp/channels/src/actions/notification_actions.test.js b/webapp/channels/src/actions/notification_actions.test.js index 634da76259..0ee8e91984 100644 --- a/webapp/channels/src/actions/notification_actions.test.js +++ b/webapp/channels/src/actions/notification_actions.test.js @@ -36,6 +36,9 @@ describe('notification_actions', () => { desktop: NotificationLevels.ALL, desktop_sound: false, desktop_threads: NotificationLevels.ALL, + mention_keys: 'mentionkey', + first_name: 'true', + channel: 'true', }; post = { @@ -79,8 +82,13 @@ describe('notification_actions', () => { current_user_id: { id: 'current_user_id', notify_props: userSettings, + username: 'currentusername', + first_name: 'currentuserfirstname', }, }, + profilesInChannel: { + gm_channel: new Set(['current_user_id']), + }, }, teams: { currentTeamId: 'team_id', @@ -109,12 +117,20 @@ describe('notification_actions', () => { id: 'another_channel_id', team_id: 'team_id', }, + gm_channel: { + id: 'gm_channel', + type: 'G', + }, }, myMembers: { channel_id: { id: 'current_user_id', notify_props: channelSettings, }, + gm_channel: { + id: 'gm_channel', + notify_props: channelSettings, + }, }, membersInChannel: { channel_id: { @@ -123,6 +139,12 @@ describe('notification_actions', () => { notify_props: channelSettings, }, }, + gm_channel: { + current_user_id: { + id: 'gm_channel', + notify_props: channelSettings, + }, + }, muted_channel_id: { current_user_id: { id: 'current_user_id', @@ -138,6 +160,10 @@ describe('notification_actions', () => { 'display_settings--collapsed_reply_threads': crt, }, }, + groups: { + groups: {}, + myGroups: [], + }, }, views: { browser: { @@ -401,5 +427,85 @@ describe('notification_actions', () => { }); }); }); + + describe('GMs', () => { + test('should notify for any message when channel setting is DEFAULT and user setting is MENTION', async () => { + const store = testConfigureStore(baseState); + userSettings.desktop = NotificationLevels.MENTION; + channelSettings.desktop = NotificationLevels.DEFAULT; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + test('should not notify for any message when channel setting is DEFAULT and user setting is NONE', async () => { + const store = testConfigureStore(baseState); + userSettings.desktop = NotificationLevels.NONE; + channelSettings.desktop = NotificationLevels.DEFAULT; + post.message = '@username'; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).not.toHaveBeenCalled(); + }); + }); + test('should notify when channel setting MENTION and there is a explicit mention', async () => { + const store = testConfigureStore(baseState); + channelSettings.desktop = NotificationLevels.MENTION; + post.message = '@currentusername'; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + test('should notify when channel setting MENTION and there is a keyword mention', async () => { + const store = testConfigureStore(baseState); + channelSettings.desktop = NotificationLevels.MENTION; + post.message = 'mentionkey'; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + test('should notify when channel setting MENTION and there is the first name', async () => { + const store = testConfigureStore(baseState); + channelSettings.desktop = NotificationLevels.MENTION; + post.message = 'currentuserfirstname'; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + test('should notify when channel setting MENTION and there is a channel mention', async () => { + const store = testConfigureStore(baseState); + channelSettings.desktop = NotificationLevels.MENTION; + post.message = '@all'; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).toHaveBeenCalled(); + }); + }); + test('should not notify when channel setting MENTION and there is no explicit mention', async () => { + const store = testConfigureStore(baseState); + channelSettings.desktop = NotificationLevels.MENTION; + post.channel_id = 'gm_channel'; + msgProps.team_id = ''; + + return store.dispatch(sendDesktopNotification(post, msgProps)).then(() => { + expect(spy).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap b/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap index 60197883fa..77bd53db54 100644 --- a/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap +++ b/webapp/channels/src/components/channel_notifications_modal/__snapshots__/channel_notifications_modal.test.tsx.snap @@ -70,6 +70,7 @@ exports[`components/channel_notifications_modal/ChannelNotificationsModal should /> `; + +exports[`components/channel_notifications_modal/ChannelNotificationsModal should match snapshot for GMs 1`] = ` + + + + + + channel_display_name + + + + +
+
+
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+ + +`; diff --git a/webapp/channels/src/components/channel_notifications_modal/channel_notifications_modal.test.tsx b/webapp/channels/src/components/channel_notifications_modal/channel_notifications_modal.test.tsx index b681e4e65a..b2f7d3f62f 100644 --- a/webapp/channels/src/components/channel_notifications_modal/channel_notifications_modal.test.tsx +++ b/webapp/channels/src/components/channel_notifications_modal/channel_notifications_modal.test.tsx @@ -54,6 +54,37 @@ describe('components/channel_notifications_modal/ChannelNotificationsModal', () expect(wrapper).toMatchSnapshot(); }); + test('should match snapshot for GMs', () => { + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + test('should provide default notify props when missing', () => { const wrapper = shallow( export type PushNotificationProps = Pick -const getDefaultDesktopNotificationLevel = (currentUserNotifyProps: UserNotifyProps): Exclude => { +const getDefaultDesktopNotificationLevel = (currentUserNotifyProps: UserNotifyProps, isGM: boolean): Exclude => { if (currentUserNotifyProps?.desktop) { - if (currentUserNotifyProps.desktop === 'default') { + if (currentUserNotifyProps.desktop === NotificationLevels.DEFAULT) { + return NotificationLevels.ALL; + } + + if (isGM && currentUserNotifyProps.desktop === NotificationLevels.MENTION) { return NotificationLevels.ALL; } return currentUserNotifyProps.desktop; @@ -86,11 +90,16 @@ const getDefaultDesktopThreadsNotifyLevel = (currentUserNotifyProps: UserNotifyP return NotificationLevels.ALL; }; -const getDefaultPushNotifyLevel = (currentUserNotifyProps: UserNotifyProps): Exclude => { +const getDefaultPushNotifyLevel = (currentUserNotifyProps: UserNotifyProps, isGM: boolean): Exclude => { if (currentUserNotifyProps?.push) { - if (currentUserNotifyProps.push === 'default') { + if (currentUserNotifyProps.push === NotificationLevels.DEFAULT) { return NotificationLevels.ALL; } + + if (isGM && currentUserNotifyProps.desktop === NotificationLevels.MENTION) { + return NotificationLevels.ALL; + } + return currentUserNotifyProps.push; } return NotificationLevels.ALL; @@ -142,7 +151,7 @@ export default class ChannelNotificationsModal extends React.PureComponent
; } + const isGM = this.isGM(); + return (
{!isChannelMuted(channelMember) &&
@@ -457,8 +478,8 @@ export default class ChannelNotificationsModal extends React.PureComponent
{sendPushNotifications && @@ -476,7 +498,7 @@ export default class ChannelNotificationsModal extends React.PureComponent }
} -
- + {!isGM && + <> +
+ + + }
diff --git a/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/describe.test.tsx.snap b/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/describe.test.tsx.snap index d2ca4ebd67..917c769285 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/describe.test.tsx.snap +++ b/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/describe.test.tsx.snap @@ -2,7 +2,7 @@ exports[`components/channel_notifications_modal/NotificationSection should match snapshot, on DESKTOP/PUSH & ALL 1`] = ` +
+ + + +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ + + +
+ +
+
+ +
+
+ +
+
+
+
, + ] + } + saving={false} + section="" + serverError="" + submit={[MockFunction]} + title={ + + } + updateSection={[MockFunction]} +/> +`; + +exports[`components/channel_notifications_modal/ExpandView gms should match snapshot, PUSH on expanded view when mentions is selected 1`] = ` + +
+ + + +
+ +
+
+ +
+
+ +
+
+
+ +
+
, + ] + } + saving={false} + section="" + serverError="" + submit={[MockFunction]} + title={ + + } + updateSection={[MockFunction]} +/> +`; + +exports[`components/channel_notifications_modal/ExpandView normal channels should match snapshot, DESKTOP on expanded view 1`] = ` `; -exports[`components/channel_notifications_modal/ExpandView should match snapshot, MARK_UNREAD on expanded view 1`] = ` +exports[`components/channel_notifications_modal/ExpandView normal channels should match snapshot, DESKTOP on expanded view when mentions is selected 1`] = ` + +
+ + + +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ + + +
+ +
+
+
+ +
+
+
+ +
+
+ + + +
+ +
+
+ +
+
+ +
+
+
+
, + ] + } + saving={false} + section="" + serverError="" + submit={[MockFunction]} + title={ + + } + updateSection={[MockFunction]} +/> +`; + +exports[`components/channel_notifications_modal/ExpandView normal channels should match snapshot, MARK_UNREAD on expanded view 1`] = ` `; -exports[`components/channel_notifications_modal/ExpandView should match snapshot, PUSH on expanded view 1`] = ` +exports[`components/channel_notifications_modal/ExpandView normal channels should match snapshot, PUSH on expanded view 1`] = ` `; + +exports[`components/channel_notifications_modal/ExpandView normal channels should match snapshot, PUSH on expanded view when mentions is selected 1`] = ` + +
+ + + +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ + + +
+ +
+
+
+ +
+
+
+
, + ] + } + saving={false} + section="" + serverError="" + submit={[MockFunction]} + title={ + + } + updateSection={[MockFunction]} +/> +`; diff --git a/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/notification_section.test.jsx.snap b/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/notification_section.test.jsx.snap index 888861bc5d..be94eadce7 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/notification_section.test.jsx.snap +++ b/webapp/channels/src/components/channel_notifications_modal/components/__snapshots__/notification_section.test.jsx.snap @@ -21,6 +21,7 @@ exports[`components/channel_notifications_modal/NotificationSection should match exports[`components/channel_notifications_modal/NotificationSection should match snapshot, DESKTOP on expanded view 1`] = ` }} /> ); @@ -113,7 +113,7 @@ export default function Describe({section, isCollapsed, memberNotifyLevel, globa return ( }} /> ); @@ -132,7 +132,7 @@ export default function Describe({section, isCollapsed, memberNotifyLevel, globa return ( }} /> ); diff --git a/webapp/channels/src/components/channel_notifications_modal/components/expand_view.test.tsx b/webapp/channels/src/components/channel_notifications_modal/components/expand_view.test.tsx index caec34c4bd..205bc3c42a 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/expand_view.test.tsx +++ b/webapp/channels/src/components/channel_notifications_modal/components/expand_view.test.tsx @@ -10,7 +10,7 @@ import {NotificationLevels, NotificationSections} from 'utils/constants'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn(), + useSelector: jest.fn(() => true), })); describe('components/channel_notifications_modal/ExpandView', () => { @@ -25,31 +25,72 @@ describe('components/channel_notifications_modal/ExpandView', () => { onCollapseSection: jest.fn(), onSubmit: jest.fn(), onReset: jest.fn(), + isGM: false, }; - test('should match snapshot, DESKTOP on expanded view', () => { - const wrapper = shallow( - , - ); + describe('normal channels', () => { + test('should match snapshot, DESKTOP on expanded view', () => { + const wrapper = shallow( + , + ); - expect(wrapper).toMatchSnapshot(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot, PUSH on expanded view', () => { + const props = {...baseProps, section: NotificationSections.PUSH}; + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot, MARK_UNREAD on expanded view', () => { + const props = {...baseProps, section: NotificationSections.MARK_UNREAD}; + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot, DESKTOP on expanded view when mentions is selected', () => { + const props = {...baseProps, memberNotifyLevel: NotificationLevels.MENTION}; + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot, PUSH on expanded view when mentions is selected', () => { + const props = {...baseProps, section: NotificationSections.PUSH, memberNotifyLevel: NotificationLevels.MENTION}; + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); }); - test('should match snapshot, PUSH on expanded view', () => { - const props = {...baseProps, section: NotificationSections.PUSH}; - const wrapper = shallow( - , - ); + describe('gms', () => { + test('should match snapshot, DESKTOP on expanded view when mentions is selected', () => { + const props = {...baseProps, isGM: true, memberNotifyLevel: NotificationLevels.MENTION}; + const wrapper = shallow( + , + ); - expect(wrapper).toMatchSnapshot(); - }); + expect(wrapper).toMatchSnapshot(); + }); - test('should match snapshot, MARK_UNREAD on expanded view', () => { - const props = {...baseProps, section: NotificationSections.MARK_UNREAD}; - const wrapper = shallow( - , - ); + test('should match snapshot, PUSH on expanded view when mentions is selected', () => { + const props = {...baseProps, section: NotificationSections.PUSH, isGM: true, memberNotifyLevel: NotificationLevels.MENTION}; + const wrapper = shallow( + , + ); - expect(wrapper).toMatchSnapshot(); + expect(wrapper).toMatchSnapshot(); + }); }); }); diff --git a/webapp/channels/src/components/channel_notifications_modal/components/expand_view.tsx b/webapp/channels/src/components/channel_notifications_modal/components/expand_view.tsx index aff565d2fc..eac49ac2fb 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/expand_view.tsx +++ b/webapp/channels/src/components/channel_notifications_modal/components/expand_view.tsx @@ -45,6 +45,7 @@ type Props = { memberDesktopNotificationSound?: string; section: string; serverError?: string; + isGM: boolean; } const sounds = Array.from(notificationSounds.keys()); @@ -74,6 +75,7 @@ export default function ExpandView({ onCollapseSection, ignoreChannelMentions, channelAutoFollowThreads, + isGM, }: Props) { const isCRTEnabled = useSelector(isCollapsedThreadsEnabled); @@ -275,6 +277,7 @@ export default function ExpandView({ {isCRTEnabled && section === NotificationSections.DESKTOP && memberNotifyLevel === NotificationLevels.MENTION && + !isGM && <>
@@ -378,6 +381,7 @@ export default function ExpandView({ {isCRTEnabled && section === NotificationSections.PUSH && memberNotifyLevel === NotificationLevels.MENTION && + !isGM && <>
diff --git a/webapp/channels/src/components/channel_notifications_modal/components/notification_section.jsx b/webapp/channels/src/components/channel_notifications_modal/components/notification_section.jsx index 1883613fe4..3cfebcc179 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/notification_section.jsx +++ b/webapp/channels/src/components/channel_notifications_modal/components/notification_section.jsx @@ -88,6 +88,11 @@ export default class NotificationSection extends React.PureComponent { * Error string from the server */ serverError: PropTypes.string, + + /** + * Whether the preferences are those of a GM + */ + isGM: PropTypes.bool, }; handleOnChange = (e) => { @@ -134,6 +139,7 @@ export default class NotificationSection extends React.PureComponent { onReset, section, serverError, + isGM, } = this.props; if (expand) { @@ -157,6 +163,7 @@ export default class NotificationSection extends React.PureComponent { onSubmit={onSubmit} serverError={serverError} onCollapseSection={this.handleCollapseSection} + isGM={isGM} /> ); } diff --git a/webapp/channels/src/components/channel_notifications_modal/components/notification_section.test.jsx b/webapp/channels/src/components/channel_notifications_modal/components/notification_section.test.jsx index d6e805cad0..fdc22b0e79 100644 --- a/webapp/channels/src/components/channel_notifications_modal/components/notification_section.test.jsx +++ b/webapp/channels/src/components/channel_notifications_modal/components/notification_section.test.jsx @@ -21,6 +21,7 @@ describe('components/channel_notifications_modal/NotificationSection', () => { onSubmit: () => {}, //eslint-disable-line no-empty-function onUpdateSection: () => {}, //eslint-disable-line no-empty-function serverError: '', + isGM: false, }; test('should match snapshot, DESKTOP on collapsed view', () => { diff --git a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx index 0bca987362..fb576194e8 100644 --- a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx +++ b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx @@ -32,6 +32,7 @@ describe('components/post_view/ChannelIntroMessages', () => { const users = [ {id: 'user1', roles: 'system_user'}, {id: 'guest1', roles: 'system_guest'}, + {id: 'test-user-id', roles: 'system_user'}, ] as UserProfile[]; const baseProps = { @@ -153,6 +154,9 @@ describe('components/post_view/ChannelIntroMessages', () => { expect(editIcon).toBeInTheDocument(); expect(editIcon).toHaveClass('icon-pencil-outline'); + + const notificationPreferencesButton = screen.getByText('Notification Preferences'); + expect(notificationPreferencesButton).toBeInTheDocument(); }); }); diff --git a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx index 27a531f689..1d1f380f1f 100644 --- a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx +++ b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx @@ -4,12 +4,14 @@ import React from 'react'; import {FormattedDate, FormattedMessage} from 'react-intl'; +import {BellRingOutlineIcon} from '@mattermost/compass-icons/components'; import type {Channel} from '@mattermost/types/channels'; -import type {UserProfile as UserProfileRedux} from '@mattermost/types/users'; +import type {UserProfile as UserProfileType} from '@mattermost/types/users'; import {Permissions} from 'mattermost-redux/constants'; import AddGroupsToTeamModal from 'components/add_groups_to_team_modal'; +import ChannelNotificationsModal from 'components/channel_notifications_modal'; import EditChannelHeaderModal from 'components/edit_channel_header_modal'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import LocalizedIcon from 'components/localized_icon'; @@ -32,12 +34,12 @@ type Props = { channel: Channel; fullWidth: boolean; locale: string; - channelProfiles: UserProfileRedux[]; + channelProfiles: UserProfileType[]; enableUserCreation?: boolean; isReadOnly?: boolean; teamIsGroupConstrained?: boolean; creatorName: string; - teammate?: UserProfileRedux; + teammate?: UserProfileType; teammateName?: string; stats: any; usersLimit: number; @@ -89,10 +91,11 @@ export default class ChannelIntroMessage extends React.PureComponent { } } -function createGMIntroMessage(channel: Channel, centeredIntro: string, profiles: UserProfileRedux[], currentUserId: string) { +function createGMIntroMessage(channel: Channel, centeredIntro: string, profiles: UserProfileType[], currentUserId: string) { const channelIntroId = 'channelIntro'; if (profiles.length > 0) { + const currentUserProfile = profiles.find((v) => v.id === currentUserId); const pictures = profiles. filter((profile) => profile.id !== currentUserId). map((profile) => ( @@ -114,16 +117,21 @@ function createGMIntroMessage(channel: Channel, centeredIntro: string, profiles: {pictures}

- for all activity in this group message.'} values={{ + b: (chunks) => {chunks}, names: channel.display_name, + br:
, }} />

- - {createSetHeaderButton(channel)} +
+ {createNotificationPreferencesButton(channel, currentUserProfile)} + + {createSetHeaderButton(channel)} +
); } @@ -143,7 +151,7 @@ function createGMIntroMessage(channel: Channel, centeredIntro: string, profiles: ); } -function createDMIntroMessage(channel: Channel, centeredIntro: string, teammate?: UserProfileRedux, teammateName?: string) { +function createDMIntroMessage(channel: Channel, centeredIntro: string, teammate?: UserProfileType, teammateName?: string) { const channelIntroId = 'channelIntro'; if (teammate) { const src = teammate ? Utils.imageURLForUser(teammate.id, teammate.last_picture_update) : ''; @@ -185,8 +193,10 @@ function createDMIntroMessage(channel: Channel, centeredIntro: string, teammate? }} />

- {pluggableButton} - {setHeaderButton} +
+ {pluggableButton} + {setHeaderButton} +
); } @@ -555,3 +565,26 @@ function createSetHeaderButton(channel: Channel) { ); } + +function createNotificationPreferencesButton(channel: Channel, currentUser?: UserProfileType) { + const isGM = channel.type === 'G'; + if (!isGM || !currentUser) { + return null; + } + + return ( + + + + + ); +} diff --git a/webapp/channels/src/components/system_notice/__snapshots__/system_notice.test.tsx.snap b/webapp/channels/src/components/system_notice/__snapshots__/system_notice.test.tsx.snap index b4cdcc5020..eac4c99b37 100644 --- a/webapp/channels/src/components/system_notice/__snapshots__/system_notice.test.tsx.snap +++ b/webapp/channels/src/components/system_notice/__snapshots__/system_notice.test.tsx.snap @@ -5,64 +5,60 @@ exports[`components/SystemNotice should match snapshot for admin, admin notice 1 className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title
-
-
some body -
-
- + - -
-
- - +
+
+ + +
`; @@ -72,47 +68,43 @@ exports[`components/SystemNotice should match snapshot for admin, regular notice className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title
-
-
some body -
-
- - + + +
`; @@ -122,47 +114,43 @@ exports[`components/SystemNotice should match snapshot for regular user, admin a className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title2
-
-
some body2 -
-
- - + + +
`; @@ -180,47 +168,43 @@ exports[`components/SystemNotice should match snapshot for regular user, regular className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title
-
-
some body -
-
- - + + +
`; @@ -232,47 +216,43 @@ exports[`components/SystemNotice should match snapshot for show function returni className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title
-
-
some body -
-
- - + + +
`; @@ -282,37 +262,81 @@ exports[`components/SystemNotice should match snapshot for with allowForget equa className="system-notice bg--white shadow--2" >
+ +
+
-
- -
some title
+ some body +
+ +
+
+ +`; + +exports[`components/SystemNotice should match snapshot when a custom icon is passed 1`] = ` +
+
+ + icon +
- some body -
-
- + some title +
+ some body +
+ + +
`; diff --git a/webapp/channels/src/components/system_notice/index.ts b/webapp/channels/src/components/system_notice/index.ts index 394a0e8a24..bb96b687c9 100644 --- a/webapp/channels/src/components/system_notice/index.ts +++ b/webapp/channels/src/components/system_notice/index.ts @@ -11,6 +11,7 @@ import {getStandardAnalytics} from 'mattermost-redux/actions/admin'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {Permissions} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles'; @@ -55,6 +56,7 @@ function makeMapStateToProps() { license, serverVersion, analytics, + currentChannel: getCurrentChannel(state), }; }; } diff --git a/webapp/channels/src/components/system_notice/notices.tsx b/webapp/channels/src/components/system_notice/notices.tsx index a2fa200fa6..de8c1e3647 100644 --- a/webapp/channels/src/components/system_notice/notices.tsx +++ b/webapp/channels/src/components/system_notice/notices.tsx @@ -7,8 +7,8 @@ import {FormattedMessage} from 'react-intl'; import ExternalLink from 'components/external_link'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import type {Notice} from 'components/system_notice/types'; +import InfoIcon from 'components/widgets/icons/info_icon'; -import mattermostIcon from 'images/icon50x50.png'; import {DocLinks} from 'utils/constants'; import * as ServerVersion from 'utils/server_version'; import * as UserAgent from 'utils/user_agent'; @@ -29,12 +29,11 @@ const notices: Notice[] = [ name: 'apiv3_deprecation', adminOnly: true, title: ( - ), - icon: mattermostIcon, body: ( ), - icon: mattermostIcon, body: ( ), - icon: mattermostIcon, body: ( ), - icon: mattermostIcon, allowForget: false, body: ( + ), + icon: (), + body: ( + )}} + /> + ), + show: (serverVersion, config, license, analytics, currentChannel) => { + return currentChannel?.type === 'G'; + }, + }, ]; export default notices; diff --git a/webapp/channels/src/components/system_notice/system_notice.test.tsx b/webapp/channels/src/components/system_notice/system_notice.test.tsx index 5b5be5694f..84d902025a 100644 --- a/webapp/channels/src/components/system_notice/system_notice.test.tsx +++ b/webapp/channels/src/components/system_notice/system_notice.test.tsx @@ -6,15 +6,13 @@ import React from 'react'; import SystemNotice from 'components/system_notice/system_notice'; -import mattermostIcon from 'images/icon50x50.png'; - describe('components/SystemNotice', () => { const baseProps = { currentUserId: 'someid', preferences: {}, dismissedNotices: {}, isSystemAdmin: false, - notices: [{name: 'notice1', adminOnly: false, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true, show: () => true}], + notices: [{name: 'notice1', adminOnly: false, title: 'some title', body: 'some body', allowForget: true, show: () => true}], serverVersion: '5.1', license: {IsLicensed: 'true'}, config: {}, @@ -38,13 +36,17 @@ describe('components/SystemNotice', () => { }); test('should match snapshot for regular user, admin notice', () => { - const props = {...baseProps, notices: [{name: 'notice1', adminOnly: true, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true, show: () => true}]}; + const props = {...baseProps, notices: [{...baseProps.notices[0], adminOnly: true}]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot for regular user, admin and regular notice', () => { - const props = {...baseProps, notices: [{name: 'notice1', adminOnly: true, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true}, {name: 'notice2', adminOnly: false, title: 'some title2', icon: mattermostIcon, body: 'some body2', allowForget: true, show: () => true}]}; + const props = {...baseProps, + notices: [ + {...baseProps.notices[0], adminOnly: true}, + {...baseProps.notices[0], name: 'notice2', title: 'some title2', body: 'some body2'}, + ]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -56,7 +58,7 @@ describe('components/SystemNotice', () => { }); test('should match snapshot for admin, admin notice', () => { - const props = {...baseProps, isSystemAdmin: true, notices: [{name: 'notice1', adminOnly: true, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true, show: () => true}]}; + const props = {...baseProps, isSystemAdmin: true, notices: [{...baseProps.notices[0], adminOnly: true}]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -74,19 +76,25 @@ describe('components/SystemNotice', () => { }); test('should match snapshot for show function returning false', () => { - const props = {...baseProps, notices: [{name: 'notice1', adminOnly: false, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true, show: () => false}]}; + const props = {...baseProps, notices: [{...baseProps.notices[0], show: () => false}]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot for show function returning true', () => { - const props = {...baseProps, notices: [{name: 'notice1', adminOnly: false, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: true, show: () => true}]}; + const props = {...baseProps, notices: [{...baseProps.notices[0], show: () => true}]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot for with allowForget equal false', () => { - const props = {...baseProps, notices: [{name: 'notice1', adminOnly: false, title: 'some title', icon: mattermostIcon, body: 'some body', allowForget: false, show: () => true}]}; + const props = {...baseProps, notices: [{...baseProps.notices[0], allowForget: false}]}; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot when a custom icon is passed', () => { + const props = {...baseProps, notices: [{...baseProps.notices[0], icon: {'icon'}}]}; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); diff --git a/webapp/channels/src/components/system_notice/system_notice.tsx b/webapp/channels/src/components/system_notice/system_notice.tsx index 05395b0c93..196160b56d 100644 --- a/webapp/channels/src/components/system_notice/system_notice.tsx +++ b/webapp/channels/src/components/system_notice/system_notice.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import type {AnalyticsRow} from '@mattermost/types/admin'; +import type {Channel} from '@mattermost/types/channels'; import type {ClientConfig, ClientLicense} from '@mattermost/types/config'; import type {PreferenceType} from '@mattermost/types/preferences'; @@ -25,6 +26,7 @@ type Props = { config: Partial; license: ClientLicense; analytics?: Record; + currentChannel?: Channel; actions: { savePreferences(userId: string, preferences: PreferenceType[]): void; dismissNotice(type: string): void; @@ -60,7 +62,13 @@ export default class SystemNotice extends React.PureComponent { continue; } - if (!notice.show?.(this.props.serverVersion, this.props.config, this.props.license, this.props.analytics)) { + if (!notice.show?.( + this.props.serverVersion, + this.props.config, + this.props.license, + this.props.analytics, + this.props.currentChannel, + )) { continue; } @@ -118,44 +126,44 @@ export default class SystemNotice extends React.PureComponent { ); } + const icon = notice.icon || ; + return (
-
-
- -
+
+ {icon} +
+
{notice.title}
-
-
{notice.body} -
- {visibleMessage} -
- - {notice.allowForget && + {visibleMessage} +
} + + {notice.allowForget && + } +
); diff --git a/webapp/channels/src/components/system_notice/types.ts b/webapp/channels/src/components/system_notice/types.ts index 7ff5296a8c..adc6d3d974 100644 --- a/webapp/channels/src/components/system_notice/types.ts +++ b/webapp/channels/src/components/system_notice/types.ts @@ -4,17 +4,20 @@ import type React from 'react'; import type {AnalyticsRow} from '@mattermost/types/admin'; +import type {Channel} from '@mattermost/types/channels'; export type Notice = { name: string; adminOnly?: boolean; title: React.ReactNode; - icon: string; + icon?: React.ReactNode; body: React.ReactNode; allowForget: boolean; show?( serverVersion: string, config: any, license: any, - analytics?: Record): boolean; + analytics?: Record, + currentChannel?: Channel, + ): boolean; } diff --git a/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/__snapshots__/desktop_notification_settings.test.tsx.snap b/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/__snapshots__/desktop_notification_settings.test.tsx.snap index b3f23c10e2..ff64ca5c19 100644 --- a/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/__snapshots__/desktop_notification_settings.test.tsx.snap +++ b/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/__snapshots__/desktop_notification_settings.test.tsx.snap @@ -50,7 +50,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> @@ -205,7 +205,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> @@ -330,7 +330,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> @@ -485,7 +485,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> @@ -693,7 +693,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> @@ -942,7 +942,7 @@ exports[`components/user_settings/notifications/DesktopNotificationSettings shou type="radio" /> diff --git a/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/desktop_notification_settings.tsx b/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/desktop_notification_settings.tsx index 34a790be5f..c07970f34f 100644 --- a/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/desktop_notification_settings.tsx +++ b/webapp/channels/src/components/user_settings/notifications/desktop_notification_setting/desktop_notification_settings.tsx @@ -397,7 +397,7 @@ export default class DesktopNotificationSettings extends React.PureComponent
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index cb4c09b959..27ecfd289e 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2994,7 +2994,7 @@ "channel_modal.type.private.title": "Private Channel", "channel_modal.type.public.description": "Anyone can join", "channel_modal.type.public.title": "Public Channel", - "channel_notifications.allActivity": "For all activity", + "channel_notifications.allActivity": "For all activity {isDefault}", "channel_notifications.channelAutoFollowThreads": "Auto-follow all new threads in this channel", "channel_notifications.channelAutoFollowThreads.help": "When enabled, you will auto-follow all new threads created in this channel unless you unfollow a thread explicitly.", "channel_notifications.channelAutoFollowThreads.off.title": "Off", @@ -3760,7 +3760,7 @@ "intro_messages.creatorPrivate": "This is the start of the {name} private channel, created by {creator} on {date}.", "intro_messages.default": "**Welcome to {display_name}!**\n \nPost messages here that you want everyone to see. Everyone automatically becomes a permanent member of this channel when they join the team.", "intro_messages.DM": "This is the start of your direct message history with {teammate}.\nDirect messages and files shared here are not shown to people outside this area.", - "intro_messages.GM": "This is the start of your group message history with {names}.\nMessages and files shared here are not shown to people outside this area.", + "intro_messages.GM": "This is the start of your group message history with {names}.{br}You'll be notified for all activity in this group message.", "intro_messages.group_message": "This is the start of your group message history with these teammates. Messages and files shared here are not shown to people outside this area.", "intro_messages.inviteGropusToChannel.button": "Add groups to this private channel", "intro_messages.inviteMembersToChannel.button": "Add members to this channel", @@ -3770,6 +3770,7 @@ "intro_messages.inviteOthersToWorkspace.title": "Let’s add some people to the workspace!", "intro_messages.noCreator": "This is the start of the {name} channel, created on {date}.", "intro_messages.noCreatorPrivate": "This is the start of the {name} private channel, created on {date}.", + "intro_messages.notificationPreferences": "Notification Preferences", "intro_messages.offTopic": "This is the start of {display_name}, a channel for non-work-related conversations.", "intro_messages.onlyInvited": " Only invited members can see this private channel.", "intro_messages.purpose": " This channel's purpose is: {purpose}", @@ -4999,7 +5000,9 @@ "system_notice.body.permissions": "Some policy and permission System Console settings have moved with the release of advanced permissions into Mattermost Free and Professional.", "system_notice.dont_show": "Don't Show Again", "system_notice.remind_me": "Remind me Later", - "system_notice.title": "**Notice**\nfrom Mattermost", + "system_notice.title": "Notice from Mattermost", + "system_notice.title.gm_as_dm": "Updates to Group Messages", + "system_noticy.body.gm_as_dm": "You wil now be notified for all activity in your group messages along with a notification badge for every new message.{br}{br}You can configure this in notification preferences for each group message.", "system_users_list.count": "{count, number} {count, plural, one {user} other {users}}", "system_users_list.countPage": "{startCount, number} - {endCount, number} {count, plural, one {user} other {users}} of {total, number} total", "system_users_list.countSearch": "{count, number} {count, plural, one {user} other {users}} of {total, number} total", @@ -5448,7 +5451,7 @@ "user.settings.notifications.never": "Never", "user.settings.notifications.off": "Off", "user.settings.notifications.on": "On", - "user.settings.notifications.onlyMentions": "Only for mentions and direct messages", + "user.settings.notifications.onlyMentions": "Only for mentions, direct messages, and group messages", "user.settings.notifications.push": "Mobile Push Notifications", "user.settings.notifications.push_notification.status": "Trigger push notifications when", "user.settings.notifications.push_threads": "When enabled, any reply to a thread you're following will send a mobile push notification.", diff --git a/webapp/channels/src/sass/components/_system-notice.scss b/webapp/channels/src/sass/components/_system-notice.scss index c5b50017ba..6340bc26c2 100644 --- a/webapp/channels/src/sass/components/_system-notice.scss +++ b/webapp/channels/src/sass/components/_system-notice.scss @@ -5,7 +5,8 @@ z-index: 9999; right: 12px; bottom: 12px; - width: 280px; + display: flex; + width: 386px; padding: 18px 20px 0; border: 1px solid alpha-color($black, 0.15); background-color: var(--center-channel-bg); @@ -13,33 +14,27 @@ box-shadow: 0 20px 30px alpha-color($black, 0.07), 0 14px 20px alpha-color($black, 0.07); } -.system-notice__header { - display: flex; - align-items: flex-start; -} - .system-notice__logo { - height: 36px; + height: 20px; svg { - width: 36px; - height: 36px; - fill: rgb(22, 109, 224); + width: 20px; + height: 20px; + fill: var(--button-bg); } } .system-notice__title { overflow: hidden; - flex: 10 1 auto; - padding: 3px 0 0 8px; + padding-bottom: 10px; + font-weight: bold; line-height: 16px; - opacity: 0.7; text-overflow: ellipsis; white-space: nowrap; } .system-notice__info { - margin-bottom: 12px; + margin-top: 12px; font-size: 12px; opacity: 0.5; @@ -49,33 +44,22 @@ } .system-notice__body { - padding: 18px 0 16px; + padding: 0 0 16px 16px; line-height: 16px; - opacity: 0.7; } .system-notice__footer { display: flex; - border-top: 1px solid alpha-color($black, 0.2); - margin: 0 -20px; + margin-top: 16px; .btn { overflow: hidden; flex: 1; + font-weight: bold; text-overflow: ellipsis; - &:hover { - background: rgb(22, 109, 224); - color: $white; - } - - &:first-child { - border-radius: 0 0 0 4px; - } - &:last-child { - border-left: 1px solid alpha-color($black, 0.2); - border-radius: 0 0 4px 0; + margin-left: 5px; } } } diff --git a/webapp/channels/src/sass/layout/_headers.scss b/webapp/channels/src/sass/layout/_headers.scss index b5f1f79711..cac525ebb5 100644 --- a/webapp/channels/src/sass/layout/_headers.scss +++ b/webapp/channels/src/sass/layout/_headers.scss @@ -524,6 +524,7 @@ margin-bottom: 10px; .fa, + svg, i { margin-right: 5px; } diff --git a/webapp/channels/src/utils/emoticons.tsx b/webapp/channels/src/utils/emoticons.tsx index 2cbd1ccfb2..b2773624b9 100644 --- a/webapp/channels/src/utils/emoticons.tsx +++ b/webapp/channels/src/utils/emoticons.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {formatWithRenderer} from './markdown'; -import MentionableRenderer from './markdown/mentionable_renderer'; +import PlainRenderer from './markdown/plain_renderer'; export const emoticonPatterns: { [key: string]: RegExp } = { slightly_smiling_face: /(^|\B)(:-?\))($|\B)/g, // :) @@ -28,7 +28,7 @@ export const emoticonPatterns: { [key: string]: RegExp } = { export const EMOJI_PATTERN = /(:([a-zA-Z0-9_+-]+):)/g; export function matchEmoticons(text: string): RegExpMatchArray | null { - const markdownCleanedText = formatWithRenderer(text, new MentionableRenderer()); + const markdownCleanedText = formatWithRenderer(text, new PlainRenderer()); let emojis = markdownCleanedText.match(EMOJI_PATTERN); for (const name of Object.keys(emoticonPatterns)) { diff --git a/webapp/channels/src/utils/markdown/mentionable_renderer.tsx b/webapp/channels/src/utils/markdown/mentionable_renderer.tsx index e3a559c6c4..3866f33e28 100644 --- a/webapp/channels/src/utils/markdown/mentionable_renderer.tsx +++ b/webapp/channels/src/utils/markdown/mentionable_renderer.tsx @@ -1,81 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import marked from 'marked'; +import {EMOJI_PATTERN} from 'utils/emoticons'; + +import PlainRenderer from './plain_renderer'; /** A Markdown renderer that converts a post into plain text that we can search for mentions */ -export default class MentionableRenderer extends marked.Renderer { - public code() { - // Code blocks can't contain mentions - return '\n'; - } - - public blockquote(text: string) { - return text + '\n'; - } - - public heading(text: string) { - return text + '\n'; - } - - public hr() { - return '\n'; - } - - public list(body: string) { - return body + '\n'; - } - - public listitem(text: string) { - return text + '\n'; - } - - public paragraph(text: string) { - return text + '\n'; - } - - public table(header: string, body: string) { - return header + '\n' + body; - } - - public tablerow(content: string) { - return content; - } - - public tablecell(content: string) { - return content + '\n'; - } - - public strong(text: string) { - return ' ' + text + ' '; - } - - public em(text: string) { - return ' ' + text + ' '; - } - - public codespan() { - // Code spans can't contain mentions - return ' '; - } - - public br() { - return '\n'; - } - - public del(text: string) { - return ' ' + text + ' '; - } - - public link(href: string, title: string, text: string) { - return ' ' + text + ' '; - } - - public image(href: string, title: string, text: string) { - return ' ' + text + ' '; - } - +export default class MentionableRenderer extends PlainRenderer { public text(text: string) { - return text; + // Remove all emojis + return text.replace(EMOJI_PATTERN, ''); } } diff --git a/webapp/channels/src/utils/markdown/plain_renderer.ts b/webapp/channels/src/utils/markdown/plain_renderer.ts new file mode 100644 index 0000000000..429d981db3 --- /dev/null +++ b/webapp/channels/src/utils/markdown/plain_renderer.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import marked from 'marked'; + +/** A Markdown renderer that converts a post into plain text */ +export default class PlainRenderer extends marked.Renderer { + public code() { + // Code blocks can't contain mentions + return '\n'; + } + + public blockquote(text: string) { + return text + '\n'; + } + + public heading(text: string) { + return text + '\n'; + } + + public hr() { + return '\n'; + } + + public list(body: string) { + return body + '\n'; + } + + public listitem(text: string) { + return text + '\n'; + } + + public paragraph(text: string) { + return text + '\n'; + } + + public table(header: string, body: string) { + return header + '\n' + body; + } + + public tablerow(content: string) { + return content; + } + + public tablecell(content: string) { + return content + '\n'; + } + + public strong(text: string) { + return ' ' + text + ' '; + } + + public em(text: string) { + return ' ' + text + ' '; + } + + public codespan() { + // Code spans can't contain mentions + return ' '; + } + + public br() { + return '\n'; + } + + public del(text: string) { + return ' ' + text + ' '; + } + + public link(href: string, title: string, text: string) { + return ' ' + text + ' '; + } + + public image(href: string, title: string, text: string) { + return ' ' + text + ' '; + } + + public text(text: string) { + return text; + } +} diff --git a/webapp/channels/src/utils/text_formatting.tsx b/webapp/channels/src/utils/text_formatting.tsx index b744204543..b7ab1ac772 100644 --- a/webapp/channels/src/utils/text_formatting.tsx +++ b/webapp/channels/src/utils/text_formatting.tsx @@ -227,7 +227,7 @@ const DEFAULT_OPTIONS: TextFormattingOptions = { * Additional CJK and Hangul compatibility characters: \u2de0-\u2dff **/ // eslint-disable-next-line no-misleading-character-class -const cjkrPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3\u1100-\u11ff\u3130-\u318f\u0400-\u04ff\u0500-\u052f\u2de0-\u2dff]/; +export const cjkrPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3\u1100-\u11ff\u3130-\u318f\u0400-\u04ff\u0500-\u052f\u2de0-\u2dff]/; export function formatText( text: string,