diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 211ec381ba..71a0388763 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -170,6 +170,11 @@ const defaultServerConfig: AdminConfig = { EnableCustomGroups: true, SelfHostedPurchase: true, AllowSyncedDrafts: true, + AllowPersistentNotifications: true, + PersistentNotificationMaxCount: 6, + PersistentNotificationMaxRecipients: 5, + PersistentNotificationIntervalMinutes: 5, + AllowPersistentNotificationsForGuests: false, }, TeamSettings: { SiteName: 'Mattermost', diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index 6c119fd728..f380cae2e3 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -80,6 +80,57 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { post.CreateAt = 0 } + if post.GetPriority() != nil { + priorityForbiddenErr := model.NewAppError("Api4.createPost", "api.post.post_priority.priority_post_not_allowed_for_user.request_error", nil, "userId="+c.AppContext.Session().UserId, http.StatusForbidden) + + if !c.App.IsPostPriorityEnabled() { + c.Err = priorityForbiddenErr + return + } + + if post.RootId != "" { + c.Err = model.NewAppError("Api4.createPost", "api.post.post_priority.priority_post_only_allowed_for_root_post.request_error", nil, "", http.StatusBadRequest) + return + } + + if ack := post.GetRequestedAck(); ack != nil && *ack { + licenseErr := minimumProfessionalLicense(c) + if licenseErr != nil { + c.Err = licenseErr + return + } + } + + if notification := post.GetPersistentNotification(); notification != nil && *notification { + licenseErr := minimumProfessionalLicense(c) + if licenseErr != nil { + c.Err = licenseErr + return + } + if !c.App.IsPersistentNotificationsEnabled() { + c.Err = priorityForbiddenErr + return + } + + if !post.IsUrgent() { + c.Err = model.NewAppError("Api4.createPost", "api.post.post_priority.urgent_persistent_notification_post.request_error", nil, "", http.StatusBadRequest) + return + } + + if !*c.App.Config().ServiceSettings.AllowPersistentNotificationsForGuests { + user, err := c.App.GetUser(c.AppContext.Session().UserId) + if err != nil { + c.Err = err + return + } + if user.IsGuest() { + c.Err = priorityForbiddenErr + return + } + } + } + } + setOnline := r.URL.Query().Get("set_online") setOnlineBool := true // By default, always set online. var err2 error diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 81692c3b7f..32e3f2a7bb 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "reflect" "sort" "strings" @@ -211,6 +212,178 @@ func TestCreatePost(t *testing.T) { require.Equal(t, post.CreateAt, rpost.CreateAt, "create at should match") } +func TestCreatePostForPriority(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := Setup(t).InitBasic() + defer th.TearDown() + client := th.Client + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + }) + + t.Run("should return forbidden when post-priority is disabled", func(t *testing.T) { + originalPrioritySetting := *th.App.Config().ServiceSettings.PostPriority + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = originalPrioritySetting + }) + + post := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + }, + }} + + _, resp, err := client.CreatePost(post) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("should return badRequest when priority is set for reply post", func(t *testing.T) { + rootPost := &model.Post{ChannelId: th.BasicChannel.Id, Message: "root"} + + post, resp, err := client.CreatePost(rootPost) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + + replyPost := &model.Post{RootId: post.Id, ChannelId: th.BasicChannel.Id, Message: "reply", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + }, + }} + _, resp, err = client.CreatePost(replyPost) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("should return statusNotImplemented when min. pro. license not available", func(t *testing.T) { + th.App.Srv().RemoveLicense() + defer th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + // for Acknowledment + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + RequestedAck: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + + // for Persistent Notification + p2 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + PersistentNotifications: model.NewBool(true), + }, + }} + _, resp, err = client.CreatePost(p2) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("should return forbidden when persistent notification not enabled", func(t *testing.T) { + originalSetting := *th.App.Config().ServiceSettings.AllowPersistentNotifications + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowPersistentNotifications = false + }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowPersistentNotifications = originalSetting + }) + + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + PersistentNotifications: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("should return badRequest when post is not urgent for persistent notification", func(t *testing.T) { + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + PersistentNotifications: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("should return forbidden when persistent notification is disabled for guest users", func(t *testing.T) { + originalSetting := *th.App.Config().ServiceSettings.AllowPersistentNotificationsForGuests + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = false + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = originalSetting + }) + + appErr := th.App.DemoteUserToGuest(th.Context, th.BasicUser) + require.Nil(t, appErr) + defer th.App.PromoteGuestToUser(th.Context, th.BasicUser, th.SystemAdminUser.Id) + + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + PersistentNotifications: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("should create priority post", func(t *testing.T) { + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + }, + }} + _, resp, err := client.CreatePost(p1) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + }) + + t.Run("should create acknowledge post", func(t *testing.T) { + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test", Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(""), + RequestedAck: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + }) + + t.Run("should create persistent notification post", func(t *testing.T) { + p1 := &model.Post{ChannelId: th.BasicChannel.Id, Message: "test @" + th.BasicUser2.Username, Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("urgent"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + }} + _, resp, err := client.CreatePost(p1) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + }) +} + func TestCreatePostWithOAuthClient(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index b9b6a22538..b7aa51f411 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -5991,6 +5991,9 @@ func TestGetThreadsForUser(t *testing.T) { *cfg.ServiceSettings.ThreadAutoFollow = true *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn }) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + t.Run("empty", func(t *testing.T) { client := th.Client @@ -6084,47 +6087,84 @@ func TestGetThreadsForUser(t *testing.T) { require.Greater(t, uss.Threads[0].Post.DeleteAt, int64(0)) }) + t.Run("throw error when post-priority service-setting is off", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + cfg.FeatureFlags.PostPriority = true + }) + + client := th.Client + + _, resp, err := client.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "testMsg", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("throw error when post-priority is set for a reply", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + client := th.Client + + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) + + rpost, resp, err := client.CreatePost(&model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + + _, resp, err = client.CreatePost(&model.Post{ + RootId: rpost.Id, + ChannelId: th.BasicChannel.Id, + Message: "testReply", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("isUrgent, 1 thread", func(t *testing.T) { - testCases := []struct { - featureEnabled bool - expected bool - }{ - {featureEnabled: true, expected: true}, - {featureEnabled: false, expected: false}, - } + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) - for _, tc := range testCases { - func() { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.PostPriority = tc.featureEnabled - cfg.FeatureFlags.PostPriority = true - }) + client := th.Client - client := th.Client + rpost, resp, err := client.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "testMsg", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, + }, + }) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + _, resp, err = client.CreatePost(&model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + require.NoError(t, err) + CheckCreatedStatus(t, resp) - rpost, resp, err := client.CreatePost(&model.Post{ - ChannelId: th.BasicChannel.Id, - Message: "testMsg", - Metadata: &model.PostMetadata{ - Priority: &model.PostPriority{ - Priority: model.NewString(model.PostPriorityUrgent), - }, - }, - }) - require.NoError(t, err) - CheckCreatedStatus(t, resp) - _, resp, err = client.CreatePost(&model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) - require.NoError(t, err) - CheckCreatedStatus(t, resp) + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) - defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) - - uss, _, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) - require.NoError(t, err) - require.Len(t, uss.Threads, 1) - require.Equal(t, uss.Threads[0].IsUrgent, tc.expected) - }() - } + uss, _, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) + require.NoError(t, err) + require.Len(t, uss.Threads, 1) + require.Equal(t, true, uss.Threads[0].IsUrgent) }) t.Run("paged, 30 threads", func(t *testing.T) { @@ -6864,9 +6904,10 @@ func TestSingleThreadGet(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true - *cfg.ServiceSettings.PostPriority = false *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn *cfg.ServiceSettings.PostPriority = true cfg.FeatureFlags.PostPriority = true diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index f1176c4126..9f52c2539e 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -122,6 +122,8 @@ type AppIface interface { // DeleteGroupConstrainedMemberships deletes team and channel memberships of users who aren't members of the allowed // groups of all group-constrained teams and channels. DeleteGroupConstrainedMemberships(c *request.Context) error + // DeletePersistentNotification stops the persistent notifications. + DeletePersistentNotification(c request.CTX, post *model.Post) *model.AppError // DeletePublicKey will delete plugin public key from the config. DeletePublicKey(name string) *model.AppError // DemoteUserToGuest Convert user's roles and all his membership's roles from @@ -297,6 +299,9 @@ type AppIface interface { RenameChannel(c request.CTX, channel *model.Channel, newChannelName string, newDisplayName string) (*model.Channel, *model.AppError) // RenameTeam is used to rename the team Name and the DisplayName fields RenameTeam(team *model.Team, newTeamName string, newDisplayName string) (*model.Team, *model.AppError) + // ResolvePersistentNotification stops the persistent notifications, if a loggedInUserID(except the post owner) reacts, reply or ack on the post. + // Post-owner can only delete the original post to stop the notifications. + ResolvePersistentNotification(c request.CTX, post *model.Post, loggedInUserID string) *model.AppError // RevokeSessionsFromAllUsers will go through all the sessions active // in the server and revoke them RevokeSessionsFromAllUsers() *model.AppError @@ -898,8 +903,10 @@ type AppIface interface { IsFirstUserAccount() bool IsLeader() bool IsPasswordValid(password string) *model.AppError + IsPersistentNotificationsEnabled() bool IsPhase2MigrationCompleted() *model.AppError IsPluginActive(pluginName string) (bool, error) + IsPostPriorityEnabled() bool IsUserSignUpAllowed() *model.AppError JoinChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError JoinDefaultChannels(c request.CTX, teamID string, user *model.User, shouldBeAdmin bool, userRequestorId string) *model.AppError @@ -1053,6 +1060,7 @@ type AppIface interface { SendNotifyAdminPosts(c *request.Context, workspaceName string, currentSKU string, trial bool) *model.AppError SendPasswordReset(email string, siteURL string) (bool, *model.AppError) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError + SendPersistentNotifications() error SendTestPushNotification(deviceID string) string SendUpgradeConfirmationEmail(isYearly bool) *model.AppError ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index c4399cecd4..b1f6e96461 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -1521,11 +1521,16 @@ func (a *App) DeleteChannel(c request.CTX, channel *model.Channel, userID string } } + if err := a.Srv().Store().PostPersistentNotification().DeleteByChannel([]string{channel.Id}); err != nil { + return model.NewAppError("DeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + deleteAt := model.GetMillis() if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + a.Srv().Platform().InvalidateCacheForChannel(channel) message := model.NewWebSocketEvent(model.WebsocketEventChannelDeleted, channel.TeamId, "", "", nil, "") @@ -2791,7 +2796,7 @@ func (a *App) markChannelAsUnreadFromPostCRTUnsupported(c request.CTX, postID st if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } - thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) + thread, mErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.IsPostPriorityEnabled()) if mErr != nil { return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, "", http.StatusInternalServerError).Wrap(mErr) } @@ -3095,6 +3100,10 @@ func (a *App) PermanentDeleteChannel(c request.CTX, channel *model.Channel) *mod return model.NewAppError("PermanentDeleteChannel", "app.webhooks.permanent_delete_outgoing_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + if err := a.Srv().Store().PostPersistentNotification().DeleteByChannel([]string{channel.Id}); err != nil { + return model.NewAppError("PermanentDeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + deleteAt := model.GetMillis() if nErr := a.Srv().Store().Channel().PermanentDelete(channel.Id); nErr != nil { diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index 03cce2090c..54c5921bf5 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -607,7 +607,7 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea } threadMembership = tm } - userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.isPostPriorityEnabled()) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(threadMembership, true, a.IsPostPriorityEnabled()) if err != nil { return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid) } diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 614ad33811..d268cc9b0e 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -3285,6 +3285,28 @@ func (a *OpenTracingAppLayer) DeleteOutgoingWebhook(hookID string) *model.AppErr return resultVar0 } +func (a *OpenTracingAppLayer) DeletePersistentNotification(c request.CTX, post *model.Post) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeletePersistentNotification") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeletePersistentNotification(c, post) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeletePluginKey(pluginID string, key string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeletePluginKey") @@ -12121,6 +12143,23 @@ func (a *OpenTracingAppLayer) IsPasswordValid(password string) *model.AppError { return resultVar0 } +func (a *OpenTracingAppLayer) IsPersistentNotificationsEnabled() bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsPersistentNotificationsEnabled") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.IsPersistentNotificationsEnabled() + + return resultVar0 +} + func (a *OpenTracingAppLayer) IsPhase2MigrationCompleted() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsPhase2MigrationCompleted") @@ -12165,6 +12204,23 @@ func (a *OpenTracingAppLayer) IsPluginActive(pluginName string) (bool, error) { return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) IsPostPriorityEnabled() bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsPostPriorityEnabled") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.IsPostPriorityEnabled() + + return resultVar0 +} + func (a *OpenTracingAppLayer) IsUserSignUpAllowed() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsUserSignUpAllowed") @@ -14397,6 +14453,28 @@ func (a *OpenTracingAppLayer) ResetSamlAuthDataToEmail(includeDeleted bool, dryR return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) ResolvePersistentNotification(c request.CTX, post *model.Post, loggedInUserID string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ResolvePersistentNotification") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.ResolvePersistentNotification(c, post, loggedInUserID) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) RestoreChannel(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RestoreChannel") @@ -15761,6 +15839,28 @@ func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.Failed return resultVar0 } +func (a *OpenTracingAppLayer) SendPersistentNotifications() error { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPersistentNotifications") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.SendPersistentNotifications() + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendSubscriptionHistoryEvent") diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 2548832529..8de17053da 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -196,6 +196,21 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel a.Srv().seenPendingPostIdsCache.SetWithExpiry(post.PendingPostId, savedPost.Id, PendingPostIDsCacheTTL) }() + // Validate recipients counts in case it's not DM + if persistentNotification := post.GetPersistentNotification(); persistentNotification != nil && *persistentNotification && channel.Type != model.ChannelTypeDirect { + err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *ExplicitMentions, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + if maxRecipients := *a.Config().ServiceSettings.PersistentNotificationMaxRecipients; len(mentions.Mentions) > maxRecipients { + return model.NewAppError("CreatePost", "api.post.post_priority.max_recipients_persistent_notification_post.request_error", map[string]any{"MaxRecipients": maxRecipients}, "", http.StatusBadRequest) + } else if len(mentions.Mentions) == 0 { + return model.NewAppError("CreatePost", "api.post.post_priority.min_recipients_persistent_notification_post.request_error", nil, "", http.StatusBadRequest) + } + return nil + }) + if err != nil { + return nil, model.NewAppError("CreatePost", "api.post.post_priority.persistent_notification_validation_error.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + post.SanitizeProps() var pchan chan store.StoreResult @@ -279,10 +294,6 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel } } - if !a.isPostPriorityEnabled() && post.GetPriority() != nil { - post.Metadata.Priority = nil - } - var metadata *model.PostMetadata if post.Metadata != nil { metadata = post.Metadata.Copy() @@ -374,6 +385,12 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel // so we just return the one that was passed with post rpost = a.PreparePostForClient(c, rpost, true, false, false) + if rpost.RootId != "" { + if appErr := a.ResolvePersistentNotification(c, parentPostList.Posts[post.RootId], rpost.UserId); appErr != nil { + return nil, appErr + } + } + // Make sure poster is following the thread if *a.Config().ServiceSettings.ThreadAutoFollow && rpost.RootId != "" { _, err := a.Srv().Store().Thread().MaintainMembership(user.Id, rpost.RootId, store.ThreadMembershipOpts{ @@ -1288,6 +1305,12 @@ func (a *App) DeletePost(c request.CTX, postID, deleteByID string) (*model.Post, } } + if post.RootId == "" { + if appErr := a.DeletePersistentNotification(c, post); appErr != nil { + return nil, appErr + } + } + postJSON, err := json.Marshal(post) if err != nil { return nil, model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -1792,7 +1815,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model } var urgentCount int - if a.isPostPriorityEnabled() { + if a.IsPostPriorityEnabled() { urgentCount, nErr = a.Srv().Store().Channel().CountUrgentPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id)) if nErr != nil { return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_urgent_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) @@ -1832,7 +1855,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model count += 1 if post.RootId == "" { countRoot += 1 - if a.isPostPriorityEnabled() { + if a.IsPostPriorityEnabled() { priority, err := a.GetPriorityForPost(post.Id) if err != nil { return 0, 0, 0, err @@ -1868,7 +1891,7 @@ func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model } } - if a.isPostPriorityEnabled() { + if a.IsPostPriorityEnabled() { priorityList, nErr := a.Srv().Store().PostPriority().GetForPosts(mentionPostIds) if nErr != nil { return 0, 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.get_priority_for_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) @@ -2277,7 +2300,3 @@ func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThrea } return topThreadList, nil } - -func (a *App) isPostPriorityEnabled() bool { - return a.Config().FeatureFlags.PostPriority && *a.Config().ServiceSettings.PostPriority -} diff --git a/server/channels/app/post_acknowledgements.go b/server/channels/app/post_acknowledgements.go index 51c50e6e77..b6933ce5c7 100644 --- a/server/channels/app/post_acknowledgements.go +++ b/server/channels/app/post_acknowledgements.go @@ -31,7 +31,6 @@ func (a *App) SaveAcknowledgementForPost(c *request.Context, postID, userID stri acknowledgedAt := model.GetMillis() acknowledgement, nErr := a.Srv().Store().PostAcknowledgement().Save(postID, userID, acknowledgedAt) - if nErr != nil { var appErr *model.AppError switch { @@ -42,6 +41,10 @@ func (a *App) SaveAcknowledgementForPost(c *request.Context, postID, userID stri } } + if appErr := a.ResolvePersistentNotification(c, post, userID); appErr != nil { + return nil, appErr + } + // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(channel.Id) diff --git a/server/channels/app/post_metadata.go b/server/channels/app/post_metadata.go index 9addf12b70..22083f22bc 100644 --- a/server/channels/app/post_metadata.go +++ b/server/channels/app/post_metadata.go @@ -63,7 +63,7 @@ func (a *App) PreparePostListForClient(c request.CTX, originalList *model.PostLi list.Posts[id] = post } - if a.isPostPriorityEnabled() { + if a.IsPostPriorityEnabled() { priority, _ := a.GetPriorityForPostList(list) acknowledgements, _ := a.GetAcknowledgementsForPostList(list) @@ -139,7 +139,7 @@ func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNe post.Metadata.Files = fileInfos } - if includePriority && a.isPostPriorityEnabled() && post.RootId == "" { + if includePriority && a.IsPostPriorityEnabled() && post.RootId == "" { // Post's Priority if any if priority, err := a.GetPriorityForPost(post.Id); err != nil { mlog.Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err)) diff --git a/server/channels/app/post_persistent_notification.go b/server/channels/app/post_persistent_notification.go new file mode 100644 index 0000000000..9d047e166a --- /dev/null +++ b/server/channels/app/post_persistent_notification.go @@ -0,0 +1,396 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "context" + "net/http" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/public/shared/mlog" + "github.com/mattermost/mattermost-server/server/v8/channels/app/request" + "github.com/mattermost/mattermost-server/server/v8/channels/store" + "github.com/pkg/errors" +) + +// ResolvePersistentNotification stops the persistent notifications, if a loggedInUserID(except the post owner) reacts, reply or ack on the post. +// Post-owner can only delete the original post to stop the notifications. +func (a *App) ResolvePersistentNotification(c request.CTX, post *model.Post, loggedInUserID string) *model.AppError { + // Ignore the post owner's actions to their own post + if loggedInUserID == post.UserId { + return nil + } + + if !a.IsPersistentNotificationsEnabled() { + return nil + } + + _, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // Either the notification post is already deleted or was never a notification post + return nil + default: + return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if !*a.Config().ServiceSettings.AllowPersistentNotificationsForGuests { + user, nErr := a.Srv().Store().User().Get(context.Background(), loggedInUserID) + if nErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(nErr, &nfErr): + return model.NewAppError("ResolvePersistentNotification", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr) + default: + return model.NewAppError("ResolvePersistentNotification", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + if user.IsGuest() { + return nil + } + } + + stopNotifications := false + if err := a.forEachPersistentNotificationPost([]*model.Post{post}, func(_ *model.Post, _ *model.Channel, _ *model.Team, mentions *ExplicitMentions, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + if mentions.isUserMentioned(loggedInUserID) { + stopNotifications = true + } + return nil + }); err != nil { + return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Only mentioned users can stop the notifications + if !stopNotifications { + return nil + } + + if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil { + return model.NewAppError("ResolvePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return nil +} + +// DeletePersistentNotification stops the persistent notifications. +func (a *App) DeletePersistentNotification(c request.CTX, post *model.Post) *model.AppError { + if !a.IsPersistentNotificationsEnabled() { + return nil + } + + _, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + // Either the notification post is already deleted or was never a notification post + return nil + default: + return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if err := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); err != nil { + return model.NewAppError("DeletePersistentNotification", "app.post_priority.delete_persistent_notification_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return nil +} + +func (a *App) SendPersistentNotifications() error { + notificationInterval := time.Duration(*a.Config().ServiceSettings.PersistentNotificationIntervalMinutes) * time.Minute + notificationMaxCount := int16(*a.Config().ServiceSettings.PersistentNotificationMaxCount) + + // fetch posts for which the "notificationInterval duration" has passed + maxTime := time.Now().Add(-notificationInterval).UnixMilli() + + // Pagination loop + for { + notificationPosts, err := a.Srv().Store().PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: maxTime, + MaxSentCount: notificationMaxCount, + PerPage: 500, + }) + if err != nil { + return errors.Wrap(err, "failed to get posts for persistent notifications") + } + + // No posts left to send persistent notifications + if len(notificationPosts) == 0 { + break + } + + postIds := make([]string, 0, len(notificationPosts)) + for _, p := range notificationPosts { + postIds = append(postIds, p.PostId) + } + posts, err := a.Srv().Store().Post().GetPostsByIds(postIds) + if err != nil { + return errors.Wrap(err, "failed to get posts by IDs") + } + + // Send notifications + if err := a.forEachPersistentNotificationPost(posts, a.sendPersistentNotifications); err != nil { + return err + } + + if err := a.Srv().Store().PostPersistentNotification().UpdateLastActivity(postIds); err != nil { + return errors.Wrapf(err, "failed to update lastActivity for notifications: %v", postIds) + } + } + + if err := a.Srv().Store().PostPersistentNotification().DeleteExpired(notificationMaxCount); err != nil { + return errors.Wrap(err, "failed to delete expired notifications") + } + + return nil +} + +func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(post *model.Post, channel *model.Channel, team *model.Team, mentions *ExplicitMentions, profileMap model.UserMap, channelNotifyProps map[string]map[string]model.StringMap) error) error { + channelsMap, teamsMap, err := a.channelTeamMapsForPosts(posts) + if err != nil { + return err + } + + channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, err := a.persistentNotificationsAuxiliaryData(channelsMap, teamsMap) + if err != nil { + return err + } + + for _, post := range posts { + channel := channelsMap[post.ChannelId] + team := teamsMap[channel.TeamId] + // GMs and DMs don't belong to any team + if channel.IsGroupOrDirect() { + team = &model.Team{} + } + profileMap := channelProfileMap[channel.Id] + + mentions := &ExplicitMentions{} + // In DMs, only the "other" user can be mentioned + if channel.Type == model.ChannelTypeDirect { + otherUserId := channel.GetOtherUserIdForDM(post.UserId) + if _, ok := profileMap[otherUserId]; ok { + mentions.addMention(otherUserId, DMMention) + } + } else { + keywords := channelKeywords[channel.Id] + mentions = getExplicitMentions(post, keywords, channelGroupMap[channel.Id]) + for _, group := range mentions.GroupMentions { + _, err := a.insertGroupMentions(group, channel, profileMap, mentions) + if err != nil { + return errors.Wrapf(err, "failed to include mentions from group - %s for channel - %s", group.Id, channel.Id) + } + } + } + + if err := fn(post, channel, team, mentions, profileMap, channelNotifyProps); err != nil { + return err + } + } + + return nil +} + +func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model.Channel, teamsMap map[string]*model.Team) (map[string]map[string]*model.Group, map[string]model.UserMap, map[string]map[string][]string, map[string]map[string]model.StringMap, error) { + channelGroupMap := make(map[string]map[string]*model.Group, len(channelsMap)) + channelProfileMap := make(map[string]model.UserMap, len(channelsMap)) + channelKeywords := make(map[string]map[string][]string, len(channelsMap)) + channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap)) + for _, c := range channelsMap { + // In DM, notifications can't be send to any 3rd person. + if c.Type != model.ChannelTypeDirect { + groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId]) + if err != nil { + return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) + } + channelGroupMap[c.Id] = make(map[string]*model.Group, len(groups)) + for k, v := range groups { + channelGroupMap[c.Id][k] = v + } + props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(c.Id, true) + if err != nil { + return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) + } + channelNotifyProps[c.Id] = props + } + + profileMap, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), c.Id, true) + if err != nil { + return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) + } + + channelKeywords[c.Id] = make(map[string][]string, len(profileMap)) + validProfileMap := make(map[string]*model.User, len(profileMap)) + for k, v := range profileMap { + if v.IsBot { + continue + } + validProfileMap[k] = v + channelKeywords[c.Id]["@"+v.Username] = []string{k} + } + channelProfileMap[c.Id] = validProfileMap + } + return channelGroupMap, channelProfileMap, channelKeywords, channelNotifyProps, nil +} + +func (a *App) channelTeamMapsForPosts(posts []*model.Post) (map[string]*model.Channel, map[string]*model.Team, error) { + channelIds := make(model.StringSet) + for _, p := range posts { + channelIds.Add(p.ChannelId) + } + channels, err := a.Srv().Store().Channel().GetChannelsByIds(channelIds.Val(), false) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to get teams by IDs") + } + channelsMap := make(map[string]*model.Channel, len(channels)) + for _, c := range channels { + channelsMap[c.Id] = c + } + + teamIds := make(model.StringSet) + for _, c := range channels { + if c.TeamId != "" { + teamIds.Add(c.TeamId) + } + } + teams := make([]*model.Team, 0, len(teamIds)) + if len(teamIds) > 0 { + teams, err = a.Srv().Store().Team().GetMany(teamIds.Val()) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to get teams by IDs") + } + } + teamsMap := make(map[string]*model.Team, len(teams)) + for _, t := range teams { + teamsMap[t.Id] = t + } + return channelsMap, teamsMap, nil +} + +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 { + mentionedUsersList = append(mentionedUsersList, id) + } + } + + sender := profileMap[post.UserId] + notification := &PostNotification{ + Post: post, + Channel: channel, + ProfileMap: profileMap, + Sender: sender, + } + + if int64(len(mentionedUsersList)) > *a.Config().TeamSettings.MaxNotificationsPerChannel { + return errors.Errorf("mentioned users: %d are more than allowed users: %d", len(mentionedUsersList), *a.Config().TeamSettings.MaxNotificationsPerChannel) + } + + if a.canSendPushNotifications() { + for _, userID := range mentionedUsersList { + user := profileMap[userID] + if user == nil { + continue + } + + status, err := a.GetStatus(userID) + if err != nil { + mlog.Warn("Unable to fetch online status", mlog.String("user_id", userID), mlog.Err(err)) + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + if ShouldSendPushNotification(profileMap[userID], channelNotifyProps[channel.Id][userID], true, status, post) { + a.sendPushNotification( + notification, + user, + true, + false, + "", + ) + } else { + // register that a notification was not sent + a.NotificationsLog().Debug("Persistent Notification not sent", + mlog.String("ackId", ""), + mlog.String("type", model.PushTypeMessage), + mlog.String("userId", userID), + mlog.String("postId", post.Id), + mlog.String("status", model.PushNotSent), + ) + } + } + } + + desktopUsers := make([]string, 0, len(mentionedUsersList)) + for _, id := range mentionedUsersList { + user := profileMap[id] + if user == nil { + continue + } + + if user.NotifyProps[model.DesktopNotifyProp] != model.UserNotifyNone && a.persistentNotificationsAllowedForStatus(id) { + desktopUsers = append(desktopUsers, id) + } + } + + if len(desktopUsers) != 0 { + post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, false, false, true) + postJSON, jsonErr := post.ToJSON() + if jsonErr != nil { + return errors.Wrapf(jsonErr, "failed to encode post to JSON") + } + + for _, u := range desktopUsers { + message := model.NewWebSocketEvent(model.WebsocketEventPersistentNotificationTriggered, team.Id, post.ChannelId, u, nil, "") + + message.Add("post", postJSON) + message.Add("channel_type", channel.Type) + message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, "")) + message.Add("channel_name", channel.Name) + message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride)) + message.Add("team_id", team.Id) + + if len(post.FileIds) != 0 { + message.Add("otherFile", "true") + + infos, err := a.Srv().Store().FileInfo().GetForPost(post.Id, false, false, true) + if err != nil { + mlog.Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(err)) + } + + for _, info := range infos { + if info.IsImage() { + message.Add("image", "true") + break + } + } + } + + message.Add("mentions", model.ArrayToJSON(desktopUsers)) + a.Publish(message) + } + } + + return nil +} + +func (a *App) persistentNotificationsAllowedForStatus(userID string) bool { + var status *model.Status + var err *model.AppError + if status, err = a.GetStatus(userID); err != nil { + status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + return status.Status != model.StatusDnd && status.Status != model.StatusOutOfOffice +} + +func (a *App) IsPersistentNotificationsEnabled() bool { + return a.IsPostPriorityEnabled() && *a.Config().ServiceSettings.AllowPersistentNotifications +} diff --git a/server/channels/app/post_persistent_notification_test.go b/server/channels/app/post_persistent_notification_test.go new file mode 100644 index 0000000000..f4a7a5c367 --- /dev/null +++ b/server/channels/app/post_persistent_notification_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/store" + storemocks "github.com/mattermost/mattermost-server/server/v8/channels/store/storetest/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResolvePersistentNotification(t *testing.T) { + t.Run("should not delete when no posts exist", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := SetupWithStoreMock(t) + defer th.TearDown() + + post := &model.Post{Id: "test id"} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", mock.Anything).Return(nil, &store.ErrNotFound{}) + mockPostPersistentNotification.On("Delete", mock.Anything).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = true + + err := th.App.ResolvePersistentNotification(th.Context, post, "") + require.Nil(t, err) + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should delete for mentioned user", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := SetupWithStoreMock(t) + defer th.TearDown() + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + post := &model.Post{Id: "pid", ChannelId: channel.Id, Message: "tagging @" + user1.Username, UserId: user2.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", mock.Anything).Return(&model.PostPersistentNotifications{PostId: post.Id}, nil) + mockPostPersistentNotification.On("Delete", mock.Anything).Return(nil) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = true + + err := th.App.ResolvePersistentNotification(th.Context, post, user1.Id) + require.Nil(t, err) + mockPostPersistentNotification.AssertCalled(t, "Delete", mock.Anything) + }) + + t.Run("should not delete for post owner", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + user1 := &model.User{Id: "uid1"} + post := &model.Post{Id: "test id", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + err := th.App.ResolvePersistentNotification(th.Context, post, user1.Id) + require.Nil(t, err) + + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should not delete for non-mentioned user", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := SetupWithStoreMock(t) + defer th.TearDown() + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + user3 := &model.User{Id: "uid3", Username: "user-3"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2, user3.Id: user3} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + post := &model.Post{Id: "pid", ChannelId: channel.Id, Message: "tagging @" + user1.Username, UserId: user2.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", mock.Anything).Return(&model.PostPersistentNotifications{PostId: post.Id}, nil) + mockPostPersistentNotification.On("Delete", mock.Anything).Return(nil) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = true + + err := th.App.ResolvePersistentNotification(th.Context, post, user3.Id) + require.Nil(t, err) + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) +} + +func TestDeletePersistentNotification(t *testing.T) { + t.Run("should not delete when no posts exist", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := SetupWithStoreMock(t) + defer th.TearDown() + + post := &model.Post{Id: "test id"} + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockPostPersistentNotification.On("GetSingle", mock.Anything).Return(nil, &store.ErrNotFound{}) + mockPostPersistentNotification.On("Delete", mock.Anything).Return(nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotificationsForGuests = true + + err := th.App.DeletePersistentNotification(th.Context, post) + require.Nil(t, err) + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should delete", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_POSTPRIORITY", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_POSTPRIORITY") + + th := SetupWithStoreMock(t) + defer th.TearDown() + + post := &model.Post{Id: "test id"} + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockPostPersistentNotification.On("GetSingle", mock.Anything).Return(&model.PostPersistentNotifications{PostId: post.Id}, nil) + mockPostPersistentNotification.On("Delete", mock.Anything).Return(nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + + err := th.App.DeletePersistentNotification(th.Context, post) + require.Nil(t, err) + mockPostPersistentNotification.AssertCalled(t, "Delete", mock.Anything) + }) +} + +func TestSendPersistentNotifications(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false) + + s := "Urgent" + tr := true + p1 := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "test " + "@" + th.BasicUser2.Username, + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: &s, + PersistentNotifications: &tr, + }, + }, + } + _, appErr := th.App.CreatePost(th.Context, p1, th.BasicChannel, false, false) + require.Nil(t, appErr) + + err := th.App.SendPersistentNotifications() + require.NoError(t, err) +} diff --git a/server/channels/app/post_priority.go b/server/channels/app/post_priority.go index 99ddf69e2f..2d7118b9b1 100644 --- a/server/channels/app/post_priority.go +++ b/server/channels/app/post_priority.go @@ -32,3 +32,7 @@ func (a *App) GetPriorityForPostList(list *model.PostList) (map[string]*model.Po return priorityMap, nil } + +func (a *App) IsPostPriorityEnabled() bool { + return a.Config().FeatureFlags.PostPriority && *a.Config().ServiceSettings.PostPriority +} diff --git a/server/channels/app/reaction.go b/server/channels/app/reaction.go index 289d6fde66..0624a2c0aa 100644 --- a/server/channels/app/reaction.go +++ b/server/channels/app/reaction.go @@ -26,7 +26,7 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) } if channel.DeleteAt > 0 { - return nil, model.NewAppError("deleteReactionForPost", "api.reaction.save.archived_channel.app_error", nil, "", http.StatusForbidden) + return nil, model.NewAppError("SaveReactionForPost", "api.reaction.save.archived_channel.app_error", nil, "", http.StatusForbidden) } reaction, nErr := a.Srv().Store().Reaction().Save(reaction) @@ -40,6 +40,12 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) } } + if post.RootId == "" { + if appErr := a.ResolvePersistentNotification(c, post, reaction.UserId); appErr != nil { + return nil, appErr + } + } + // The post is always modified since the UpdateAt always changes a.invalidateCacheForChannelPosts(post.ChannelId) diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 8a540af3d6..990092d0f3 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -51,6 +51,7 @@ import ( "github.com/mattermost/mattermost-server/server/v8/channels/jobs/migrations" "github.com/mattermost/mattermost-server/server/v8/channels/jobs/notify_admin" "github.com/mattermost/mattermost-server/server/v8/channels/jobs/plugins" + "github.com/mattermost/mattermost-server/server/v8/channels/jobs/post_persistent_notifications" "github.com/mattermost/mattermost-server/server/v8/channels/jobs/product_notices" "github.com/mattermost/mattermost-server/server/v8/channels/jobs/resend_invitation_email" "github.com/mattermost/mattermost-server/server/v8/channels/product" @@ -1582,6 +1583,12 @@ func (s *Server) initJobs() { notify_admin.MakeScheduler(s.Jobs, s.License(), model.JobTypeTrialNotifyAdmin), ) + s.Jobs.RegisterJobType( + model.JobTypePostPersistentNotifications, + post_persistent_notifications.MakeWorker(s.Jobs, New(ServerConnector(s.Channels()))), + post_persistent_notifications.MakeScheduler(s.Jobs, func() *model.License { return s.License() }), + ) + s.Jobs.RegisterJobType( model.JobTypeInstallPluginNotifyAdmin, notify_admin.MakeInstallPluginNotifyWorker(s.Jobs, New(ServerConnector(s.Channels()))), diff --git a/server/channels/app/team.go b/server/channels/app/team.go index 382a7b8fda..aed66326d2 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -1897,7 +1897,7 @@ func (a *App) GetTeamsUnreadForUser(excludeTeamId string, userID string, include includeCollapsedThreads = includeCollapsedThreads && *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled if includeCollapsedThreads { - teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs, a.isPostPriorityEnabled()) + teamUnreads, err := a.Srv().Store().Thread().GetTeamsUnreadForUser(userID, teamIDs, a.IsPostPriorityEnabled()) if err != nil { return nil, model.NewAppError("GetTeamsUnreadForUser", "app.team.get_unread.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1976,6 +1976,10 @@ func (a *App) SoftDeleteTeam(teamID string) *model.AppError { return err } + if err := a.Srv().Store().PostPersistentNotification().DeleteByTeam([]string{team.Id}); err != nil { + return model.NewAppError("SoftDeleteTeam", "app.post_persistent_notification.delete_by_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + team.DeleteAt = model.GetMillis() team, nErr := a.Srv().Store().Team().Update(team) if nErr != nil { diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 6f82e83267..4539d5f8d2 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -2474,7 +2474,7 @@ func (a *App) ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.U func (a *App) GetThreadsForUser(userID, teamID string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError) { var result model.Threads var eg errgroup.Group - postPriorityIsEnabled := a.isPostPriorityEnabled() + postPriorityIsEnabled := a.IsPostPriorityEnabled() if postPriorityIsEnabled { options.IncludeIsUrgent = true } @@ -2571,7 +2571,7 @@ func (a *App) GetThreadMembershipForUser(userId, threadId string) (*model.Thread } func (a *App) GetThreadForUser(threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, *model.AppError) { - thread, nErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended, a.isPostPriorityEnabled()) + thread, nErr := a.Srv().Store().Thread().GetThreadForUser(threadMembership, extended, a.IsPostPriorityEnabled()) if nErr != nil { var nfErr *store.ErrNotFound switch { @@ -2657,7 +2657,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(c request.CTX, userID, tea } message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, teamID, "", userID, nil, "") - userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true, a.isPostPriorityEnabled()) + userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true, a.IsPostPriorityEnabled()) if err != nil { var errNotFound *store.ErrNotFound diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index eef5b1fe2a..3fa6593781 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -216,6 +216,8 @@ channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql channels/db/migrations/mysql/000108_remove_orphaned_oauth_preferences.down.sql channels/db/migrations/mysql/000108_remove_orphaned_oauth_preferences.up.sql +channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql +channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -432,3 +434,5 @@ channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql channels/db/migrations/postgres/000108_remove_orphaned_oauth_preferences.down.sql channels/db/migrations/postgres/000108_remove_orphaned_oauth_preferences.up.sql +channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql +channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql diff --git a/server/channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql b/server/channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql new file mode 100644 index 0000000000..a798c9a3cf --- /dev/null +++ b/server/channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS PersistentNotifications; diff --git a/server/channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql b/server/channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql new file mode 100644 index 0000000000..257bf13ad4 --- /dev/null +++ b/server/channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS PersistentNotifications ( + PostId varchar(26) NOT NULL, + CreateAt bigint(20) DEFAULT NULL, + LastSentAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + SentCount smallint DEFAULT NULL, + PRIMARY KEY (PostId) +); diff --git a/server/channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql b/server/channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql new file mode 100644 index 0000000000..8c8290bf49 --- /dev/null +++ b/server/channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS persistentnotifications; diff --git a/server/channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql b/server/channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql new file mode 100644 index 0000000000..884b135585 --- /dev/null +++ b/server/channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS persistentnotifications ( + postid VARCHAR(26) PRIMARY KEY, + createat bigint, + lastsentat bigint, + deleteat bigint, + sentcount smallint +); diff --git a/server/channels/jobs/post_persistent_notifications/scheduler.go b/server/channels/jobs/post_persistent_notifications/scheduler.go new file mode 100644 index 0000000000..42d90797fe --- /dev/null +++ b/server/channels/jobs/post_persistent_notifications/scheduler.go @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package post_persistent_notifications + +import ( + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/jobs" +) + +type Scheduler struct { + *jobs.PeriodicScheduler +} + +func (scheduler *Scheduler) NextScheduleTime(cfg *model.Config, _ time.Time, _ bool, _ *model.Job) *time.Time { + nextTime := time.Now().Add((time.Duration(*cfg.ServiceSettings.PersistentNotificationIntervalMinutes) * time.Minute) / 2) + return &nextTime +} + +func MakeScheduler(jobServer *jobs.JobServer, licenseFunc func() *model.License) model.Scheduler { + enabledFunc := func(_ *model.Config) bool { + l := licenseFunc() + return l != nil && (l.SkuShortName == model.LicenseShortSkuProfessional || l.SkuShortName == model.LicenseShortSkuEnterprise) + } + return &Scheduler{jobs.NewPeriodicScheduler(jobServer, model.JobTypePostPersistentNotifications, 0, enabledFunc)} +} diff --git a/server/channels/jobs/post_persistent_notifications/worker.go b/server/channels/jobs/post_persistent_notifications/worker.go new file mode 100644 index 0000000000..0723edb80b --- /dev/null +++ b/server/channels/jobs/post_persistent_notifications/worker.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package post_persistent_notifications + +import ( + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/jobs" +) + +const ( + JobName = "PostPersistentNotifications" +) + +type AppIface interface { + SendPersistentNotifications() error + IsPersistentNotificationsEnabled() bool +} + +func MakeWorker(jobServer *jobs.JobServer, app AppIface) model.Worker { + isEnabled := func(_ *model.Config) bool { + return app.IsPersistentNotificationsEnabled() + } + execute := func(job *model.Job) error { + defer jobServer.HandleJobPanic(job) + return app.SendPersistentNotifications() + } + worker := jobs.NewSimpleWorker(JobName, jobServer, execute, isEnabled) + return worker +} diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 45219cf4b6..c112573ec1 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -19,48 +19,49 @@ import ( type OpenTracingLayer struct { store.Store - AuditStore store.AuditStore - BotStore store.BotStore - ChannelStore store.ChannelStore - ChannelMemberHistoryStore store.ChannelMemberHistoryStore - ClusterDiscoveryStore store.ClusterDiscoveryStore - CommandStore store.CommandStore - CommandWebhookStore store.CommandWebhookStore - ComplianceStore store.ComplianceStore - DraftStore store.DraftStore - EmojiStore store.EmojiStore - FileInfoStore store.FileInfoStore - GroupStore store.GroupStore - JobStore store.JobStore - LicenseStore store.LicenseStore - LinkMetadataStore store.LinkMetadataStore - NotifyAdminStore store.NotifyAdminStore - OAuthStore store.OAuthStore - PluginStore store.PluginStore - PostStore store.PostStore - PostAcknowledgementStore store.PostAcknowledgementStore - PostPriorityStore store.PostPriorityStore - PreferenceStore store.PreferenceStore - ProductNoticesStore store.ProductNoticesStore - ReactionStore store.ReactionStore - RemoteClusterStore store.RemoteClusterStore - RetentionPolicyStore store.RetentionPolicyStore - RoleStore store.RoleStore - SchemeStore store.SchemeStore - SessionStore store.SessionStore - SharedChannelStore store.SharedChannelStore - StatusStore store.StatusStore - SystemStore store.SystemStore - TeamStore store.TeamStore - TermsOfServiceStore store.TermsOfServiceStore - ThreadStore store.ThreadStore - TokenStore store.TokenStore - TrueUpReviewStore store.TrueUpReviewStore - UploadSessionStore store.UploadSessionStore - UserStore store.UserStore - UserAccessTokenStore store.UserAccessTokenStore - UserTermsOfServiceStore store.UserTermsOfServiceStore - WebhookStore store.WebhookStore + AuditStore store.AuditStore + BotStore store.BotStore + ChannelStore store.ChannelStore + ChannelMemberHistoryStore store.ChannelMemberHistoryStore + ClusterDiscoveryStore store.ClusterDiscoveryStore + CommandStore store.CommandStore + CommandWebhookStore store.CommandWebhookStore + ComplianceStore store.ComplianceStore + DraftStore store.DraftStore + EmojiStore store.EmojiStore + FileInfoStore store.FileInfoStore + GroupStore store.GroupStore + JobStore store.JobStore + LicenseStore store.LicenseStore + LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore + OAuthStore store.OAuthStore + PluginStore store.PluginStore + PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPersistentNotificationStore store.PostPersistentNotificationStore + PostPriorityStore store.PostPriorityStore + PreferenceStore store.PreferenceStore + ProductNoticesStore store.ProductNoticesStore + ReactionStore store.ReactionStore + RemoteClusterStore store.RemoteClusterStore + RetentionPolicyStore store.RetentionPolicyStore + RoleStore store.RoleStore + SchemeStore store.SchemeStore + SessionStore store.SessionStore + SharedChannelStore store.SharedChannelStore + StatusStore store.StatusStore + SystemStore store.SystemStore + TeamStore store.TeamStore + TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore + TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore + UploadSessionStore store.UploadSessionStore + UserStore store.UserStore + UserAccessTokenStore store.UserAccessTokenStore + UserTermsOfServiceStore store.UserTermsOfServiceStore + WebhookStore store.WebhookStore } func (s *OpenTracingLayer) Audit() store.AuditStore { @@ -143,6 +144,10 @@ func (s *OpenTracingLayer) PostAcknowledgement() store.PostAcknowledgementStore return s.PostAcknowledgementStore } +func (s *OpenTracingLayer) PostPersistentNotification() store.PostPersistentNotificationStore { + return s.PostPersistentNotificationStore +} + func (s *OpenTracingLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -331,6 +336,11 @@ type OpenTracingLayerPostAcknowledgementStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPostPersistentNotificationStore struct { + store.PostPersistentNotificationStore + Root *OpenTracingLayer +} + type OpenTracingLayerPostPriorityStore struct { store.PostPriorityStore Root *OpenTracingLayer @@ -6839,6 +6849,132 @@ func (s *OpenTracingLayerPostAcknowledgementStore) Save(postID string, userID st return result, err } +func (s *OpenTracingLayerPostPersistentNotificationStore) Delete(postIds []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostPersistentNotificationStore.Delete(postIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) DeleteByChannel(channelIds []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.DeleteByChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostPersistentNotificationStore.DeleteByChannel(channelIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) DeleteByTeam(teamIds []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.DeleteByTeam") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostPersistentNotificationStore.DeleteByTeam(teamIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.DeleteExpired") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostPersistentNotificationStore.DeleteExpired(maxSentCount) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPersistentNotificationStore.Get(params) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.GetSingle") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostPersistentNotificationStore.GetSingle(postID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostPersistentNotificationStore) UpdateLastActivity(postIds []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPersistentNotificationStore.UpdateLastActivity") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostPersistentNotificationStore.UpdateLastActivity(postIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPost") @@ -12942,6 +13078,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} newStore.PostAcknowledgementStore = &OpenTracingLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPersistentNotificationStore = &OpenTracingLayerPostPersistentNotificationStore{PostPersistentNotificationStore: childStore.PostPersistentNotification(), Root: &newStore} newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 7c5b6aa526..09d2d42d4c 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -22,48 +22,49 @@ const mySQLDeadlockCode = uint16(1213) type RetryLayer struct { store.Store - AuditStore store.AuditStore - BotStore store.BotStore - ChannelStore store.ChannelStore - ChannelMemberHistoryStore store.ChannelMemberHistoryStore - ClusterDiscoveryStore store.ClusterDiscoveryStore - CommandStore store.CommandStore - CommandWebhookStore store.CommandWebhookStore - ComplianceStore store.ComplianceStore - DraftStore store.DraftStore - EmojiStore store.EmojiStore - FileInfoStore store.FileInfoStore - GroupStore store.GroupStore - JobStore store.JobStore - LicenseStore store.LicenseStore - LinkMetadataStore store.LinkMetadataStore - NotifyAdminStore store.NotifyAdminStore - OAuthStore store.OAuthStore - PluginStore store.PluginStore - PostStore store.PostStore - PostAcknowledgementStore store.PostAcknowledgementStore - PostPriorityStore store.PostPriorityStore - PreferenceStore store.PreferenceStore - ProductNoticesStore store.ProductNoticesStore - ReactionStore store.ReactionStore - RemoteClusterStore store.RemoteClusterStore - RetentionPolicyStore store.RetentionPolicyStore - RoleStore store.RoleStore - SchemeStore store.SchemeStore - SessionStore store.SessionStore - SharedChannelStore store.SharedChannelStore - StatusStore store.StatusStore - SystemStore store.SystemStore - TeamStore store.TeamStore - TermsOfServiceStore store.TermsOfServiceStore - ThreadStore store.ThreadStore - TokenStore store.TokenStore - TrueUpReviewStore store.TrueUpReviewStore - UploadSessionStore store.UploadSessionStore - UserStore store.UserStore - UserAccessTokenStore store.UserAccessTokenStore - UserTermsOfServiceStore store.UserTermsOfServiceStore - WebhookStore store.WebhookStore + AuditStore store.AuditStore + BotStore store.BotStore + ChannelStore store.ChannelStore + ChannelMemberHistoryStore store.ChannelMemberHistoryStore + ClusterDiscoveryStore store.ClusterDiscoveryStore + CommandStore store.CommandStore + CommandWebhookStore store.CommandWebhookStore + ComplianceStore store.ComplianceStore + DraftStore store.DraftStore + EmojiStore store.EmojiStore + FileInfoStore store.FileInfoStore + GroupStore store.GroupStore + JobStore store.JobStore + LicenseStore store.LicenseStore + LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore + OAuthStore store.OAuthStore + PluginStore store.PluginStore + PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPersistentNotificationStore store.PostPersistentNotificationStore + PostPriorityStore store.PostPriorityStore + PreferenceStore store.PreferenceStore + ProductNoticesStore store.ProductNoticesStore + ReactionStore store.ReactionStore + RemoteClusterStore store.RemoteClusterStore + RetentionPolicyStore store.RetentionPolicyStore + RoleStore store.RoleStore + SchemeStore store.SchemeStore + SessionStore store.SessionStore + SharedChannelStore store.SharedChannelStore + StatusStore store.StatusStore + SystemStore store.SystemStore + TeamStore store.TeamStore + TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore + TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore + UploadSessionStore store.UploadSessionStore + UserStore store.UserStore + UserAccessTokenStore store.UserAccessTokenStore + UserTermsOfServiceStore store.UserTermsOfServiceStore + WebhookStore store.WebhookStore } func (s *RetryLayer) Audit() store.AuditStore { @@ -146,6 +147,10 @@ func (s *RetryLayer) PostAcknowledgement() store.PostAcknowledgementStore { return s.PostAcknowledgementStore } +func (s *RetryLayer) PostPersistentNotification() store.PostPersistentNotificationStore { + return s.PostPersistentNotificationStore +} + func (s *RetryLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -334,6 +339,11 @@ type RetryLayerPostAcknowledgementStore struct { Root *RetryLayer } +type RetryLayerPostPersistentNotificationStore struct { + store.PostPersistentNotificationStore + Root *RetryLayer +} + type RetryLayerPostPriorityStore struct { store.PostPriorityStore Root *RetryLayer @@ -7750,6 +7760,153 @@ func (s *RetryLayerPostAcknowledgementStore) Save(postID string, userID string, } +func (s *RetryLayerPostPersistentNotificationStore) Delete(postIds []string) error { + + tries := 0 + for { + err := s.PostPersistentNotificationStore.Delete(postIds) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) DeleteByChannel(channelIds []string) error { + + tries := 0 + for { + err := s.PostPersistentNotificationStore.DeleteByChannel(channelIds) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) DeleteByTeam(teamIds []string) error { + + tries := 0 + for { + err := s.PostPersistentNotificationStore.DeleteByTeam(teamIds) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error { + + tries := 0 + for { + err := s.PostPersistentNotificationStore.DeleteExpired(maxSentCount) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) { + + tries := 0 + for { + result, err := s.PostPersistentNotificationStore.Get(params) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) { + + tries := 0 + for { + result, err := s.PostPersistentNotificationStore.GetSingle(postID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPostPersistentNotificationStore) UpdateLastActivity(postIds []string) error { + + tries := 0 + for { + err := s.PostPersistentNotificationStore.UpdateLastActivity(postIds) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { tries := 0 @@ -14750,6 +14907,7 @@ func New(childStore store.Store) *RetryLayer { newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} newStore.PostAcknowledgementStore = &RetryLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPersistentNotificationStore = &RetryLayerPostPersistentNotificationStore{PostPersistentNotificationStore: childStore.PostPersistentNotification(), Root: &newStore} newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 8c3a22f768..21b7459d8c 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -57,6 +57,7 @@ func genStore() *mocks.Store { mock.On("Draft").Return(&mocks.DraftStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) + mock.On("PostPersistentNotification").Return(&mocks.PostPersistentNotificationStore{}) mock.On("TrueUpReview").Return(&mocks.TrueUpReviewStore{}) return mock } diff --git a/server/channels/store/sqlstore/post_persistent_notification_store.go b/server/channels/store/sqlstore/post_persistent_notification_store.go new file mode 100644 index 0000000000..e822598643 --- /dev/null +++ b/server/channels/store/sqlstore/post_persistent_notification_store.go @@ -0,0 +1,193 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlPostPersistentNotificationStore struct { + *SqlStore +} + +func newSqlPostPersistentNotificationStore(sqlStore *SqlStore) store.PostPersistentNotificationStore { + return &SqlPostPersistentNotificationStore{ + SqlStore: sqlStore, + } +} + +func (s *SqlPostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) { + builder := s.getQueryBuilder(). + Select("PostId, CreateAt, LastSentAt, DeleteAt, SentCount"). + From("PersistentNotifications"). + Where(sq.And{ + sq.Eq{"DeleteAt": 0}, + sq.Eq{"PostId": postID}, + }) + + post := &model.PostPersistentNotifications{} + err := s.GetReplicaX().GetBuilder(post, builder) + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Persistent Notification Post", postID) + } + return nil, errors.Wrapf(err, "failed to get the persistent notification post=%s", postID) + } + return post, nil +} + +// Get returns only valid posts. +func (s *SqlPostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) { + if params.PerPage == 0 { + params.PerPage = 1000 + } + + builder := s.getQueryBuilder(). + Select("PostId, CreateAt, LastSentAt, DeleteAt, SentCount"). + From("PersistentNotifications"). + Where(sq.And{ + sq.Eq{"DeleteAt": 0}, + sq.LtOrEq{"CreateAt": params.MaxTime}, + sq.LtOrEq{"LastSentAt": params.MaxTime}, + sq.Lt{"SentCount": params.MaxSentCount}, + }). + Limit(uint64(params.PerPage)) + + var posts []*model.PostPersistentNotifications + // Replica may not have the latest changes(done by UpdateLastActivity func) + // by the time this Get func is called again in the loop. + err := s.GetMasterX().SelectBuilder(&posts, builder) + if err != nil { + return nil, errors.Wrap(err, "failed to get notifications") + } + + return posts, nil +} + +func (s *SqlPostPersistentNotificationStore) UpdateLastActivity(postIds []string) error { + builder := s.getQueryBuilder(). + Update("PersistentNotifications"). + Set("LastSentAt", model.GetMillis()). + Set("SentCount", sq.Expr("SentCount+1")). + Where(sq.Eq{"PostId": postIds}) + + _, err := s.GetMasterX().ExecBuilder(builder) + if err != nil { + return errors.Wrapf(err, "failed to update last activity for posts %s", postIds) + } + + return nil +} + +func (s *SqlPostPersistentNotificationStore) Delete(postIds []string) error { + count := len(postIds) + if count == 0 { + return nil + } + + builder := s.getQueryBuilder(). + Update("PersistentNotifications"). + Set("DeleteAt", model.GetMillis()). + Where(sq.Eq{"PostId": postIds}) + + _, err := s.GetMasterX().ExecBuilder(builder) + if err != nil { + return errors.Wrapf(err, "failed to delete notifications for posts %s", postIds) + } + + return nil +} + +func (s *SqlPostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error { + builder := s.getQueryBuilder(). + Update("PersistentNotifications"). + Set("DeleteAt", model.GetMillis()). + Where(sq.And{ + sq.Eq{"DeleteAt": 0}, + sq.GtOrEq{"SentCount": maxSentCount}, + }) + + _, err := s.GetMasterX().ExecBuilder(builder) + if err != nil { + return errors.Wrap(err, "failed to delete notifications") + } + + return nil +} + +func (s *SqlPostPersistentNotificationStore) DeleteByChannel(channelIds []string) error { + count := len(channelIds) + if count == 0 { + return nil + } + + deleteAt := model.GetMillis() + var builder sq.UpdateBuilder + builderType := s.getQueryBuilder() + if s.DriverName() == model.DatabaseDriverMysql { + builder = builderType. + Update("PersistentNotifications, Posts"). + Set("PersistentNotifications.DeleteAt", deleteAt) + } + + if s.DriverName() == model.DatabaseDriverPostgres { + builder = builderType. + Update("PersistentNotifications"). + Set("DeleteAt", deleteAt). + From("Posts") + } + + builder = builder.Where(sq.And{ + sq.Expr("Posts.Id = PersistentNotifications.PostId"), + sq.Eq{"Posts.ChannelId": channelIds}, + }) + + _, err := s.GetMasterX().ExecBuilder(builder) + if err != nil { + return errors.Wrapf(err, "failed to delete notifications for channels %s", channelIds) + } + + return nil +} + +func (s *SqlPostPersistentNotificationStore) DeleteByTeam(teamIds []string) error { + count := len(teamIds) + if count == 0 { + return nil + } + + deleteAt := model.GetMillis() + var builder sq.UpdateBuilder + builderType := s.getQueryBuilder() + if s.DriverName() == model.DatabaseDriverMysql { + builder = builderType. + Update("PersistentNotifications, Posts, Channels"). + Set("PersistentNotifications.DeleteAt", deleteAt) + } + + if s.DriverName() == model.DatabaseDriverPostgres { + builder = builderType. + Update("PersistentNotifications"). + Set("DeleteAt", deleteAt). + From("Posts, Channels") + } + + builder = builder.Where(sq.And{ + sq.Expr("Posts.Id = PersistentNotifications.PostId"), + sq.Expr("Posts.ChannelId = Channels.Id"), + sq.Eq{"Channels.TeamId": teamIds}, + }) + + _, err := s.GetMasterX().ExecBuilder(builder) + if err != nil { + return errors.Wrapf(err, "failed to delete notifications for teams %s", teamIds) + } + + return nil +} diff --git a/server/channels/store/sqlstore/post_persistent_notification_store_test.go b/server/channels/store/sqlstore/post_persistent_notification_store_test.go new file mode 100644 index 0000000000..9c655560dc --- /dev/null +++ b/server/channels/store/sqlstore/post_persistent_notification_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/server/v8/channels/store/storetest" +) + +func TestPostPersistentNotificationStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostPersistentNotificationStore) +} diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index be6ced3e1e..341c0ddbda 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -223,6 +223,10 @@ func (s *SqlPostStore) SaveMultiple(posts []*model.Post) ([]*model.Post, int, er return nil, -1, errors.Wrap(err, "failed to save PostPriority") } + if err = s.savePostsPersistentNotifications(transaction, posts); err != nil { + return nil, -1, errors.Wrap(err, "failed to save posts persistent notifications") + } + if err = transaction.Commit(); err != nil { // don't need to rollback here since the transaction is already closed return posts, -1, errors.Wrap(err, "commit_transaction") @@ -3004,6 +3008,20 @@ func (s *SqlPostStore) savePostsPriority(transaction *sqlxTxWrapper, posts []*mo return nil } +func (s *SqlPostStore) savePostsPersistentNotifications(transaction *sqlxTxWrapper, posts []*model.Post) error { + for _, post := range posts { + if priority := post.GetPriority(); priority != nil && priority.PersistentNotifications != nil && *priority.PersistentNotifications { + if _, err := transaction.NamedExec(`INSERT INTO PersistentNotifications (PostId, CreateAt, LastSentAt, DeleteAt, SentCount) VALUES (:PostId, :CreateAt, :LastSentAt, :DeleteAt, :SentCount)`, &model.PostPersistentNotifications{ + PostId: post.Id, + CreateAt: post.CreateAt, + }); err != nil { + return err + } + } + } + return nil +} + func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts []*model.Post) error { postsByRoot := map[string][]*model.Post{} var rootIds []string diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index affb003766..a8a97b0d82 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -71,48 +71,49 @@ const ( var tablesToCheckForCollation = []string{"incomingwebhooks", "preferences", "users", "uploadsessions", "channels", "publicchannels"} type SqlStoreStores struct { - team store.TeamStore - channel store.ChannelStore - post store.PostStore - retentionPolicy store.RetentionPolicyStore - thread store.ThreadStore - user store.UserStore - bot store.BotStore - audit store.AuditStore - cluster store.ClusterDiscoveryStore - remoteCluster store.RemoteClusterStore - compliance store.ComplianceStore - session store.SessionStore - oauth store.OAuthStore - system store.SystemStore - webhook store.WebhookStore - command store.CommandStore - commandWebhook store.CommandWebhookStore - preference store.PreferenceStore - license store.LicenseStore - token store.TokenStore - emoji store.EmojiStore - status store.StatusStore - fileInfo store.FileInfoStore - uploadSession store.UploadSessionStore - reaction store.ReactionStore - job store.JobStore - userAccessToken store.UserAccessTokenStore - plugin store.PluginStore - channelMemberHistory store.ChannelMemberHistoryStore - role store.RoleStore - scheme store.SchemeStore - TermsOfService store.TermsOfServiceStore - productNotices store.ProductNoticesStore - group store.GroupStore - UserTermsOfService store.UserTermsOfServiceStore - linkMetadata store.LinkMetadataStore - sharedchannel store.SharedChannelStore - draft store.DraftStore - notifyAdmin store.NotifyAdminStore - postPriority store.PostPriorityStore - postAcknowledgement store.PostAcknowledgementStore - trueUpReview store.TrueUpReviewStore + team store.TeamStore + channel store.ChannelStore + post store.PostStore + retentionPolicy store.RetentionPolicyStore + thread store.ThreadStore + user store.UserStore + bot store.BotStore + audit store.AuditStore + cluster store.ClusterDiscoveryStore + remoteCluster store.RemoteClusterStore + compliance store.ComplianceStore + session store.SessionStore + oauth store.OAuthStore + system store.SystemStore + webhook store.WebhookStore + command store.CommandStore + commandWebhook store.CommandWebhookStore + preference store.PreferenceStore + license store.LicenseStore + token store.TokenStore + emoji store.EmojiStore + status store.StatusStore + fileInfo store.FileInfoStore + uploadSession store.UploadSessionStore + reaction store.ReactionStore + job store.JobStore + userAccessToken store.UserAccessTokenStore + plugin store.PluginStore + channelMemberHistory store.ChannelMemberHistoryStore + role store.RoleStore + scheme store.SchemeStore + TermsOfService store.TermsOfServiceStore + productNotices store.ProductNoticesStore + group store.GroupStore + UserTermsOfService store.UserTermsOfServiceStore + linkMetadata store.LinkMetadataStore + sharedchannel store.SharedChannelStore + draft store.DraftStore + notifyAdmin store.NotifyAdminStore + postPriority store.PostPriorityStore + postAcknowledgement store.PostAcknowledgementStore + postPersistentNotification store.PostPersistentNotificationStore + trueUpReview store.TrueUpReviewStore } type SqlStore struct { @@ -232,6 +233,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) + store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store) store.stores.trueUpReview = newSqlTrueUpReviewStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1076,6 +1078,10 @@ func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { return ss.stores.postAcknowledgement } +func (ss *SqlStore) PostPersistentNotification() store.PostPersistentNotificationStore { + return ss.stores.postPersistentNotification +} + func (ss *SqlStore) TrueUpReview() store.TrueUpReviewStore { return ss.stores.trueUpReview } diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 2cb52d064d..cfed05ce2b 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -84,6 +84,7 @@ type Store interface { NotifyAdmin() NotifyAdminStore PostPriority() PostPriorityStore PostAcknowledgement() PostAcknowledgementStore + PostPersistentNotification() PostPersistentNotificationStore TrueUpReview() TrueUpReviewStore } @@ -999,6 +1000,16 @@ type PostAcknowledgementStore interface { Delete(acknowledgement *model.PostAcknowledgement) error } +type PostPersistentNotificationStore interface { + Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) + GetSingle(postID string) (*model.PostPersistentNotifications, error) + UpdateLastActivity(postIds []string) error + Delete(postIds []string) error + DeleteExpired(maxSentCount int16) error + DeleteByChannel(channelIds []string) error + DeleteByTeam(teamIds []string) error +} + type TrueUpReviewStore interface { GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) diff --git a/server/channels/store/storetest/mocks/PostPersistentNotificationStore.go b/server/channels/store/storetest/mocks/PostPersistentNotificationStore.go new file mode 100644 index 0000000000..85ba455071 --- /dev/null +++ b/server/channels/store/storetest/mocks/PostPersistentNotificationStore.go @@ -0,0 +1,152 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// PostPersistentNotificationStore is an autogenerated mock type for the PostPersistentNotificationStore type +type PostPersistentNotificationStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: postIds +func (_m *PostPersistentNotificationStore) Delete(postIds []string) error { + ret := _m.Called(postIds) + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(postIds) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteByChannel provides a mock function with given fields: channelIds +func (_m *PostPersistentNotificationStore) DeleteByChannel(channelIds []string) error { + ret := _m.Called(channelIds) + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(channelIds) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteByTeam provides a mock function with given fields: teamIds +func (_m *PostPersistentNotificationStore) DeleteByTeam(teamIds []string) error { + ret := _m.Called(teamIds) + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(teamIds) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteExpired provides a mock function with given fields: maxSentCount +func (_m *PostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error { + ret := _m.Called(maxSentCount) + + var r0 error + if rf, ok := ret.Get(0).(func(int16) error); ok { + r0 = rf(maxSentCount) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: params +func (_m *PostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) { + ret := _m.Called(params) + + var r0 []*model.PostPersistentNotifications + var r1 error + if rf, ok := ret.Get(0).(func(model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error)); ok { + return rf(params) + } + if rf, ok := ret.Get(0).(func(model.GetPersistentNotificationsPostsParams) []*model.PostPersistentNotifications); ok { + r0 = rf(params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostPersistentNotifications) + } + } + + if rf, ok := ret.Get(1).(func(model.GetPersistentNotificationsPostsParams) error); ok { + r1 = rf(params) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSingle provides a mock function with given fields: postID +func (_m *PostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) { + ret := _m.Called(postID) + + var r0 *model.PostPersistentNotifications + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.PostPersistentNotifications, error)); ok { + return rf(postID) + } + if rf, ok := ret.Get(0).(func(string) *model.PostPersistentNotifications); ok { + r0 = rf(postID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostPersistentNotifications) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UpdateLastActivity provides a mock function with given fields: postIds +func (_m *PostPersistentNotificationStore) UpdateLastActivity(postIds []string) error { + ret := _m.Called(postIds) + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(postIds) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewPostPersistentNotificationStore interface { + mock.TestingT + Cleanup(func()) +} + +// NewPostPersistentNotificationStore creates a new instance of PostPersistentNotificationStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewPostPersistentNotificationStore(t mockConstructorTestingTNewPostPersistentNotificationStore) *PostPersistentNotificationStore { + mock := &PostPersistentNotificationStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index 4e5cc834db..3ed4cc4fd7 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -500,6 +500,22 @@ func (_m *Store) PostAcknowledgement() store.PostAcknowledgementStore { return r0 } +// PostPersistentNotification provides a mock function with given fields: +func (_m *Store) PostPersistentNotification() store.PostPersistentNotificationStore { + ret := _m.Called() + + var r0 store.PostPersistentNotificationStore + if rf, ok := ret.Get(0).(func() store.PostPersistentNotificationStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostPersistentNotificationStore) + } + } + + return r0 +} + // PostPriority provides a mock function with given fields: func (_m *Store) PostPriority() store.PostPriorityStore { ret := _m.Called() diff --git a/server/channels/store/storetest/post_persistent_notification_store.go b/server/channels/store/storetest/post_persistent_notification_store.go new file mode 100644 index 0000000000..7542954ea3 --- /dev/null +++ b/server/channels/store/storetest/post_persistent_notification_store.go @@ -0,0 +1,452 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost-server/server/public/model" + "github.com/mattermost/mattermost-server/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostPersistentNotificationStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("Get", func(t *testing.T) { testPostPersistentNotificationStoreGet(t, ss) }) + t.Run("Delete", func(t *testing.T) { testPostPersistentNotificationStoreDelete(t, ss) }) + t.Run("UpdateLastSentAt", func(t *testing.T) { testPostPersistentNotificationStoreUpdateLastSentAt(t, ss) }) +} + +func testPostPersistentNotificationStoreGet(t *testing.T, ss store.Store) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.CreateAt = 10 + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p2 := model.Post{} + p2.ChannelId = p1.ChannelId + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.CreateAt = 20 + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(true), + }, + } + + // Invalid - Has no Priority + p3 := model.Post{} + p3.ChannelId = p1.ChannelId + p3.UserId = model.NewId() + p3.Message = NewTestId() + p3.CreateAt = 30 + + // Invalid - Notification is false + p4 := model.Post{} + p4.ChannelId = p1.ChannelId + p4.UserId = model.NewId() + p4.Message = NewTestId() + p4.CreateAt = 40 + p4.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(false), + }, + } + + p5 := model.Post{} + p5.ChannelId = p1.ChannelId + p5.UserId = model.NewId() + p5.Message = NewTestId() + p5.CreateAt = 50 + p5.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3, &p4, &p5}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + defer ss.Post().PermanentDeleteByChannel(p1.ChannelId) + defer ss.PostPersistentNotification().Delete([]string{p1.Id, p2.Id, p3.Id, p4.Id, p5.Id}) + + t.Run("Get Single", func(t *testing.T) { + pn, err := ss.PostPersistentNotification().GetSingle(p1.Id) + require.NoError(t, err) + assert.Equal(t, p1.Id, pn.PostId) + + pn, err = ss.PostPersistentNotification().GetSingle(p2.Id) + require.NoError(t, err) + assert.Equal(t, p2.Id, pn.PostId) + + pn, err = ss.PostPersistentNotification().GetSingle(p5.Id) + require.NoError(t, err) + assert.Equal(t, p5.Id, pn.PostId) + + pn, err = ss.PostPersistentNotification().GetSingle(p3.Id) + require.Error(t, err) + require.Zero(t, pn) + + pn, err = ss.PostPersistentNotification().GetSingle(p4.Id) + require.Error(t, err) + require.Zero(t, pn) + }) + + t.Run("Get all before MaxTime", func(t *testing.T) { + validIDs := []string{p1.Id, p2.Id, p5.Id} + getIDs := func(posts []*model.PostPersistentNotifications) (ids []string) { + for _, p := range posts { + ids = append(ids, p.PostId) + } + return + } + + // p5 is filtered by maxTime + pn, err := ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: 45, + MaxSentCount: 60, + PerPage: 20, + }) + require.NoError(t, err) + require.Len(t, pn, 2) + assert.Contains(t, getIDs(pn), p1.Id) + assert.Contains(t, getIDs(pn), p2.Id) + + // nothing is filtered out + pn, err = ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: 100, + MaxSentCount: 60, + PerPage: 20, + }) + require.NoError(t, err) + require.Len(t, pn, 3) + assert.ElementsMatch(t, validIDs, getIDs(pn)) + }) +} + +func testPostPersistentNotificationStoreUpdateLastSentAt(t *testing.T, ss store.Store) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.CreateAt = 10 + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + defer ss.Post().PermanentDeleteByChannel(p1.ChannelId) + defer ss.PostPersistentNotification().Delete([]string{p1.Id}) + + // Update from 0 value + now := model.GetTimeForMillis(model.GetMillis()) + delta := 2 * time.Second + err = ss.PostPersistentNotification().UpdateLastActivity([]string{p1.Id}) + require.NoError(t, err) + + pn, err := ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: model.GetMillisForTime(now.Add(delta)), + MaxSentCount: 60, + }) + require.NoError(t, err) + require.Len(t, pn, 1) + assert.WithinDuration(t, now, model.GetTimeForMillis(pn[0].LastSentAt), delta) + + time.Sleep(time.Second) + + // Update from non-zero value + now = model.GetTimeForMillis(model.GetMillis()) + delta = 2 * time.Second + err = ss.PostPersistentNotification().UpdateLastActivity([]string{p1.Id}) + require.NoError(t, err) + + pn, err = ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: model.GetMillisForTime(now.Add(delta)), + MaxSentCount: 60, + }) + require.NoError(t, err) + require.Len(t, pn, 1) + assert.WithinDuration(t, now, model.GetTimeForMillis(pn[0].LastSentAt), delta) +} + +func testPostPersistentNotificationStoreDelete(t *testing.T, ss store.Store) { + t.Run("Delete", func(t *testing.T) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.CreateAt = 10 + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p2 := model.Post{} + p2.ChannelId = p1.ChannelId + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.CreateAt = 20 + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(true), + }, + } + + p3 := model.Post{} + p3.ChannelId = p1.ChannelId + p3.UserId = model.NewId() + p3.Message = NewTestId() + p3.CreateAt = 30 + p3.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + defer ss.Post().PermanentDeleteByChannel(p1.ChannelId) + defer ss.PostPersistentNotification().Delete([]string{p1.Id, p2.Id, p3.Id}) + + err = ss.PostPersistentNotification().Delete([]string{p1.Id, p3.Id}) + require.NoError(t, err) + + pn, err := ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: 100, + MaxSentCount: 6, + PerPage: 20, + }) + require.NoError(t, err) + require.Len(t, pn, 1) + assert.Equal(t, p2.Id, pn[0].PostId) + }) + + t.Run("Delete By Channel", func(t *testing.T) { + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.CreateAt = 10 + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p2 := model.Post{} + p2.ChannelId = p1.ChannelId + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.CreateAt = 20 + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(true), + }, + } + + p3 := model.Post{} + p3.ChannelId = p1.ChannelId + p3.UserId = model.NewId() + p3.Message = NewTestId() + p3.CreateAt = 30 + p3.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p4 := model.Post{} + p4.ChannelId = model.NewId() + p4.UserId = model.NewId() + p4.Message = NewTestId() + p4.CreateAt = 40 + p4.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p5 := model.Post{} + p5.ChannelId = p4.ChannelId + p5.UserId = model.NewId() + p5.Message = NewTestId() + p5.CreateAt = 50 + p5.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3, &p4, &p5}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + defer ss.Post().PermanentDeleteByChannel(p1.ChannelId) + defer ss.Post().PermanentDeleteByChannel(p4.ChannelId) + defer ss.PostPersistentNotification().Delete([]string{p1.Id, p2.Id, p3.Id, p4.Id, p5.Id}) + + err = ss.PostPersistentNotification().DeleteByChannel([]string{p1.ChannelId}) + require.NoError(t, err) + + pn, err := ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: 100, + MaxSentCount: 6, + PerPage: 20, + }) + require.NoError(t, err) + require.Len(t, pn, 2) + assert.ElementsMatch(t, []string{p4.Id, p5.Id}, []string{pn[0].PostId, pn[1].PostId}) + }) + + t.Run("Delete By Team", func(t *testing.T) { + t1 := &model.Team{DisplayName: "t1", Name: NewTestId(), Email: MakeEmail(), Type: model.TeamOpen} + _, err := ss.Team().Save(t1) + require.NoError(t, err) + t2 := &model.Team{DisplayName: "t2", Name: NewTestId(), Email: MakeEmail(), Type: model.TeamOpen} + _, err = ss.Team().Save(t2) + require.NoError(t, err) + + c1 := &model.Channel{TeamId: t1.Id, Name: model.NewId(), DisplayName: "c1", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(c1, -1) + require.NoError(t, err) + c2 := &model.Channel{TeamId: t1.Id, Name: model.NewId(), DisplayName: "c2", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(c2, -1) + require.NoError(t, err) + c3 := &model.Channel{TeamId: t2.Id, Name: model.NewId(), DisplayName: "c1", Type: model.ChannelTypeOpen} + _, err = ss.Channel().Save(c3, -1) + require.NoError(t, err) + + p1 := model.Post{} + p1.ChannelId = c1.Id + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.CreateAt = 10 + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p2 := model.Post{} + p2.ChannelId = p1.ChannelId + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.CreateAt = 20 + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(true), + }, + } + + p3 := model.Post{} + p3.ChannelId = c2.Id + p3.UserId = model.NewId() + p3.Message = NewTestId() + p3.CreateAt = 30 + p3.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p4 := model.Post{} + p4.ChannelId = c3.Id + p4.UserId = model.NewId() + p4.Message = NewTestId() + p4.CreateAt = 40 + p4.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + p5 := model.Post{} + p5.ChannelId = p4.ChannelId + p5.UserId = model.NewId() + p5.Message = NewTestId() + p5.CreateAt = 50 + p5.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(false), + PersistentNotifications: model.NewBool(true), + }, + } + + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2, &p3, &p4, &p5}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + defer ss.Post().PermanentDeleteByChannel(c1.Id) + defer ss.Post().PermanentDeleteByChannel(c2.Id) + defer ss.Post().PermanentDeleteByChannel(c3.Id) + defer ss.Channel().PermanentDeleteByTeam(t1.Id) + defer ss.Channel().PermanentDeleteByTeam(t2.Id) + defer ss.Team().PermanentDelete(t1.Id) + defer ss.Team().PermanentDelete(t2.Id) + defer ss.PostPersistentNotification().Delete([]string{p1.Id, p2.Id, p3.Id, p4.Id, p5.Id}) + + err = ss.PostPersistentNotification().DeleteByTeam([]string{t1.Id}) + require.NoError(t, err) + + pn, err := ss.PostPersistentNotification().Get(model.GetPersistentNotificationsPostsParams{ + MaxTime: 100, + MaxSentCount: 6, + PerPage: 20, + }) + require.NoError(t, err) + require.Len(t, pn, 2) + assert.ElementsMatch(t, []string{p4.Id, p5.Id}, []string{pn[0].PostId, pn[1].PostId}) + }) +} diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 0bea010ba6..eb7101e8cf 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -17,49 +17,50 @@ import ( // Store can be used to provide mock stores for testing. type Store struct { - TeamStore mocks.TeamStore - ChannelStore mocks.ChannelStore - PostStore mocks.PostStore - UserStore mocks.UserStore - RetentionPolicyStore mocks.RetentionPolicyStore - BotStore mocks.BotStore - AuditStore mocks.AuditStore - ClusterDiscoveryStore mocks.ClusterDiscoveryStore - RemoteClusterStore mocks.RemoteClusterStore - ComplianceStore mocks.ComplianceStore - SessionStore mocks.SessionStore - OAuthStore mocks.OAuthStore - SystemStore mocks.SystemStore - WebhookStore mocks.WebhookStore - CommandStore mocks.CommandStore - CommandWebhookStore mocks.CommandWebhookStore - PreferenceStore mocks.PreferenceStore - LicenseStore mocks.LicenseStore - TokenStore mocks.TokenStore - EmojiStore mocks.EmojiStore - ThreadStore mocks.ThreadStore - StatusStore mocks.StatusStore - FileInfoStore mocks.FileInfoStore - UploadSessionStore mocks.UploadSessionStore - ReactionStore mocks.ReactionStore - JobStore mocks.JobStore - UserAccessTokenStore mocks.UserAccessTokenStore - PluginStore mocks.PluginStore - ChannelMemberHistoryStore mocks.ChannelMemberHistoryStore - RoleStore mocks.RoleStore - SchemeStore mocks.SchemeStore - TermsOfServiceStore mocks.TermsOfServiceStore - GroupStore mocks.GroupStore - UserTermsOfServiceStore mocks.UserTermsOfServiceStore - LinkMetadataStore mocks.LinkMetadataStore - SharedChannelStore mocks.SharedChannelStore - ProductNoticesStore mocks.ProductNoticesStore - DraftStore mocks.DraftStore - context context.Context - NotifyAdminStore mocks.NotifyAdminStore - PostPriorityStore mocks.PostPriorityStore - PostAcknowledgementStore mocks.PostAcknowledgementStore - TrueUpReviewStore mocks.TrueUpReviewStore + TeamStore mocks.TeamStore + ChannelStore mocks.ChannelStore + PostStore mocks.PostStore + UserStore mocks.UserStore + RetentionPolicyStore mocks.RetentionPolicyStore + BotStore mocks.BotStore + AuditStore mocks.AuditStore + ClusterDiscoveryStore mocks.ClusterDiscoveryStore + RemoteClusterStore mocks.RemoteClusterStore + ComplianceStore mocks.ComplianceStore + SessionStore mocks.SessionStore + OAuthStore mocks.OAuthStore + SystemStore mocks.SystemStore + WebhookStore mocks.WebhookStore + CommandStore mocks.CommandStore + CommandWebhookStore mocks.CommandWebhookStore + PreferenceStore mocks.PreferenceStore + LicenseStore mocks.LicenseStore + TokenStore mocks.TokenStore + EmojiStore mocks.EmojiStore + ThreadStore mocks.ThreadStore + StatusStore mocks.StatusStore + FileInfoStore mocks.FileInfoStore + UploadSessionStore mocks.UploadSessionStore + ReactionStore mocks.ReactionStore + JobStore mocks.JobStore + UserAccessTokenStore mocks.UserAccessTokenStore + PluginStore mocks.PluginStore + ChannelMemberHistoryStore mocks.ChannelMemberHistoryStore + RoleStore mocks.RoleStore + SchemeStore mocks.SchemeStore + TermsOfServiceStore mocks.TermsOfServiceStore + GroupStore mocks.GroupStore + UserTermsOfServiceStore mocks.UserTermsOfServiceStore + LinkMetadataStore mocks.LinkMetadataStore + SharedChannelStore mocks.SharedChannelStore + ProductNoticesStore mocks.ProductNoticesStore + DraftStore mocks.DraftStore + context context.Context + NotifyAdminStore mocks.NotifyAdminStore + PostPriorityStore mocks.PostPriorityStore + PostAcknowledgementStore mocks.PostAcknowledgementStore + PostPersistentNotificationStore mocks.PostPersistentNotificationStore + TrueUpReviewStore mocks.TrueUpReviewStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -110,6 +111,9 @@ func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorit func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { return &s.PostAcknowledgementStore } +func (s *Store) PostPersistentNotification() store.PostPersistentNotificationStore { + return &s.PostPersistentNotificationStore +} func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } func (s *Store) Close() { /* do nothing */ } func (s *Store) LockToMaster() { /* do nothing */ } @@ -171,5 +175,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.NotifyAdminStore, &s.PostPriorityStore, &s.PostAcknowledgementStore, + &s.PostPersistentNotificationStore, ) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index c213415986..c301c651dc 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -17,49 +17,50 @@ import ( type TimerLayer struct { store.Store - Metrics einterfaces.MetricsInterface - AuditStore store.AuditStore - BotStore store.BotStore - ChannelStore store.ChannelStore - ChannelMemberHistoryStore store.ChannelMemberHistoryStore - ClusterDiscoveryStore store.ClusterDiscoveryStore - CommandStore store.CommandStore - CommandWebhookStore store.CommandWebhookStore - ComplianceStore store.ComplianceStore - DraftStore store.DraftStore - EmojiStore store.EmojiStore - FileInfoStore store.FileInfoStore - GroupStore store.GroupStore - JobStore store.JobStore - LicenseStore store.LicenseStore - LinkMetadataStore store.LinkMetadataStore - NotifyAdminStore store.NotifyAdminStore - OAuthStore store.OAuthStore - PluginStore store.PluginStore - PostStore store.PostStore - PostAcknowledgementStore store.PostAcknowledgementStore - PostPriorityStore store.PostPriorityStore - PreferenceStore store.PreferenceStore - ProductNoticesStore store.ProductNoticesStore - ReactionStore store.ReactionStore - RemoteClusterStore store.RemoteClusterStore - RetentionPolicyStore store.RetentionPolicyStore - RoleStore store.RoleStore - SchemeStore store.SchemeStore - SessionStore store.SessionStore - SharedChannelStore store.SharedChannelStore - StatusStore store.StatusStore - SystemStore store.SystemStore - TeamStore store.TeamStore - TermsOfServiceStore store.TermsOfServiceStore - ThreadStore store.ThreadStore - TokenStore store.TokenStore - TrueUpReviewStore store.TrueUpReviewStore - UploadSessionStore store.UploadSessionStore - UserStore store.UserStore - UserAccessTokenStore store.UserAccessTokenStore - UserTermsOfServiceStore store.UserTermsOfServiceStore - WebhookStore store.WebhookStore + Metrics einterfaces.MetricsInterface + AuditStore store.AuditStore + BotStore store.BotStore + ChannelStore store.ChannelStore + ChannelMemberHistoryStore store.ChannelMemberHistoryStore + ClusterDiscoveryStore store.ClusterDiscoveryStore + CommandStore store.CommandStore + CommandWebhookStore store.CommandWebhookStore + ComplianceStore store.ComplianceStore + DraftStore store.DraftStore + EmojiStore store.EmojiStore + FileInfoStore store.FileInfoStore + GroupStore store.GroupStore + JobStore store.JobStore + LicenseStore store.LicenseStore + LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore + OAuthStore store.OAuthStore + PluginStore store.PluginStore + PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore + PostPersistentNotificationStore store.PostPersistentNotificationStore + PostPriorityStore store.PostPriorityStore + PreferenceStore store.PreferenceStore + ProductNoticesStore store.ProductNoticesStore + ReactionStore store.ReactionStore + RemoteClusterStore store.RemoteClusterStore + RetentionPolicyStore store.RetentionPolicyStore + RoleStore store.RoleStore + SchemeStore store.SchemeStore + SessionStore store.SessionStore + SharedChannelStore store.SharedChannelStore + StatusStore store.StatusStore + SystemStore store.SystemStore + TeamStore store.TeamStore + TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore + TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore + UploadSessionStore store.UploadSessionStore + UserStore store.UserStore + UserAccessTokenStore store.UserAccessTokenStore + UserTermsOfServiceStore store.UserTermsOfServiceStore + WebhookStore store.WebhookStore } func (s *TimerLayer) Audit() store.AuditStore { @@ -142,6 +143,10 @@ func (s *TimerLayer) PostAcknowledgement() store.PostAcknowledgementStore { return s.PostAcknowledgementStore } +func (s *TimerLayer) PostPersistentNotification() store.PostPersistentNotificationStore { + return s.PostPersistentNotificationStore +} + func (s *TimerLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -330,6 +335,11 @@ type TimerLayerPostAcknowledgementStore struct { Root *TimerLayer } +type TimerLayerPostPersistentNotificationStore struct { + store.PostPersistentNotificationStore + Root *TimerLayer +} + type TimerLayerPostPriorityStore struct { store.PostPriorityStore Root *TimerLayer @@ -6186,6 +6196,118 @@ func (s *TimerLayerPostAcknowledgementStore) Save(postID string, userID string, return result, err } +func (s *TimerLayerPostPersistentNotificationStore) Delete(postIds []string) error { + start := time.Now() + + err := s.PostPersistentNotificationStore.Delete(postIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPostPersistentNotificationStore) DeleteByChannel(channelIds []string) error { + start := time.Now() + + err := s.PostPersistentNotificationStore.DeleteByChannel(channelIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.DeleteByChannel", success, elapsed) + } + return err +} + +func (s *TimerLayerPostPersistentNotificationStore) DeleteByTeam(teamIds []string) error { + start := time.Now() + + err := s.PostPersistentNotificationStore.DeleteByTeam(teamIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.DeleteByTeam", success, elapsed) + } + return err +} + +func (s *TimerLayerPostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error { + start := time.Now() + + err := s.PostPersistentNotificationStore.DeleteExpired(maxSentCount) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.DeleteExpired", success, elapsed) + } + return err +} + +func (s *TimerLayerPostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) { + start := time.Now() + + result, err := s.PostPersistentNotificationStore.Get(params) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) { + start := time.Now() + + result, err := s.PostPersistentNotificationStore.GetSingle(postID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.GetSingle", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostPersistentNotificationStore) UpdateLastActivity(postIds []string) error { + start := time.Now() + + err := s.PostPersistentNotificationStore.UpdateLastActivity(postIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostPersistentNotificationStore.UpdateLastActivity", success, elapsed) + } + return err +} + func (s *TimerLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { start := time.Now() @@ -11663,6 +11785,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} newStore.PostAcknowledgementStore = &TimerLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} + newStore.PostPersistentNotificationStore = &TimerLayerPostPersistentNotificationStore{PostPersistentNotificationStore: childStore.PostPersistentNotification(), Root: &newStore} newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/server/channels/utils/utils.go b/server/channels/utils/utils.go index ac210f9d21..88b4ca1006 100644 --- a/server/channels/utils/utils.go +++ b/server/channels/utils/utils.go @@ -229,13 +229,13 @@ func RoundOffToZeroes(n float64) int64 { return firstDigit * tens } -func min(a, b int) int { +func MinInt(a, b int) int { if a < b { return a } return b } -func max(a, b int) int { +func MaxInt(a, b int) int { if a > b { return a } @@ -246,7 +246,7 @@ func max(a, b int) int { // It implicitly sets the lowest minResolution to 0. // e.g. 0 reports 1s, 1 reports 10s, 2 reports 100s, 3 reports 1000s func RoundOffToZeroesResolution(n float64, minResolution int) int64 { - resolution := max(0, minResolution) + resolution := MaxInt(0, minResolution) if n >= -9 && n <= 9 { if resolution == 0 { return int64(n) @@ -255,7 +255,7 @@ func RoundOffToZeroesResolution(n float64, minResolution int) int64 { } zeroes := int(math.Log10(math.Abs(n))) - resolution = min(zeroes, resolution) + resolution = MinInt(zeroes, resolution) tens := int64(math.Pow10(resolution)) significantDigits := int64(n) / tens return significantDigits * tens diff --git a/server/config/client.go b/server/config/client.go index f341e61b9a..8670c22e1f 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -136,6 +136,11 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["EnableCustomGroups"] = "false" props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled) props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority) + props["AllowPersistentNotifications"] = strconv.FormatBool(*c.ServiceSettings.AllowPersistentNotifications) + props["AllowPersistentNotificationsForGuests"] = strconv.FormatBool(*c.ServiceSettings.AllowPersistentNotificationsForGuests) + props["PersistentNotificationMaxCount"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxCount), 10) + props["PersistentNotificationIntervalMinutes"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationIntervalMinutes), 10) + props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10) props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete) diff --git a/server/i18n/en.json b/server/i18n/en.json index 89ccfcb532..6f7b2c3acb 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2375,6 +2375,30 @@ "id": "api.post.patch_post.can_not_update_post_in_deleted.error", "translation": "Can not update a post in a deleted channel." }, + { + "id": "api.post.post_priority.max_recipients_persistent_notification_post.request_error", + "translation": "Persistent notification post allows maximum of {{.MaxRecipients}} recipients." + }, + { + "id": "api.post.post_priority.min_recipients_persistent_notification_post.request_error", + "translation": "Persistent notification post must have minimum 1 recipient." + }, + { + "id": "api.post.post_priority.persistent_notification_validation_error.request_error", + "translation": "Persistent notification validation failed." + }, + { + "id": "api.post.post_priority.priority_post_not_allowed_for_user.request_error", + "translation": "User is not allowed to create priority post or persistent notification." + }, + { + "id": "api.post.post_priority.priority_post_only_allowed_for_root_post.request_error", + "translation": "Only root posts are allowed to have priority." + }, + { + "id": "api.post.post_priority.urgent_persistent_notification_post.request_error", + "translation": "Persistent notification posts must have the Urgent Priority." + }, { "id": "api.post.posts_by_ids.invalid_body.request_error", "translation": "The number of Post IDs received has exceeded the maximum size of {{.MaxLength}}" @@ -6259,6 +6283,18 @@ "id": "app.post.update.app_error", "translation": "Unable to update the Post." }, + { + "id": "app.post_persistent_notification.delete_by_channel.app_error", + "translation": "Unable to delete the persistent notifications by channel." + }, + { + "id": "app.post_persistent_notification.delete_by_team.app_error", + "translation": "Unable to delete the persistent notifications by team." + }, + { + "id": "app.post_priority.delete_persistent_notification_post.app_error", + "translation": "Failed to delete persistent notification post" + }, { "id": "app.post_prority.get_for_post.app_error", "translation": "Unable to get postpriority for post" @@ -8907,6 +8943,18 @@ "id": "model.config.is_valid.password_length.app_error", "translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}." }, + { + "id": "model.config.is_valid.persistent_notifications_count.app_error", + "translation": "Invalid total number of persistent notification per post. Must be a positive number." + }, + { + "id": "model.config.is_valid.persistent_notifications_interval.app_error", + "translation": "Invalid frequency of persistent notifications. Must be at least two minutes." + }, + { + "id": "model.config.is_valid.persistent_notifications_recipients.app_error", + "translation": "Invalid maximum number of recipients for persistent notifications. Must be a positive number." + }, { "id": "model.config.is_valid.rate_mem.app_error", "translation": "Invalid memory store size for rate limit settings. Must be a positive number." diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index a5237665ec..24477b1a51 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -474,6 +474,11 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, + "allow_persistent_notifications": *cfg.ServiceSettings.AllowPersistentNotifications, + "allow_persistent_notifications_for_guests": *cfg.ServiceSettings.AllowPersistentNotificationsForGuests, + "persistent_notification_interval_minutes": *cfg.ServiceSettings.PersistentNotificationIntervalMinutes, + "persistent_notification_max_count": *cfg.ServiceSettings.PersistentNotificationMaxCount, + "persistent_notification_max_recipients": *cfg.ServiceSettings.PersistentNotificationMaxRecipients, "self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) diff --git a/server/public/model/config.go b/server/public/model/config.go index 0902de2b9e..0df9ae8bdd 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -376,6 +376,11 @@ type ServiceSettings struct { EnableLatex *bool `access:"site_posts"` EnableInlineLatex *bool `access:"site_posts"` PostPriority *bool `access:"site_posts"` + AllowPersistentNotifications *bool `access:"site_posts"` + AllowPersistentNotificationsForGuests *bool `access:"site_posts"` + PersistentNotificationIntervalMinutes *int `access:"site_posts"` + PersistentNotificationMaxCount *int `access:"site_posts"` + PersistentNotificationMaxRecipients *int `access:"site_posts"` EnableAPIChannelDeletion *bool EnableLocalMode *bool `access:"cloud_restrictable"` LocalModeSocketLocation *string `access:"cloud_restrictable"` // telemetry: none @@ -854,6 +859,26 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { s.PostPriority = NewBool(true) } + if s.AllowPersistentNotifications == nil { + s.AllowPersistentNotifications = NewBool(true) + } + + if s.AllowPersistentNotificationsForGuests == nil { + s.AllowPersistentNotificationsForGuests = NewBool(false) + } + + if s.PersistentNotificationIntervalMinutes == nil { + s.PersistentNotificationIntervalMinutes = NewInt(5) + } + + if s.PersistentNotificationMaxCount == nil { + s.PersistentNotificationMaxCount = NewInt(6) + } + + if s.PersistentNotificationMaxRecipients == nil { + s.PersistentNotificationMaxRecipients = NewInt(5) + } + if s.AllowSyncedDrafts == nil { s.AllowSyncedDrafts = NewBool(true) } @@ -3789,6 +3814,16 @@ func (s *ServiceSettings) isValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.collapsed_threads.app_error", nil, "", http.StatusBadRequest) } + if *s.PersistentNotificationIntervalMinutes < 2 { + return NewAppError("Config.IsValid", "model.config.is_valid.persistent_notifications_interval.app_error", nil, "", http.StatusBadRequest) + } + if *s.PersistentNotificationMaxCount <= 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.persistent_notifications_count.app_error", nil, "", http.StatusBadRequest) + } + if *s.PersistentNotificationMaxRecipients <= 0 { + return NewAppError("Config.IsValid", "model.config.is_valid.persistent_notifications_recipients.app_error", nil, "", http.StatusBadRequest) + } + // we check if file has a valid parent, the server will try to create the socket // file if it doesn't exist, but we need to be sure if the directory exist or not if *s.EnableLocalMode { diff --git a/server/public/model/job.go b/server/public/model/job.go index 3801e1f01b..9c3b010c30 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -32,6 +32,7 @@ const ( JobTypeLastAccessibleFile = "last_accessible_file" JobTypeUpgradeNotifyAdmin = "upgrade_notify_admin" JobTypeTrialNotifyAdmin = "trial_notify_admin" + JobTypePostPersistentNotifications = "post_persistent_notifications" JobTypeInstallPluginNotifyAdmin = "install_plugin_notify_admin" JobTypeHostedPurchaseScreening = "hosted_purchase_screening" diff --git a/server/public/model/post.go b/server/public/model/post.go index 3058318b41..5891207792 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -172,6 +172,20 @@ type PostPriority struct { ChannelId string `json:",omitempty"` } +type PostPersistentNotifications struct { + PostId string + CreateAt int64 + LastSentAt int64 + DeleteAt int64 + SentCount int16 +} + +type GetPersistentNotificationsPostsParams struct { + MaxTime int64 + MaxSentCount int16 + PerPage int +} + type SearchParameter struct { Terms *string `json:"terms"` IsOrSearch *bool `json:"is_or_search"` @@ -803,11 +817,26 @@ func (o *Post) GetPreviewedPostProp() string { } func (o *Post) GetPriority() *PostPriority { - if o.Metadata != nil && o.Metadata.Priority != nil { - return o.Metadata.Priority + if o.Metadata == nil { + return nil } + return o.Metadata.Priority +} - return nil +func (o *Post) GetPersistentNotification() *bool { + priority := o.GetPriority() + if priority == nil { + return nil + } + return priority.PersistentNotifications +} + +func (o *Post) GetRequestedAck() *bool { + priority := o.GetPriority() + if priority == nil { + return nil + } + return priority.RequestedAck } func (o *Post) IsUrgent() bool { diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index c92943ecb0..a4ffaf61c5 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -81,6 +81,7 @@ const ( WebsocketEventDraftDeleted = "draft_deleted" WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" + WebsocketEventPersistentNotificationTriggered = "persistent_notification_triggered" WebsocketEventHostedCustomerSignupProgressUpdated = "hosted_customer_signup_progress_updated" ) diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index cbad38a3e0..03ce908cab 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -105,6 +105,7 @@ import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {handleNewPost} from 'actions/post_actions'; import * as StatusActions from 'actions/status_actions'; import {loadProfilesForSidebar} from 'actions/user_actions'; +import {sendDesktopNotification} from 'actions/notification_actions.jsx'; import store from 'stores/redux_store.jsx'; import WebSocketClient from 'client/web_websocket_client.jsx'; import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins'; @@ -580,6 +581,9 @@ export function handleEvent(msg) { case SocketEvents.DRAFT_DELETED: dispatch(handleDeleteDraftEvent(msg)); break; + case SocketEvents.PERSISTENT_NOTIFICATION_TRIGGERED: + dispatch(handlePersistentNotification(msg)); + break; case SocketEvents.HOSTED_CUSTOMER_SIGNUP_PROGRESS_UPDATED: dispatch(handleHostedCustomerSignupProgressUpdated(msg)); break; @@ -1722,10 +1726,17 @@ function handleDeleteDraftEvent(msg) { }; } +function handlePersistentNotification(msg) { + return async (doDispatch) => { + const post = JSON.parse(msg.data.post); + + doDispatch(sendDesktopNotification(post, msg.data)); + }; +} + function handleHostedCustomerSignupProgressUpdated(msg) { return { type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, data: msg.data.progress, }; } - diff --git a/webapp/channels/src/components/admin_console/admin_definition.jsx b/webapp/channels/src/components/admin_console/admin_definition.jsx index 5df1eb17f6..4c374c7572 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.jsx +++ b/webapp/channels/src/components/admin_console/admin_definition.jsx @@ -224,6 +224,7 @@ export const it = { export const validators = { isRequired: (text, textDefault) => (value) => new ValidationResult(Boolean(value), text, textDefault), + minValue: (min, text, textDefault) => (value) => new ValidationResult((value >= min), text, textDefault), }; const usesLegacyOauth = (config, state, license, enterpriseReady, consoleAccess, cloud) => { @@ -2820,6 +2821,132 @@ const AdminDefinition = { isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), isHidden: it.configIsFalse('FeatureFlags', 'PostPriority'), }, + { + type: Constants.SettingsTypes.TYPE_BOOL, + key: 'ServiceSettings.AllowPersistentNotifications', + label: t('admin.posts.persistentNotifications.title'), + label_default: 'Persistent Notifications', + help_text: t('admin.posts.persistentNotifications.desc'), + help_text_default: 'When enabled, users can trigger repeating notifications for the recipients of urgent messages. Learn more about message priority and persistent notifications in our documentation.', + help_text_values: { + link: (msg) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.any( + it.configIsFalse('FeatureFlags', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'PostPriority'), + ), + }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.PersistentNotificationMaxRecipients', + label: t('admin.posts.persistentNotificationsMaxRecipients.title'), + label_default: 'Maximum number of recipients for persistent notifications', + help_text: t('admin.posts.persistentNotificationsMaxRecipients.desc'), + help_text_default: 'Configure the maximum number of recipients to which users may send persistent notifications. Learn more about message priority and persistent notifications in our documentation.', + help_text_values: { + link: (msg) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.any( + it.configIsFalse('FeatureFlags', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'AllowPersistentNotifications'), + ), + }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.PersistentNotificationIntervalMinutes', + label: t('admin.posts.persistentNotificationsInterval.title'), + label_default: 'Frequency of persistent notifications', + help_text: t('admin.posts.persistentNotificationsInterval.desc'), + help_text_default: 'Configure the number of minutes between repeated notifications for urgent messages send with persistent notifications. Learn more about message priority and persistent notifications in our documentation.', + help_text_values: { + link: (msg) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.any( + it.configIsFalse('FeatureFlags', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'AllowPersistentNotifications'), + ), + validate: validators.minValue(2, t('admin.posts.persistentNotificationsInterval.minValue'), 'Frequency cannot not be set to less than 2 minutes'), + }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.PersistentNotificationMaxCount', + label: t('admin.posts.persistentNotificationsMaxCount.title'), + label_default: 'Total number of persistent notification per post', + help_text: t('admin.posts.persistentNotificationsMaxCount.desc'), + help_text_default: 'Configure the maximum number of times users may receive persistent notifications. Learn more about message priority and persistent notifications in our documentation.', + help_text_values: { + link: (msg) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.any( + it.configIsFalse('FeatureFlags', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'AllowPersistentNotifications'), + ), + }, + { + type: Constants.SettingsTypes.TYPE_BOOL, + key: 'ServiceSettings.AllowPersistentNotificationsForGuests', + label: t('admin.posts.persistentNotificationsGuests.title'), + label_default: 'Allow guests to send persistent notifications', + help_text: t('admin.posts.persistentNotificationsGuests.desc'), + help_text_default: 'Whether a guest is able to require persistent notifications. Learn more about message priority and persistent notifications in our documentation.', + help_text_values: { + link: (msg) => ( + + {msg} + + ), + }, + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.any( + it.configIsFalse('GuestAccountsSettings', 'Enable'), + it.configIsFalse('FeatureFlags', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'PostPriority'), + it.configIsFalse('ServiceSettings', 'AllowPersistentNotifications'), + ), + }, { type: Constants.SettingsTypes.TYPE_BOOL, key: 'ServiceSettings.EnableLinkPreviews', diff --git a/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.jsx.snap b/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.jsx.snap index 3d377a3004..3441f60081 100644 --- a/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.jsx.snap +++ b/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.jsx.snap @@ -20,6 +20,7 @@ exports[`components/advanced_create_post Show tutorial 1`] = ` } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -98,6 +99,7 @@ exports[`components/advanced_create_post should match snapshot for center textbo } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -176,6 +178,7 @@ exports[`components/advanced_create_post should match snapshot when cannot post } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -254,6 +257,7 @@ exports[`components/advanced_create_post should match snapshot when file upload } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -332,6 +336,7 @@ exports[`components/advanced_create_post should match snapshot, can post; previe } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -410,6 +415,7 @@ exports[`components/advanced_create_post should match snapshot, can post; previe } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -488,6 +494,7 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -566,6 +573,7 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -644,6 +652,7 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = ` } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -722,6 +731,7 @@ exports[`components/advanced_create_post should match snapshot, post priority di } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -794,60 +804,11 @@ exports[`components/advanced_create_post should match snapshot, post priority en - - - - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - - - - - , + , ] } applyMarkdown={[Function]} @@ -862,6 +823,7 @@ exports[`components/advanced_create_post should match snapshot, post priority en } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -929,65 +891,16 @@ exports[`components/advanced_create_post should match snapshot, post priority en - - - - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - - - - - , + } + />, ] } applyMarkdown={[Function]} @@ -1002,6 +915,7 @@ exports[`components/advanced_create_post should match snapshot, post priority en } } currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" + disableSend={false} draft={ Object { "fileInfos": Array [], @@ -1041,65 +955,19 @@ exports[`components/advanced_create_post should match snapshot, post priority en hideEmojiPicker={[Function]} isFormattingBarHidden={false} labels={ -
- - - - + - - -
+ } + /> } location="CENTER" maxPostSize={4000} diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx index 29f04579ff..4cda2c0e8c 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx @@ -4,10 +4,6 @@ /* eslint-disable max-lines */ import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import classNames from 'classnames'; - -import {AlertCircleOutlineIcon, CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; import {isNil} from 'lodash'; @@ -34,6 +30,7 @@ import { splitMessageBasedOnCaretPosition, groupsMentionedInText, mentionsMinusSpecialMentionsInText, + hasRequestedPersistentNotifications, } from 'utils/post_utils'; import {getTable, hasHtmlLink, formatMarkdownMessage, formatGithubCodePaste, isGitHubCodeBlock, isHttpProtocol, isHttpsProtocol} from 'utils/paste'; import * as UserAgent from 'utils/user_agent'; @@ -41,9 +38,6 @@ import * as Utils from 'utils/utils'; import EmojiMap from 'utils/emoji_map'; import {applyLinkMarkdown, ApplyLinkMarkdownOptions, applyMarkdown, ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import Tooltip from 'components/tooltip'; -import OverlayTrigger from 'components/overlay_trigger'; -import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; import NotifyConfirmModal from 'components/notify_confirm_modal'; import EditChannelHeaderModal from 'components/edit_channel_header_modal'; import EditChannelPurposeModal from 'components/edit_channel_purpose_modal'; @@ -51,14 +45,14 @@ import {FileUpload as FileUploadClass} from 'components/file_upload/file_upload' import ResetStatusModal from 'components/reset_status_modal'; import TextboxClass from 'components/textbox/textbox'; import PostPriorityPickerOverlay from 'components/post_priority/post_priority_picker_overlay'; -import PriorityLabel from 'components/post_priority/post_priority_label'; +import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; import {PostDraft} from 'types/store/draft'; import {ModalData} from 'types/actions'; import {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels'; -import {Post, PostMetadata, PostPriorityMetadata} from '@mattermost/types/posts'; +import {Post, PostMetadata, PostPriority, PostPriorityMetadata} from '@mattermost/types/posts'; import {PreferenceType} from '@mattermost/types/preferences'; import {ServerError} from '@mattermost/types/errors'; import {CommandArgs} from '@mattermost/types/integrations'; @@ -67,10 +61,13 @@ import {FileInfo} from '@mattermost/types/files'; import {Emoji} from '@mattermost/types/emojis'; import AdvancedTextEditor from '../advanced_text_editor/advanced_text_editor'; -import {IconContainer} from '../advanced_text_editor/formatting_bar/formatting_icon'; import FileLimitStickyBanner from '../file_limit_sticky_banner'; + import {FilePreviewInfo} from '../file_preview/file_preview'; + +import PriorityLabels from './priority_labels'; + const KeyCodes = Constants.KeyCodes; function isDraftEmpty(draft: PostDraft): boolean { @@ -276,7 +273,6 @@ class AdvancedCreatePost extends React.PureComponent { private topDiv: React.RefObject; private textboxRef: React.RefObject; private fileUploadRef: React.RefObject; - private postPriorityPickerRef: React.RefObject; static getDerivedStateFromProps(props: Props, state: State): Partial { let updatedState: Partial = { @@ -317,7 +313,6 @@ class AdvancedCreatePost extends React.PureComponent { this.topDiv = React.createRef(); this.textboxRef = React.createRef(); this.fileUploadRef = React.createRef(); - this.postPriorityPickerRef = React.createRef(); } componentDidMount() { @@ -599,6 +594,20 @@ class AdvancedCreatePost extends React.PureComponent { }); }; + showPersistNotificationModal = (message: string, specialMentions: {[key: string]: boolean}, channelType: Channel['type']) => { + this.props.actions.openModal({ + modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL, + dialogType: PersistNotificationConfirmModal, + dialogProps: { + currentChannelTeammateUsername: this.props.currentChannelTeammateUsername, + specialMentions, + channelType, + message, + onConfirm: this.handleNotifyAllConfirmation, + }, + }); + }; + getStatusFromSlashCommand = () => { const {message} = this.state; const tokens = message.split(' '); @@ -673,7 +682,17 @@ class AdvancedCreatePost extends React.PureComponent { } } - if (memberNotifyCount > 0) { + const isDirectOrGroup = + updateChannel.type === Constants.DM_CHANNEL || updateChannel.type === Constants.GM_CHANNEL; + + if ( + this.props.isPostPriorityEnabled && + hasRequestedPersistentNotifications(this.props.draft?.metadata?.priority) + ) { + this.showPersistNotificationModal(this.state.message, specialMentions, updateChannel.type); + this.isDraftSubmitting = false; + return; + } else if (memberNotifyCount > 0) { this.showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount); this.isDraftSubmitting = false; return; @@ -708,8 +727,6 @@ class AdvancedCreatePost extends React.PureComponent { return; } - const isDirectOrGroup = - updateChannel.type === Constants.DM_CHANNEL || updateChannel.type === Constants.GM_CHANNEL; if (!isDirectOrGroup && trimRight(this.state.message) === '/purpose') { const editChannelPurposeModalData = { modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, @@ -837,7 +854,7 @@ class AdvancedCreatePost extends React.PureComponent { return; } - if (allowSending) { + if (allowSending && this.isValidPersistentNotifications()) { if (e.persist) { e.persist(); } @@ -1555,20 +1572,9 @@ class AdvancedCreatePost extends React.PureComponent { }; handlePostPriorityHide = () => { - this.setState({ - showPostPriorityPicker: false, - }); - this.focusTextbox(); + this.focusTextbox(true); }; - togglePostPriorityPicker = () => { - this.setState((prev) => ({ - showPostPriorityPicker: !prev.showPostPriorityPicker, - })); - }; - - getPostPriorityPickerRef = () => this.postPriorityPickerRef.current; - hasPrioritySet = () => { return ( this.props.isPostPriorityEnabled && @@ -1579,7 +1585,41 @@ class AdvancedCreatePost extends React.PureComponent { ); }; + isValidPersistentNotifications = (): boolean => { + if (!this.hasPrioritySet()) { + return true; + } + + const {currentChannel} = this.props; + const {priority, persistent_notifications: persistentNotifications} = this.props.draft.metadata!.priority!; + if (priority !== PostPriority.URGENT || !persistentNotifications) { + return true; + } + + if (currentChannel.type === Constants.DM_CHANNEL) { + return true; + } + + if (this.hasSpecialMentions()) { + return false; + } + + const mentions = mentionsMinusSpecialMentionsInText(this.state.message); + + return mentions.length > 0; + }; + + getSpecialMentions = (): {[key: string]: boolean} => { + return specialMentionsInText(this.state.message); + }; + + hasSpecialMentions = (): boolean => { + return Object.values(this.getSpecialMentions()).includes(true); + }; + render() { + const {draft, canPost} = this.props; + let centerClass = ''; if (!this.props.fullWidthTextBox) { centerClass = 'center'; @@ -1589,78 +1629,6 @@ class AdvancedCreatePost extends React.PureComponent { return null; } - const priorityLabels = ( - this.hasPrioritySet() ? ( -
- {this.props.draft.metadata!.priority!.priority && ( - - )} - {this.props.draft.metadata!.priority!.requested_ack && ( -
- - - - )} - > - - - {!(this.props.draft.metadata!.priority!.priority) && ( - - )} -
- )} - {!this.props.shouldShowPreview && ( - - - - )} - > - - - )} -
- ) : undefined - ); - return (
{ className={centerClass} onSubmit={this.handleSubmit} > - { - this.props.canPost && - (this.props.draft.fileInfos.length > 0 || this.props.draft.uploadsInProgress.length > 0) && + {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( - } + )} { errorClass={this.state.errorClass} serverError={this.state.serverError} isFormattingBarHidden={this.state.isFormattingBarHidden} - draft={this.props.draft} + draft={draft} showSendTutorialTip={this.props.showSendTutorialTip} handleSubmit={this.handleSubmit} removePreview={this.removePreview} setShowPreview={this.setShowPreview} shouldShowPreview={this.props.shouldShowPreview} maxPostSize={this.props.maxPostSize} - canPost={this.props.canPost} + canPost={canPost} applyMarkdown={this.applyMarkdown} useChannelMentions={this.props.useChannelMentions} badConnection={this.props.badConnection} @@ -1722,46 +1688,27 @@ class AdvancedCreatePost extends React.PureComponent { fileUploadRef={this.fileUploadRef} prefillMessage={this.prefillMessage} textboxRef={this.textboxRef} - labels={priorityLabels} + disableSend={!this.isValidPersistentNotifications()} + labels={this.hasPrioritySet() ? ( + + ) : undefined} additionalControls={[ this.props.isPostPriorityEnabled && ( - - - - - - )} - > - - - - - + ), ].filter(Boolean)} /> diff --git a/webapp/channels/src/components/advanced_create_post/priority_labels.tsx b/webapp/channels/src/components/advanced_create_post/priority_labels.tsx new file mode 100644 index 0000000000..09c1caeff6 --- /dev/null +++ b/webapp/channels/src/components/advanced_create_post/priority_labels.tsx @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, CSSProperties} from 'react'; +import {FormattedMessage} from 'react-intl'; +import styled from 'styled-components'; + +import {CheckCircleOutlineIcon, BellRingOutlineIcon} from '@mattermost/compass-icons/components'; + +import OverlayTrigger from 'components/overlay_trigger'; +import Tooltip from 'components/tooltip'; +import PriorityLabel from 'components/post_priority/post_priority_label'; +import {HasNoMentions, HasSpecialMentions} from 'components/post_priority/error_messages'; + +import Constants from 'utils/constants'; + +import {PostPriorityMetadata} from '@mattermost/types/posts'; + +type Props = { + canRemove: boolean; + hasError: boolean; + specialMentions?: {[key: string]: boolean}; + onRemove?: () => void; + padding?: CSSProperties['padding']; + persistentNotifications?: PostPriorityMetadata['persistent_notifications']; + priority?: PostPriorityMetadata['priority']; + requestedAck?: PostPriorityMetadata['requested_ack']; +}; + +type StyledProps = { + hasError: boolean; +}; + +const Priority = styled.div` + align-items: center; + display: flex; + gap: 6px; + padding: ${(props: {padding: CSSProperties['padding']}) => props.padding || '14px 16px 0'} +`; + +const Acknowledgements = styled.div` + align-items: center; + color: ${(props: StyledProps) => (props.hasError ? 'var(--dnd-indicator)' : 'var(--online-indicator)')}; + display: flex; + + > span { + margin-left: 4px; + font-size: 11px; + font-weight: 600; + } +`; + +const Notifications = styled.div` + align-items: center; + color: var(--dnd-indicator); + display: flex; + + > span { + margin-left: 4px; + font-size: 11px; + font-weight: 600; + } +`; + +const Close = styled.button` + align-items: center; + color: rgb(var(--center-channel-color)); + display: flex; + font-size: 17px; + justify-content: center; + margin-top: -1px; + opacity: 0.56; + visibility: hidden; + + &:hover { + opacity: 0.72; + } + + ${Priority}:hover & { + visibility: visible; + } +`; + +const Error = styled.div` + color: var(--dnd-indicator); + font-size: 11px; + font-weight: 600; +`; + +function PriorityLabels({ + canRemove, + hasError, + specialMentions, + onRemove, + padding, + persistentNotifications, + priority, + requestedAck, +}: Props) { + return ( + + {priority && ( + + )} + {persistentNotifications && ( + + + + )} + > + + + + + )} + {requestedAck && ( + + + + + )} + > + + + {!(priority) && ( + + )} + + )} + {hasError && ( + + {(specialMentions && Object.values(specialMentions).includes(true)) ? : } + + )} + {canRemove && ( + + + + )} + > + + + + + + + + )} + + ); +} + +export default memo(PriorityLabels); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss index 3ae2492b60..a67274ea2d 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss @@ -67,50 +67,6 @@ } } - &__priority { - display: flex; - align-items: center; - padding: 14px 16px 0; - gap: 6px; - - &-ack { - display: flex; - align-items: center; - color: var(--online-indicator); - - > span { - margin-left: 4px; - font-size: 11px; - font-weight: 600; - } - - &-tooltip { - max-width: 230px; - } - } - - button.close { - display: flex; - align-items: center; - justify-content: center; - margin-top: -1px; - color: rgb(var(--center-channel-color)); - font-size: 17px; - opacity: 0.56; - visibility: hidden; - - &:hover { - opacity: 0.72; - } - } - - &:hover { - button.close { - visibility: visible; - } - } - } - &__action-button { display: flex; width: 32px; diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index 9b76d8bd7a..dfdc290b5d 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -101,6 +101,7 @@ type Props = { isThreadView?: boolean; additionalControls?: React.ReactNodeArray; labels?: React.ReactNode; + disableSend?: boolean; } const AdvanceTextEditor = ({ @@ -156,6 +157,7 @@ const AdvanceTextEditor = ({ isThreadView, additionalControls, labels, + disableSend = false, }: Props) => { const readOnlyChannel = !canPost; const {formatMessage} = useIntl(); @@ -301,7 +303,7 @@ const AdvanceTextEditor = ({ ); } - const disableSendButton = Boolean(readOnlyChannel || (!message.trim().length && !draft.fileInfos.length)); + const disableSendButton = Boolean(readOnlyChannel || (!message.trim().length && !draft.fileInfos.length)) || disableSend; const sendButton = readOnlyChannel ? null : ( { type: 'channel' as 'channel' | 'thread', user: {} as UserProfile, value: {} as PostDraft, + postPriorityEnabled: false, isRemote: false, }; diff --git a/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx b/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx index 3ec5dd72d1..0fc3fa4aae 100644 --- a/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx +++ b/webapp/channels/src/components/drafts/channel_draft/channel_draft.tsx @@ -5,9 +5,13 @@ import React, {memo, useCallback} from 'react'; import {useDispatch} from 'react-redux'; import {useHistory} from 'react-router-dom'; +import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; +import {openModal} from 'actions/views/modals'; import {createPost} from 'actions/post_actions'; import {removeDraft} from 'actions/views/drafts'; import {PostDraft} from 'types/store/draft'; +import {hasRequestedPersistentNotifications, specialMentionsInText} from 'utils/post_utils'; +import {ModalIdentifiers} from 'utils/constants'; import type {Channel} from '@mattermost/types/channels'; import type {UserProfile, UserStatus} from '@mattermost/types/users'; @@ -25,6 +29,7 @@ type Props = { displayName: string; draftId: string; id: Channel['id']; + postPriorityEnabled: boolean; status: UserStatus['status']; type: 'channel' | 'thread'; user: UserProfile; @@ -37,6 +42,7 @@ function ChannelDraft({ channelUrl, displayName, draftId, + postPriorityEnabled, status, type, user, @@ -48,11 +54,30 @@ function ChannelDraft({ const handleOnEdit = useCallback(() => { history.push(channelUrl); - }, [channelUrl]); + }, [history, channelUrl]); const handleOnDelete = useCallback((id: string) => { dispatch(removeDraft(id, channel.id)); - }, [channel.id]); + }, [dispatch, channel.id]); + + const doSubmit = useCallback((id: string, post: Post) => { + dispatch(createPost(post, value.fileInfos)); + dispatch(removeDraft(id, channel.id)); + history.push(channelUrl); + }, [dispatch, history, value.fileInfos, channel.id, channelUrl]); + + const showPersistNotificationModal = useCallback((id: string, post: Post) => { + dispatch(openModal({ + modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL, + dialogType: PersistNotificationConfirmModal, + dialogProps: { + message: post.message, + channelType: channel.type, + specialMentions: specialMentionsInText(post.message), + onConfirm: () => doSubmit(id, post), + }, + })); + }, [channel.type, dispatch, doSubmit]); const handleOnSend = useCallback(async (id: string) => { const post = {} as Post; @@ -67,11 +92,12 @@ function ChannelDraft({ return; } - dispatch(createPost(post, value.fileInfos)); - dispatch(removeDraft(id, channel.id)); - - history.push(channelUrl); - }, [value, channelUrl, user.id, channel.id]); + if (postPriorityEnabled && hasRequestedPersistentNotifications(value?.metadata?.priority)) { + showPersistNotificationModal(id, post); + return; + } + doSubmit(id, post); + }, [doSubmit, postPriorityEnabled, value, user.id, showPersistNotificationModal]); if (!channel) { return null; diff --git a/webapp/channels/src/components/drafts/channel_draft/index.ts b/webapp/channels/src/components/drafts/channel_draft/index.ts index b3722f1cdf..a671c1b711 100644 --- a/webapp/channels/src/components/drafts/channel_draft/index.ts +++ b/webapp/channels/src/components/drafts/channel_draft/index.ts @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; +import {isPostPriorityEnabled} from 'mattermost-redux/selectors/entities/posts'; import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; @@ -28,6 +29,7 @@ function makeMapStateToProps() { return { channel, channelUrl, + postPriorityEnabled: isPostPriorityEnabled(state), }; }; } diff --git a/webapp/channels/src/components/drafts/panel/__snapshots__/panel_body.test.tsx.snap b/webapp/channels/src/components/drafts/panel/__snapshots__/panel_body.test.tsx.snap index 45e0bcc69f..499bb8ec6b 100644 --- a/webapp/channels/src/components/drafts/panel/__snapshots__/panel_body.test.tsx.snap +++ b/webapp/channels/src/components/drafts/panel/__snapshots__/panel_body.test.tsx.snap @@ -583,7 +583,7 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1 priority={ Object { "priority": "important", - "requested_ack": true, + "requested_ack": false, } } status="status" @@ -776,76 +776,68 @@ exports[`components/drafts/panel/panel_body should match snapshot for priority 1 display_name -
- - - -
- - - - - - - - Important - - -
-
-
-
-
- - - - - -
-
+ + + + + + + + Important + + + + + + + + +
display_name -
-
- - - - - - - - Request acknowledgement - - -
-
+
+ + + + } + placement="top" + trigger={ + Array [ + "hover", + "focus", + ] + } + > + + + + } + placement="top" + trigger={ + Array [ + "hover", + "focus", + ] + } + > + + + + + + + + + + Request acknowledgement + + +
+ +
+ +
span { - margin-left: 4px; - font-size: 11px; - font-weight: 600; - } - } - } - .file-preview__container { height: auto; flex-wrap: wrap; diff --git a/webapp/channels/src/components/drafts/panel/panel_body.test.tsx b/webapp/channels/src/components/drafts/panel/panel_body.test.tsx index c40a87781f..007362e0e9 100644 --- a/webapp/channels/src/components/drafts/panel/panel_body.test.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_body.test.tsx @@ -112,7 +112,7 @@ describe('components/drafts/panel/panel_body', () => { {...baseProps} priority={{ priority: PostPriority.IMPORTANT, - requested_ack: true, + requested_ack: false, }} /> , diff --git a/webapp/channels/src/components/drafts/panel/panel_body.tsx b/webapp/channels/src/components/drafts/panel/panel_body.tsx index 5a0e7164a4..e112b2a589 100644 --- a/webapp/channels/src/components/drafts/panel/panel_body.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_body.tsx @@ -3,16 +3,13 @@ import React, {useCallback} from 'react'; import {useSelector} from 'react-redux'; -import {FormattedMessage} from 'react-intl'; - -import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; import Markdown from 'components/markdown'; import FilePreview from 'components/file_preview'; import ProfilePicture from 'components/profile_picture'; -import PriorityLabel from 'components/post_priority/post_priority_label'; +import PriorityLabels from 'components/advanced_create_post/priority_labels'; import {imageURLForUser, handleFormattedTextClick} from 'utils/utils'; import type {PostDraft} from 'types/store/draft'; @@ -77,25 +74,14 @@ function PanelBody({
{displayName} {priority && ( -
- {priority.priority && ( - - )} - {priority.requested_ack && ( -
- - {!priority.priority && ( - - )} -
- )} -
+ )}
diff --git a/webapp/channels/src/components/persist_notification_confirm_modal.tsx b/webapp/channels/src/components/persist_notification_confirm_modal.tsx new file mode 100644 index 0000000000..5dc8fcbb6b --- /dev/null +++ b/webapp/channels/src/components/persist_notification_confirm_modal.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getPersistentNotificationIntervalMinutes, getPersistentNotificationMaxRecipients} from 'mattermost-redux/selectors/entities/posts'; + +import {GlobalState} from 'types/store'; +import {makeGetUserOrGroupMentionCountFromMessage} from 'utils/post_utils'; +import Constants from 'utils/constants'; + +import GenericModal from 'components/generic_modal'; +import {UserProfile} from '@mattermost/types/users'; +import {Channel} from '@mattermost/types/channels'; + +import {HasNoMentions, HasSpecialMentions} from './post_priority/error_messages'; + +type Props = { + currentChannelTeammateUsername?: UserProfile['username']; + specialMentions: {[key: string]: boolean}; + channelType: Channel['type']; + message: string; + onConfirm: () => void; + onExited: () => void; +}; + +function PersistNotificationConfirmModal({ + channelType, + currentChannelTeammateUsername, + specialMentions, + message, + onConfirm, + onExited, +}: Props) { + let body: React.ReactNode = ''; + let title: React.ReactNode = ''; + let confirmBtn: React.ReactNode = ''; + let handleConfirm = () => {}; + + const getMentionCount = useMemo(makeGetUserOrGroupMentionCountFromMessage, []); + const maxRecipients = useSelector(getPersistentNotificationMaxRecipients); + const interval = useSelector(getPersistentNotificationIntervalMinutes); + const count = useSelector((state: GlobalState) => getMentionCount(state, message)); + + if (channelType === Constants.DM_CHANNEL) { + handleConfirm = onConfirm; + title = ( + + ); + body = ( + {chunks}, + }} + /> + ); + confirmBtn = ( + + ); + } else if (Object.values(specialMentions).includes(true)) { + body = ( + + ); + confirmBtn = ( + + ); + } else if (count === 0) { + title = ; + body = ( + + ); + confirmBtn = ( + + ); + } else if (count > Number(maxRecipients)) { + title = ( + + ); + body = ( + {chunks}, + }} + /> + ); + confirmBtn = ( + + ); + } else { + handleConfirm = onConfirm; + title = ( + + ); + body = ( + + ); + confirmBtn = ( + + ); + } + + return ( + {}} + handleConfirm={handleConfirm} + handleEnterKeyPress={handleConfirm} + isDeleteModal={false} + modalHeaderText={title} + onExited={onExited} + > + {body} + + ); +} + +export default memo(PersistNotificationConfirmModal); diff --git a/webapp/channels/src/components/post_priority/error_messages.tsx b/webapp/channels/src/components/post_priority/error_messages.tsx new file mode 100644 index 0000000000..48f15aabd0 --- /dev/null +++ b/webapp/channels/src/components/post_priority/error_messages.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {FormattedMessage, FormattedList} from 'react-intl'; + +export function HasSpecialMentions({specialMentions}: {specialMentions: {[key: string]: boolean}}) { + const mentions = useMemo(() => { + return Object.keys(specialMentions). + filter((key) => specialMentions[key]). + map((key) => `@${key}`); + + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ + specialMentions.all, + specialMentions.here, + specialMentions.channel, + ]); + /* eslint-enable react-hooks/exhaustive-deps */ + + return ( + + ), + }} + /> + ); +} + +export function HasNoMentions() { + return ( + + ); +} diff --git a/webapp/channels/src/components/post_priority/post_priority_badge.tsx b/webapp/channels/src/components/post_priority/post_priority_badge.tsx index 907e8cc618..a534025a4f 100644 --- a/webapp/channels/src/components/post_priority/post_priority_badge.tsx +++ b/webapp/channels/src/components/post_priority/post_priority_badge.tsx @@ -19,8 +19,8 @@ const Badge = styled.span` justify-content: center; height: 20px; width: 20px; + margin-left: 8px; min-width: 20px; - margin-right: 10px; border-radius: 10px; color: #fff; diff --git a/webapp/channels/src/components/post_priority/post_priority_picker.tsx b/webapp/channels/src/components/post_priority/post_priority_picker.tsx index 6f8ca90d30..4ab5233098 100644 --- a/webapp/channels/src/components/post_priority/post_priority_picker.tsx +++ b/webapp/channels/src/components/post_priority/post_priority_picker.tsx @@ -1,14 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState, memo} from 'react'; +import React, {useCallback, useState, memo} from 'react'; import {useSelector} from 'react-redux'; import {FormattedMessage, useIntl} from 'react-intl'; import styled from 'styled-components'; -import {AlertOutlineIcon, AlertCircleOutlineIcon, MessageTextOutlineIcon, CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; +import {AlertOutlineIcon, AlertCircleOutlineIcon, MessageTextOutlineIcon, CheckCircleOutlineIcon, BellRingOutlineIcon} from '@mattermost/compass-icons/components'; -import {isPostAcknowledgementsEnabled} from 'mattermost-redux/selectors/entities/posts'; +import {getPersistentNotificationIntervalMinutes, isPersistentNotificationsEnabled, isPostAcknowledgementsEnabled} from 'mattermost-redux/selectors/entities/posts'; import BetaTag from '../widgets/tag/beta_tag'; @@ -21,11 +21,6 @@ type Props = { settings?: PostPriorityMetadata; onClose: () => void; onApply: (props: PostPriorityMetadata) => void; - placement: string; - rightOffset?: number; - topOffset?: number; - leftOffset?: number; - style?: React.CSSProperties; } const UrgentIcon = styled(AlertOutlineIcon)` @@ -44,6 +39,10 @@ const AcknowledgementIcon = styled(CheckCircleOutlineIcon)` fill: rgba(var(--center-channel-color-rgb), 0.56); `; +const PersistentNotificationsIcon = styled(BellRingOutlineIcon)` + fill: rgba(var(--center-channel-color-rgb), 0.56); +`; + const Header = styled.h4` align-items: center; display: flex; @@ -73,53 +72,50 @@ const Footer = styled.div` `; const Picker = styled.div` - position: absolute; - z-index: 1100; - display: flex; - flex-direction: column; - border: solid 1px rgba(var(--center-channel-color-rgb), 0.16); - margin-right: 3px; + *zoom: 1; background: var(--center-channel-bg); border-radius: 4px; + border: solid 1px rgba(var(--center-channel-color-rgb), 0.16); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - user-select: none; + display: flex; + flex-direction: column; + left: 0; + margin-right: 3px; + min-width: 0; overflow: hidden; - *zoom: 1; + user-select: none; + width: max-content; `; function PostPriorityPicker({ - leftOffset = 0, onApply, onClose, - placement, - rightOffset = 0, settings, - style, - topOffset = 0, }: Props) { const {formatMessage} = useIntl(); const [priority, setPriority] = useState(settings?.priority || ''); const [requestedAck, setRequestedAck] = useState(settings?.requested_ack || false); - - const ref = useRef(null); - - useEffect(() => { - ref.current?.focus(); - }, []); + const [persistentNotifications, setPersistentNotifications] = useState(settings?.persistent_notifications || false); const postAcknowledgementsEnabled = useSelector(isPostAcknowledgementsEnabled); + const persistentNotificationsEnabled = useSelector(isPersistentNotificationsEnabled) && postAcknowledgementsEnabled; + const interval = useSelector(getPersistentNotificationIntervalMinutes); + + const makeOnSelectPriority = useCallback((type?: PostPriority) => (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); - const makeOnSelectPriority = useCallback((type?: PostPriority) => () => { setPriority(type || ''); if (!postAcknowledgementsEnabled) { onApply({ priority: type || '', requested_ack: false, + persistent_notifications: false, }); onClose(); - } else if (type === PostPriority.URGENT) { - setRequestedAck(true); + } else if (type !== PostPriority.URGENT) { + setPersistentNotifications(false); } }, [onApply, onClose, postAcknowledgementsEnabled]); @@ -127,43 +123,23 @@ function PostPriorityPicker({ setRequestedAck(!requestedAck); }, [requestedAck]); + const handlePersistentNotifications = useCallback(() => { + setPersistentNotifications(!persistentNotifications); + }, [persistentNotifications]); + const handleApply = () => { onApply({ priority, requested_ack: requestedAck, + persistent_notifications: persistentNotifications, }); onClose(); }; - let pickerStyle: React.CSSProperties = {}; - if (style && !(style.left === 0 && style.top === 0)) { - if (placement === 'top' || placement === 'bottom') { - // Only take the top/bottom position passed by React Bootstrap since we want to be left-aligned - pickerStyle = { - top: style.top, - bottom: style.bottom, - left: leftOffset, - }; - } else { - pickerStyle = {...style}; - } - - pickerStyle.top = pickerStyle.top ? Number(pickerStyle.top) + topOffset : topOffset; - - if (pickerStyle.right) { - pickerStyle.right = Number(pickerStyle.right) + rightOffset; - } - } - const feedbackLink = postAcknowledgementsEnabled ? 'https://forms.gle/noA8Azg7RdaBZtMB6' : 'https://forms.gle/mMcRFQzyKAo9Sv49A'; return ( - +
{formatMessage({ id: 'post_priority.picker.header', @@ -215,22 +191,44 @@ function PostPriorityPicker({ })} /> - {postAcknowledgementsEnabled && ( + {(postAcknowledgementsEnabled || persistentNotificationsEnabled) && ( - } - text={formatMessage({ - id: 'post_priority.requested_ack.text', - defaultMessage: 'Request acknowledgement', - })} - description={formatMessage({ - id: 'post_priority.requested_ack.description', - defaultMessage: 'An acknowledgement button will appear with your message', - })} - /> + {postAcknowledgementsEnabled && ( + } + text={formatMessage({ + id: 'post_priority.requested_ack.text', + defaultMessage: 'Request acknowledgement', + })} + description={formatMessage({ + id: 'post_priority.requested_ack.description', + defaultMessage: 'An acknowledgement button will appear with your message', + })} + /> + )} + {priority === PostPriority.URGENT && persistentNotificationsEnabled && ( + } + text={formatMessage({ + id: 'post_priority.persistent_notifications.text', + defaultMessage: 'Send persistent notifications', + })} + description={formatMessage( + { + id: 'post_priority.persistent_notifications.description', + defaultMessage: 'Recipients will be notified every {interval, plural, one {1 minute} other {{interval} minutes}} until they acknowledge or reply', + }, { + interval, + }, + )} + /> + )} )} diff --git a/webapp/channels/src/components/post_priority/post_priority_picker_item.tsx b/webapp/channels/src/components/post_priority/post_priority_picker_item.tsx index 4ca58b83c2..df4dd4da8d 100644 --- a/webapp/channels/src/components/post_priority/post_priority_picker_item.tsx +++ b/webapp/channels/src/components/post_priority/post_priority_picker_item.tsx @@ -33,7 +33,7 @@ const ItemButton = styled.button` `; const Wrapper = styled.div` - cursor: pointer; + cursor: ${(props) => (props.disabled ? 'default' : 'pointer')}; &:hover { background-color: rgba(var(--center-channel-color-rgb), 0.1); @@ -113,7 +113,8 @@ function ToggleItem({ }: ToggleProps) { return ( diff --git a/webapp/channels/src/components/post_priority/post_priority_picker_overlay.tsx b/webapp/channels/src/components/post_priority/post_priority_picker_overlay.tsx index 9aec58b1e6..0866ca9838 100644 --- a/webapp/channels/src/components/post_priority/post_priority_picker_overlay.tsx +++ b/webapp/channels/src/components/post_priority/post_priority_picker_overlay.tsx @@ -1,56 +1,147 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo} from 'react'; -import {Overlay} from 'react-bootstrap'; -import memoize from 'memoize-one'; +import React, {memo, useCallback, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; +import { + FloatingFocusManager, + FloatingPortal, + autoUpdate, + offset, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, + flip, + shift, +} from '@floating-ui/react-dom-interactions'; + +import {AlertCircleOutlineIcon} from '@mattermost/compass-icons/components'; + +import {IconContainer} from 'components/advanced_text_editor/formatting_bar/formatting_icon'; +import useTooltip from 'components/common/hooks/useTooltip'; import {PostPriorityMetadata} from '@mattermost/types/posts'; import PostPriorityPicker from './post_priority_picker'; type Props = { - show: boolean; + disabled: boolean; settings?: PostPriorityMetadata; - target: () => React.RefObject | React.ReactInstance | null; onApply: (props: PostPriorityMetadata) => void; - onHide: () => void; - defaultHorizontalPosition: 'left'|'right'; + onClose: () => void; }; function PostPriorityPickerOverlay({ - show, + disabled, settings, - target, onApply, - onHide, + onClose, }: Props) { - const pickerPosition = memoize((trigger, show) => { - if (show && trigger) { - return trigger.getBoundingClientRect().left; - } - return 0; + const [pickerOpen, setPickerOpen] = useState(false); + + const { + reference: tooltipRef, + getReferenceProps: getTooltipReferenceProps, + tooltip, + } = useTooltip({ + placement: 'top', + message: ( + + ), }); - const offset = pickerPosition(target(), show); + + const handleClose = useCallback(() => { + setPickerOpen(false); + onClose(); + }, [onClose]); + + const { + x: pickerX, + y: pickerY, + reference: pickerRef, + floating: pickerFloating, + strategy: pickerStrategy, + context: pickerContext, + } = useFloating({ + open: pickerOpen, + onOpenChange: setPickerOpen, + placement: 'top-start', + whileElementsMounted: autoUpdate, + middleware: [ + offset({mainAxis: 4}), + flip({ + fallbackPlacements: ['top'], + }), + shift({ + padding: 16, + }), + ], + }); + + const { + getFloatingProps: getPickerFloatingProps, + getReferenceProps: getPickerReferenceProps, + } = useInteractions([ + useClick(pickerContext), + useDismiss(pickerContext), + useRole(pickerContext), + ]); return ( - - - + <> +
+ + + +
+ + {pickerOpen && ( + +
+ +
+
+ )} +
+ {!pickerOpen && tooltip} + ); } diff --git a/webapp/channels/src/components/post_view/acknowledgements/post_acknowledgements.scss b/webapp/channels/src/components/post_view/acknowledgements/post_acknowledgements.scss index 2c00fed89f..411104a86f 100644 --- a/webapp/channels/src/components/post_view/acknowledgements/post_acknowledgements.scss +++ b/webapp/channels/src/components/post_view/acknowledgements/post_acknowledgements.scss @@ -30,7 +30,7 @@ &--disabled, &:disabled { background: rgba(var(--online-indicator-rgb), 0.08); - cursor: not-allowed; + cursor: default; } &:hover:enabled { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index e23b704b5b..203a07f67f 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1913,6 +1913,17 @@ "admin.plugins.settings.marketplaceUrlDesc.empty": " Marketplace URL is a required field.", "admin.plugins.settings.requirePluginSignature": "Require Plugin Signature:", "admin.plugins.settings.requirePluginSignatureDesc": "When true, uploading plugins is disabled and may only be installed through the Marketplace. Plugins are always verified during Mattermost server startup and initialization. See documentation to learn more.", + "admin.posts.persistentNotifications.desc": "When enabled, users can trigger repeating notifications for the recipients of urgent messages. Learn more about message priority and persistent notifications in our documentation.", + "admin.posts.persistentNotifications.title": "Persistent Notifications", + "admin.posts.persistentNotificationsGuests.desc": "Whether a guest is able to require persistent notifications. Learn more about message priority and persistent notifications in our documentation.", + "admin.posts.persistentNotificationsGuests.title": "Allow guests to send persistent notifications", + "admin.posts.persistentNotificationsInterval.desc": "Configure the number of minutes between repeated notifications for urgent messages send with persistent notifications. Learn more about message priority and persistent notifications in our documentation.", + "admin.posts.persistentNotificationsInterval.minValue": "Frequency must be at least two minutes", + "admin.posts.persistentNotificationsInterval.title": "Frequency of persistent notifications", + "admin.posts.persistentNotificationsMaxCount.desc": "Configure the maximum number of times users may receive persistent notifications. Learn more about message priority and persistent notifications in our documentation.", + "admin.posts.persistentNotificationsMaxCount.title": "Total number of persistent notification per post", + "admin.posts.persistentNotificationsMaxRecipients.desc": "Configure the maximum number of recipients to which users may send persistent notifications. Learn more about message priority and persistent notifications in our documentation.", + "admin.posts.persistentNotificationsMaxRecipients.title": "Maximum number of recipients for persistent notifications", "admin.posts.postPriority.desc": "When enabled, users can configure a visual indicator to communicate messages that are important or urgent. Learn more about message priority in our documentation.", "admin.posts.postPriority.title": "Message Priority", "admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators.", @@ -4331,6 +4342,18 @@ "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", "permalink.show_dialog_warn.join": "Join", "permalink.show_dialog_warn.title": "Join private channel", + "persist_notification.confirm": "Send", + "persist_notification.confirm.description": "Mentioned recipients will be notified every {interval, plural, one {1 minute} other {{interval} minutes}} until they’ve acknowledged the message.", + "persist_notification.confirm.title": "Send persistent notifications?", + "persist_notification.dm_or_gm": "Send", + "persist_notification.dm_or_gm.description": "{username} will be notified every {interval, plural, one {1 minute} other {{interval} minutes}} until they’ve acknowledged the message.", + "persist_notification.dm_or_gm.title": "Send persistent notifications?", + "persist_notification.special_mentions.confirm": "Got it", + "persist_notification.too_few.confirm": "Got it", + "persist_notification.too_few.description": "There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.", + "persist_notification.too_many.confirm": "Got it", + "persist_notification.too_many.description": "You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.", + "persist_notification.too_many.title": "Too many recipients", "picture_selector.image.ariaLabel": "Picture selector image", "picture_selector.remove_picture": "Remove picture", "picture_selector.select_button.ariaLabel": "Select picture", @@ -4408,6 +4431,11 @@ "post_pre_header.pinned": "Pinned", "post_priority.acknowledgements.title": "Acknowledgements", "post_priority.button.acknowledge": "Acknowledge", + "post_priority.error.no_mentions": "Recipients must be @mentioned", + "post_priority.error.special_mentions": "{mention} can’t be used with persistent notifications", + "post_priority.persistent_notifications.description": "Recipients will be notified every {interval, plural, one {1 minute} other {{interval} minutes}} until they acknowledge or reply", + "post_priority.persistent_notifications.text": "Send persistent notifications", + "post_priority.persistent_notifications.tooltip": "Persistent notifications will be sent", "post_priority.picker.apply": "Apply", "post_priority.picker.cancel": "Cancel", "post_priority.picker.feedback": "Give feedback", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts index 5c5f07a315..897177a504 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts @@ -24,6 +24,7 @@ import { import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils'; import {shouldShowJoinLeaveMessages} from 'mattermost-redux/utils/post_list'; +import {isGuest} from 'mattermost-redux/utils/user_utils'; import {Channel} from '@mattermost/types/channels'; import { @@ -775,10 +776,40 @@ export function isPostAcknowledgementsEnabled(state: GlobalState) { ); } +export function getAllowPersistentNotifications(state: GlobalState) { + return ( + isPostPriorityEnabled(state) && + getConfig(state).AllowPersistentNotifications === 'true' + ); +} + +export function getPersistentNotificationMaxRecipients(state: GlobalState) { + return getConfig(state).PersistentNotificationMaxRecipients; +} + +export function getPersistentNotificationIntervalMinutes(state: GlobalState) { + return getConfig(state).PersistentNotificationIntervalMinutes; +} + +export function getAllowPersistentNotificationsForGuests(state: GlobalState) { + return ( + isPostPriorityEnabled(state) && + getConfig(state).AllowPersistentNotificationsForGuests === 'true' + ); +} + export function getPostAcknowledgements(state: GlobalState, postId: Post['id']): Record { return state.entities.posts.acknowledgements[postId]; } +export const isPersistentNotificationsEnabled = createSelector( + 'getPersistentNotificationsEnabled', + getCurrentUser, + getAllowPersistentNotifications, + getAllowPersistentNotificationsForGuests, + (user, forAll, forGuests) => (isGuest(user.roles) ? (forAll && forGuests) : forAll), +); + export function makeGetPostAcknowledgementsWithProfiles(): (state: GlobalState, postId: Post['id']) => Array<{user: UserProfile; acknowledgedAt: PostAcknowledgement['acknowledged_at']}> { return createSelector( 'makeGetPostAcknowledgementsWithProfiles', diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 37deaf2a0f..7c8a60340a 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -448,6 +448,7 @@ export const ModalIdentifiers = { MARK_ALL_THREADS_AS_READ: 'mark_all_threads_as_read_modal', DELINQUENCY_MODAL_DOWNGRADE: 'delinquency_modal_downgrade', CLOUD_LIMITS_DOWNGRADE: 'cloud_limits_downgrade', + PERSIST_NOTIFICATION_CONFIRM_MODAL: 'persist_notification_confirm_modal', AIR_GAPPED_SELF_HOSTED_PURCHASE: 'air_gapped_self_hosted_purchase', WORK_TEMPLATE: 'work_template', DOWNGRADE_MODAL: 'downgrade_modal', @@ -651,6 +652,7 @@ export const SocketEvents = { DRAFT_CREATED: 'draft_created', DRAFT_UPDATED: 'draft_updated', DRAFT_DELETED: 'draft_deleted', + PERSISTENT_NOTIFICATION_TRIGGERED: 'persistent_notification_triggered', HOSTED_CUSTOMER_SIGNUP_PROGRESS_UPDATED: 'hosted_customer_signup_progress_updated', }; diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index fea73c028d..d7dfb01d5d 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -20,6 +20,7 @@ import {get, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from 'mat import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentTeamId, getTeam} from 'mattermost-redux/selectors/entities/teams'; import {makeGetDisplayName, getCurrentUserId, getUser, UserMentionKey, getUsersByUsername} from 'mattermost-redux/selectors/entities/users'; +import {getAllGroupsForReferenceByName} from 'mattermost-redux/selectors/entities/groups'; import {memoizeResult} from 'mattermost-redux/utils/helpers'; @@ -27,7 +28,7 @@ import {Channel} from '@mattermost/types/channels'; import {ClientConfig, ClientLicense} from '@mattermost/types/config'; import {ServerError} from '@mattermost/types/errors'; import {Group} from '@mattermost/types/groups'; -import {Post} from '@mattermost/types/posts'; +import {Post, PostPriority, PostPriorityMetadata} from '@mattermost/types/posts'; import {Reaction} from '@mattermost/types/reactions'; import {UserProfile} from '@mattermost/types/users'; @@ -738,3 +739,41 @@ export function mentionsMinusSpecialMentionsInText(message: string) { return mentions; } + +function isUserProfile(entity: UserProfile | Group): entity is UserProfile { + return (entity as UserProfile).username !== undefined; +} + +export function makeGetUserOrGroupMentionCountFromMessage(): (state: GlobalState, message: Post['message']) => number { + return createSelector( + 'getUserOrGroupMentionCountFromMessage', + (_state: GlobalState, message: Post['message']) => message, + getUsersByUsername, + getAllGroupsForReferenceByName, + (message, users, groups) => { + let count = 0; + const markdownCleanedText = formatWithRenderer(message, new MentionableRenderer()); + const mentions = new Set(markdownCleanedText.match(Constants.MENTIONS_REGEX) || []); + mentions.forEach((mention) => { + const data = {...groups, ...users}; + const userOrGroup = getUserOrGroupFromMentionName(data, mention.substring(1)); + + if (userOrGroup) { + if (isUserProfile(userOrGroup)) { + count++; + } else { + count += userOrGroup.member_count; + } + } + }); + return count; + }, + ); +} + +export function hasRequestedPersistentNotifications(priority?: PostPriorityMetadata) { + return ( + priority?.priority === PostPriority.URGENT && + priority?.persistent_notifications + ); +} diff --git a/webapp/platform/components/src/legacy_generic_modal/legacy_generic_modal.tsx b/webapp/platform/components/src/legacy_generic_modal/legacy_generic_modal.tsx index 640c44c501..eaec42b8a3 100644 --- a/webapp/platform/components/src/legacy_generic_modal/legacy_generic_modal.tsx +++ b/webapp/platform/components/src/legacy_generic_modal/legacy_generic_modal.tsx @@ -40,6 +40,7 @@ export type Props = { backdropClassName?: string; tabIndex?: number; children: React.ReactNode; + autoFocusConfirmButton?: boolean; keyboardEscape?: boolean; headerInput?: React.ReactNode; bodyPadding?: boolean; @@ -130,6 +131,7 @@ export class LegacyGenericModal extends React.PureComponent { confirmButton = (