From 9399ce863795c614c8c89dd38a7dfcb0e92f7f02 Mon Sep 17 00:00:00 2001 From: Vishal Date: Thu, 18 May 2023 23:44:12 +0530 Subject: [PATCH] [MM-47751][MM-48102] MPA: Send Persistent Notifications (#21619) * MM-46410: adds urgency on mention counts We have introduced priority for posts in https://github.com/mattermost/mattermost-webapp/pull/10951. We do need to color the mention badges in the webapp with a prominent color when a mention is posted in an urgent message. A thread has urgent mentions if the root post is marked as urgent, and the replies contain mentions to the user viewing the thread. This PR adds two columns, urgentmentioncount, and isurgent, in channelmembers, and threads tables respectively. Furthermore when asking for team/thread mention counts, we also return urgent mention counts for the user. * Adds PostAcknowledgements table and apis * job init and fetch mentions * add-migrations * delete-expired * send-notifications * Fetches post priority in batches * stop-notifications * stop-notification-on-reply * MM-47750: Adds PostAcknowledgements table and apis - Adds post acknowledgement api/app/store methods to be able to save and delete post acknowledgements by users. - Adds wesbsocket events for acknowledgement created/deleted - Returns post acknowledgements in the post's metadata * add-license-check * add-pagination * delete on channel and team * validate guests * add configs * move create priority post check from app to api * Add desktop notifications * check status * use config in job * add IsUrgent check * Add last-sent-at * validate max recipients * Update lastSentAt * Validate min. recipient * send email notification only once * remove email notifications * use latest time from config to run job * Add notifications counter * publish events to mentioned users only * pickup license updates in scheduler * don't allow post owner to stop notifications * follow normal notifications behaviour * Validates persistent notifications interval * move logic of handling valid and expired posts into sql * Adds persistent notifications in the webapp --------- Co-authored-by: koox00 <3829551+koox00@users.noreply.github.com> Co-authored-by: Mattermod Co-authored-by: Mattermost Build --- .../support/server/default_config.ts | 5 + server/channels/api4/post.go | 51 ++ server/channels/api4/post_test.go | 173 +++++++ server/channels/api4/user_test.go | 115 +++-- server/channels/app/app_iface.go | 8 + server/channels/app/channel.go | 11 +- server/channels/app/notification.go | 2 +- .../app/opentracing/opentracing_layer.go | 100 ++++ server/channels/app/post.go | 41 +- server/channels/app/post_acknowledgements.go | 5 +- server/channels/app/post_metadata.go | 4 +- .../app/post_persistent_notification.go | 396 +++++++++++++++ .../app/post_persistent_notification_test.go | 237 +++++++++ server/channels/app/post_priority.go | 4 + server/channels/app/reaction.go | 8 +- server/channels/app/server.go | 7 + server/channels/app/team.go | 6 +- server/channels/app/user.go | 6 +- server/channels/db/migrations/migrations.list | 4 + ...9_create_persistent_notifications.down.sql | 1 + ...109_create_persistent_notifications.up.sql | 8 + ...9_create_persistent_notifications.down.sql | 1 + ...109_create_persistent_notifications.up.sql | 7 + .../scheduler.go | 28 ++ .../post_persistent_notifications/worker.go | 30 ++ .../opentracinglayer/opentracinglayer.go | 221 +++++++-- .../channels/store/retrylayer/retrylayer.go | 242 ++++++++-- .../store/retrylayer/retrylayer_test.go | 1 + .../post_persistent_notification_store.go | 193 ++++++++ ...post_persistent_notification_store_test.go | 14 + server/channels/store/sqlstore/post_store.go | 18 + server/channels/store/sqlstore/store.go | 90 ++-- server/channels/store/store.go | 11 + .../mocks/PostPersistentNotificationStore.go | 152 ++++++ .../channels/store/storetest/mocks/Store.go | 16 + .../post_persistent_notification_store.go | 452 ++++++++++++++++++ server/channels/store/storetest/store.go | 91 ++-- .../channels/store/timerlayer/timerlayer.go | 209 ++++++-- server/channels/utils/utils.go | 8 +- server/config/client.go | 5 + server/i18n/en.json | 48 ++ .../platform/services/telemetry/telemetry.go | 5 + server/public/model/config.go | 35 ++ server/public/model/job.go | 1 + server/public/model/post.go | 35 +- server/public/model/websocket_message.go | 1 + .../src/actions/websocket_actions.jsx | 13 +- .../admin_console/admin_definition.jsx | 127 +++++ .../advanced_create_post.test.jsx.snap | 208 ++------ .../advanced_create_post.tsx | 235 ++++----- .../advanced_create_post/priority_labels.tsx | 192 ++++++++ .../advanced_text_editor.scss | 44 -- .../advanced_text_editor.tsx | 4 +- .../__snapshots__/channel_draft.test.tsx.snap | 2 + .../channel_draft/channel_draft.test.tsx | 1 + .../drafts/channel_draft/channel_draft.tsx | 40 +- .../components/drafts/channel_draft/index.ts | 2 + .../__snapshots__/panel_body.test.tsx.snap | 285 +++++++---- .../components/drafts/panel/panel_body.scss | 19 - .../drafts/panel/panel_body.test.tsx | 2 +- .../components/drafts/panel/panel_body.tsx | 32 +- .../persist_notification_confirm_modal.tsx | 165 +++++++ .../post_priority/error_messages.tsx | 44 ++ .../post_priority/post_priority_badge.tsx | 2 +- .../post_priority/post_priority_picker.tsx | 140 +++--- .../post_priority_picker_item.tsx | 5 +- .../post_priority_picker_overlay.tsx | 157 ++++-- .../post_acknowledgements.scss | 2 +- webapp/channels/src/i18n/en.json | 28 ++ .../src/selectors/entities/posts.ts | 31 ++ webapp/channels/src/utils/constants.tsx | 2 + webapp/channels/src/utils/post_utils.ts | 41 +- .../legacy_generic_modal.tsx | 2 + webapp/platform/types/src/config.ts | 9 + 74 files changed, 4052 insertions(+), 888 deletions(-) create mode 100644 server/channels/app/post_persistent_notification.go create mode 100644 server/channels/app/post_persistent_notification_test.go create mode 100644 server/channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql create mode 100644 server/channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql create mode 100644 server/channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql create mode 100644 server/channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql create mode 100644 server/channels/jobs/post_persistent_notifications/scheduler.go create mode 100644 server/channels/jobs/post_persistent_notifications/worker.go create mode 100644 server/channels/store/sqlstore/post_persistent_notification_store.go create mode 100644 server/channels/store/sqlstore/post_persistent_notification_store_test.go create mode 100644 server/channels/store/storetest/mocks/PostPersistentNotificationStore.go create mode 100644 server/channels/store/storetest/post_persistent_notification_store.go create mode 100644 webapp/channels/src/components/advanced_create_post/priority_labels.tsx create mode 100644 webapp/channels/src/components/persist_notification_confirm_modal.tsx create mode 100644 webapp/channels/src/components/post_priority/error_messages.tsx 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 = (