diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 833d005ef5..3738132cc4 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -1354,6 +1354,10 @@ func (a *App) UpdateChannelMemberNotifyProps(c request.CTX, data map[string]stri filteredProps[model.IgnoreChannelMentionsNotifyProp] = ignoreChannelMentions } + if channelAutoFollowThreads, exists := data[model.ChannelAutoFollowThreads]; exists { + filteredProps[model.ChannelAutoFollowThreads] = channelAutoFollowThreads + } + member, err := a.Srv().Store().Channel().UpdateMemberNotifyProps(channelID, userID, filteredProps) if err != nil { var appErr *model.AppError diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index d18aeb5b0f..68ae82bff1 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -101,13 +101,15 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea } channelMemberNotifyPropsMap := result.Data.(map[string]model.StringMap) - followers := make(model.StringArray, 0) + followers := make(model.StringSet, 0) if tchan != nil { result = <-tchan if result.NErr != nil { return nil, result.NErr } - followers = result.Data.([]string) + for _, v := range result.Data.([]string) { + followers.Add(v) + } } groups := make(map[string]*model.Group) @@ -235,6 +237,14 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea threadParticipants[id] = true } + if channel.Type != model.ChannelTypeDirect { + for id, propsMap := range channelMemberNotifyPropsMap { + if ok := followers.Has(id); !ok && propsMap[model.ChannelAutoFollowThreads] == model.ChannelAutoFollowThreadsOn { + 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. @@ -286,8 +296,8 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea followersMutex.Lock() // add new followers to existing followers - if threadMembership.Following && !followers.Contains(userID) { - followers = append(followers, userID) + if ok := followers.Has(userID); !ok && threadMembership.Following { + followers.Add(userID) newParticipants[userID] = true } followersMutex.Unlock() @@ -330,7 +340,7 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea notificationsForCRT := &CRTNotifiers{} if isCRTAllowed && post.RootId != "" { - for _, uid := range followers { + for uid := range followers { profile := profileMap[uid] if profile == nil || !a.IsCRTEnabledForUser(c, uid) { continue @@ -578,7 +588,7 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea // If this is a reply in a thread, notify participants if isCRTAllowed && post.RootId != "" { - for _, uid := range followers { + 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 { diff --git a/server/channels/app/notification_test.go b/server/channels/app/notification_test.go index 0fa390dc8d..4dfcd3f74d 100644 --- a/server/channels/app/notification_test.go +++ b/server/channels/app/notification_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-server/server/v8/channels/store" "github.com/mattermost/mattermost-server/server/v8/channels/utils" "github.com/mattermost/mattermost-server/server/v8/model" "github.com/mattermost/mattermost-server/server/v8/platform/shared/i18n" @@ -2819,3 +2820,66 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { assert.Nil(t, membership) }) } + +func TestChannelAutoFollowThreads(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + u1 := th.BasicUser + u2 := th.BasicUser2 + u3 := th.CreateUser() + th.LinkUserToTeam(u3, th.BasicTeam) + c1 := th.BasicChannel + th.AddUserToChannel(u2, c1) + th.AddUserToChannel(u3, c1) + + // Set auto-follow for user 2 + member, appErr := th.App.UpdateChannelMemberNotifyProps(th.Context, map[string]string{model.ChannelAutoFollowThreads: model.ChannelAutoFollowThreadsOn}, c1.Id, u2.Id) + require.Nil(t, appErr) + require.Equal(t, model.ChannelAutoFollowThreadsOn, member.NotifyProps[model.ChannelAutoFollowThreads]) + + rootPost := &model.Post{ + ChannelId: c1.Id, + Message: "root post by user3", + UserId: u3.Id, + } + rpost, appErr := th.App.CreatePost(th.Context, rootPost, c1, false, true) + require.Nil(t, appErr) + + replyPost1 := &model.Post{ + ChannelId: c1.Id, + Message: "reply post by user1", + UserId: u1.Id, + RootId: rpost.Id, + } + _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, false, true) + require.Nil(t, appErr) + + // user-2 starts auto-following thread + threadMembership, appErr := th.App.GetThreadMembershipForUser(u2.Id, rpost.Id) + require.Nil(t, appErr) + require.NotNil(t, threadMembership) + assert.True(t, threadMembership.Following) + + // Set "following" to false + _, err := th.App.Srv().Store().Thread().MaintainMembership(u2.Id, rpost.Id, store.ThreadMembershipOpts{ + Following: false, + UpdateFollowing: true, + }) + require.NoError(t, err) + + replyPost2 := &model.Post{ + ChannelId: c1.Id, + Message: "reply post 2 by user1", + UserId: u1.Id, + RootId: rpost.Id, + } + _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, false, true) + require.Nil(t, appErr) + + // Do NOT start auto-following thread, once "un-followed" + threadMembership, appErr = th.App.GetThreadMembershipForUser(u2.Id, rpost.Id) + require.Nil(t, appErr) + require.NotNil(t, threadMembership) + assert.False(t, threadMembership.Following) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 490d218c87..c359acc24f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -8459,6 +8459,10 @@ "id": "model.channel.is_valid.update_at.app_error", "translation": "Update at must be a valid time." }, + { + "id": "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error", + "translation": "Invalid channel-auto-follow-threads value." + }, { "id": "model.channel_member.is_valid.channel_id.app_error", "translation": "Invalid channel id." diff --git a/server/model/channel_member.go b/server/model/channel_member.go index dbe0014eac..23768bb6c1 100644 --- a/server/model/channel_member.go +++ b/server/model/channel_member.go @@ -19,6 +19,9 @@ const ( IgnoreChannelMentionsOff = "off" IgnoreChannelMentionsOn = "on" IgnoreChannelMentionsNotifyProp = "ignore_channel_mentions" + ChannelAutoFollowThreadsOff = "off" + ChannelAutoFollowThreadsOn = "on" + ChannelAutoFollowThreads = "channel_auto_follow_threads" ) type ChannelUnread struct { @@ -172,6 +175,12 @@ func (o *ChannelMember) IsValid() *AppError { } } + if channelAutoFollowThreads, ok := o.NotifyProps[ChannelAutoFollowThreads]; ok { + if len(channelAutoFollowThreads) > 3 || !IsChannelAutoFollowThreadsValid(channelAutoFollowThreads) { + return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error", nil, "channel_auto_follow_threads="+channelAutoFollowThreads, http.StatusBadRequest) + } + } + if len(o.Roles) > UserRolesMaxLength { return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.roles_limit.app_error", map[string]any{"Limit": UserRolesMaxLength}, "", http.StatusBadRequest) @@ -223,6 +232,10 @@ func IsIgnoreChannelMentionsValid(ignoreChannelMentions string) bool { return ignoreChannelMentions == IgnoreChannelMentionsOn || ignoreChannelMentions == IgnoreChannelMentionsOff || ignoreChannelMentions == IgnoreChannelMentionsDefault } +func IsChannelAutoFollowThreadsValid(channelAutoFollowThreads string) bool { + return channelAutoFollowThreads == ChannelAutoFollowThreadsOn || channelAutoFollowThreads == ChannelAutoFollowThreadsOff +} + func GetDefaultChannelNotifyProps() StringMap { return StringMap{ DesktopNotifyProp: ChannelNotifyDefault, @@ -230,5 +243,6 @@ func GetDefaultChannelNotifyProps() StringMap { PushNotifyProp: ChannelNotifyDefault, EmailNotifyProp: ChannelNotifyDefault, IgnoreChannelMentionsNotifyProp: IgnoreChannelMentionsDefault, + ChannelAutoFollowThreads: ChannelAutoFollowThreadsOff, } } diff --git a/server/model/utils.go b/server/model/utils.go index 956aa8caf3..fb1068e053 100644 --- a/server/model/utils.go +++ b/server/model/utils.go @@ -41,8 +41,26 @@ const ( var ErrMaxPropSizeExceeded = fmt.Errorf("max prop size of %d exceeded", maxPropSizeBytes) type StringInterface map[string]any +type StringSet map[string]struct{} type StringArray []string +func (ss StringSet) Has(val string) bool { + _, ok := ss[val] + return ok +} + +func (ss StringSet) Add(val string) { + ss[val] = struct{}{} +} + +func (ss StringSet) Val() []string { + keys := make([]string, 0, len(ss)) + for k := range ss { + keys = append(keys, k) + } + return keys +} + func (sa StringArray) Remove(input string) StringArray { for index := range sa { if sa[index] == input { diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_members/__snapshots__/channel_members.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_members/__snapshots__/channel_members.test.tsx.snap index de12f30686..337301c12b 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_members/__snapshots__/channel_members.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_members/__snapshots__/channel_members.test.tsx.snap @@ -124,6 +124,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelMembers should match "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -145,6 +146,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelMembers should match "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -166,6 +168,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelMembers should match "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove_role.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove_role.test.tsx.snap index 68a500efec..18a77631df 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove_role.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove_role.test.tsx.snap @@ -95,6 +95,7 @@ exports[`components/admin_console/team_channel_settings/group/UsersToRemoveRole "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -186,6 +187,7 @@ exports[`components/admin_console/team_channel_settings/group/UsersToRemoveRole "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -277,6 +279,7 @@ exports[`components/admin_console/team_channel_settings/group/UsersToRemoveRole "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", diff --git a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap index 6af48c2eb8..876556517d 100644 --- a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap +++ b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap @@ -284,6 +284,7 @@ exports[`components/ChannelHeader should match snapshot with last active display "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -589,6 +590,7 @@ exports[`components/ChannelHeader should match snapshot with no last active disp "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -893,6 +895,7 @@ exports[`components/ChannelHeader should render active channel files 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -1196,6 +1199,7 @@ exports[`components/ChannelHeader should render active flagged posts 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -1499,6 +1503,7 @@ exports[`components/ChannelHeader should render active mentions posts 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -1802,6 +1807,7 @@ exports[`components/ChannelHeader should render active pinned posts 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -2074,6 +2080,7 @@ exports[`components/ChannelHeader should render archived view 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -2409,6 +2416,7 @@ exports[`components/ChannelHeader should render correct menu when muted 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -2712,6 +2720,7 @@ exports[`components/ChannelHeader should render not active channel files 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -3036,6 +3045,7 @@ exports[`components/ChannelHeader should render properly when custom status is e "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -3381,6 +3391,7 @@ exports[`components/ChannelHeader should render properly when custom status is s "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -3685,6 +3696,7 @@ exports[`components/ChannelHeader should render properly when empty 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -3988,6 +4000,7 @@ exports[`components/ChannelHeader should render properly when populated 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -4310,6 +4323,7 @@ exports[`components/ChannelHeader should render properly when populated with cha "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -4626,6 +4640,7 @@ exports[`components/ChannelHeader should render shared view 1`] = ` "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", @@ -4938,6 +4953,7 @@ exports[`components/ChannelHeader should render the pinned icon with the pinned "msg_count": 0, "msg_count_root": 0, "notify_props": Object { + "channel_auto_follow_threads": "off", "desktop": "default", "email": "default", "ignore_channel_mentions": "default", 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 875eeb1d1f..54c6f5fb8e 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 @@ -121,6 +121,20 @@ exports[`components/channel_notifications_modal/ChannelNotificationsModal should serverError={null} /> +
+