From 118d0346ee101a6d158cc71620f7aa096c224635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 8 Nov 2024 13:57:06 +0100 Subject: [PATCH] Add test notification tool (#28334) * Add test notification tool * Add frontend styles * Remove option from admin view * Refactor create post and add translations * Fix several CI errors * Fix API and frontend snapshots * Refactor trailing and leading icon on buttons * Add different button states * i18n-extract * Fix wrong text * Add tests * Fix wrong string * Fix test * feat: E2E send test notifications (#28371) * Refactor send desktop notification * Address rest of the feedback * Fix tests * Add correct link * Fix test --------- Co-authored-by: Mattermost Build Co-authored-by: yasserfaraazkhan --- api/v4/source/system.yaml | 24 ++ .../trigger_browser_notification_spec.ts | 128 ++++++++ e2e-tests/cypress/tests/support/index.js | 1 + .../tests/support/notification_commands.ts | 38 +++ server/channels/api4/system.go | 11 + server/channels/app/app_iface.go | 1 + server/channels/app/notification_push.go | 4 + server/channels/app/notification_push_test.go | 33 +++ .../app/opentracing/opentracing_layer.go | 22 ++ server/channels/app/post.go | 35 +++ server/channels/app/post_test.go | 41 +++ server/i18n/en.json | 20 ++ server/public/model/client4.go | 13 + server/public/model/post.go | 7 +- server/public/model/post_test.go | 11 +- .../src/actions/notification_actions.test.js | 28 ++ .../src/actions/notification_actions.tsx | 19 +- .../common/hooks/use_external_link.test.ts | 132 +++++++++ .../common/hooks/use_external_link.ts | 47 +++ .../__snapshots__/external_link.test.tsx.snap | 2 +- .../external_link/external_link.test.tsx | 4 +- .../src/components/external_link/index.tsx | 43 +-- .../marketplace_item_plugin.test.tsx | 1 + .../src/components/section_notice/types.d.ts | 10 + ...end_test_notification_notice.test.tsx.snap | 53 ++++ .../user_settings_notifications.test.tsx.snap | 276 +++++++++++++++++- .../send_test_notification_notice.test.tsx | 67 +++++ .../send_test_notification_notice.tsx | 138 +++++++++ .../user_settings_notifications.tsx | 3 +- webapp/channels/src/i18n/en.json | 7 + webapp/platform/client/src/client4.ts | 7 + 31 files changed, 1170 insertions(+), 56 deletions(-) create mode 100644 e2e-tests/cypress/tests/integration/channels/test_notification/trigger_browser_notification_spec.ts create mode 100644 e2e-tests/cypress/tests/support/notification_commands.ts create mode 100644 webapp/channels/src/components/common/hooks/use_external_link.test.ts create mode 100644 webapp/channels/src/components/common/hooks/use_external_link.ts create mode 100644 webapp/channels/src/components/section_notice/types.d.ts create mode 100644 webapp/channels/src/components/user_settings/notifications/__snapshots__/send_test_notification_notice.test.tsx.snap create mode 100644 webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.test.tsx create mode 100644 webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.tsx diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 976220f8ebc..c42c418dc61 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -222,6 +222,30 @@ $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalServerError" + /api/v4/notifications/test: + post: + tags: + - system + summary: Send a test notification + description: > + Send a test notification to make sure you have your notification settings + configured correctly. + + ##### Permissions + + Must be logged in. + operationId: TestNotification + responses: + "200": + description: Notification successfully sent + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" /api/v4/site_url/test: post: tags: diff --git a/e2e-tests/cypress/tests/integration/channels/test_notification/trigger_browser_notification_spec.ts b/e2e-tests/cypress/tests/integration/channels/test_notification/trigger_browser_notification_spec.ts new file mode 100644 index 00000000000..7bc118dcf66 --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/test_notification/trigger_browser_notification_spec.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Stage: @prod +// Group: @channels @notification + +describe('Verify users can receive notification on browser', () => { + let offTopic: string; + const notificationMessage = 'If you received this test notification, it worked!'; + before(() => { + cy.apiInitSetup({userPrefix: 'other', loginAfter: true}).then(({offTopicUrl}) => { + offTopic = offTopicUrl; + }); + }); + + it('MM-T5631_1 should be able to receive notification when notifications are enabled on the browser', () => { + cy.visit(offTopic); + cy.stubNotificationPermission('granted'); + cy.get('#CustomizeYourExperienceTour > button').click(); + triggertestNotification(); + cy.get('@notificationStub').should('be.called'); + cy.get('@notificationStub').should((stub) => { + expect(stub).to.have.been.calledWithMatch( + 'Direct Message', + Cypress.sinon.match({ + body: '@@system-bot: If you received this test notification, it worked!', + tag: '@@system-bot: If you received this test notification, it worked!', + requireInteraction: false, + silent: false, + }), + ); + }); + cy.get('#accountSettingsHeader button.close').click(); + + // * Verify user still recieves a message from system bot + cy.verifySystemBotMessageRecieved(notificationMessage); + }); + + it('MM-T5631_2 should not be able to receive notification when notifications are denied on the browser', () => { + cy.visit(offTopic); + cy.stubNotificationPermission('denied'); + cy.get('#CustomizeYourExperienceTour > button').click(); + triggertestNotification(); + + // * Assert that the Notification constructor was not called + cy.get('@notificationStub').should('not.be.called'); + cy.get('#accountSettingsHeader button.close').click(); + + // * Verify user still recieves a message from system bot + cy.verifySystemBotMessageRecieved(notificationMessage); + }); + + it('MM-T5631_3 should not trigger notification when permission is default (no decision made)', () => { + cy.visit(offTopic); + cy.stubNotificationPermission('default'); + cy.get('#CustomizeYourExperienceTour > button').click(); + triggertestNotification(); + + // * Assert that the Notification constructor was not called + cy.get('@notificationStub').should('not.be.called'); + cy.get('#accountSettingsHeader button.close').click(); + + // * Verify user still recieves a message from system bot + cy.verifySystemBotMessageRecieved(notificationMessage); + }); + + // Simulating macOS Focus Mode by suppressing the Notification constructor entirely + it('MM-T5631_4 should not show notification when Focus Mode is enabled (simulating no notification pop-up)', () => { + cy.visit(offTopic); + cy.stubNotificationPermission('granted'); + + cy.window().then((win) => { + win.Notification = function() { + // Do nothing to simulate Focus Mode + }; + + cy.stub(win, 'Notification').as('notificationStub').callsFake(() => { + return null; // Prevent the notification from being created + }); + }); + cy.get('#CustomizeYourExperienceTour > button').click(); + triggertestNotification(); + + // * Assert that the Notification constructor was not called in Focus Mode + cy.get('@notificationStub').should('not.be.called'); + cy.get('#accountSettingsHeader button.close').click(); + + // * Verify user still recieves a message from system bot + cy.verifySystemBotMessageRecieved(notificationMessage); + }); + + it('should still recieve a test notification when user has set Global and Channel Notification preference to Nothing', () => { + cy.visit(offTopic); + cy.stubNotificationPermission('default'); + + // # Mute Channel + cy.get('#channelHeaderTitle > span').click(); + cy.get('#channelToggleMuteChannel').should('have.text', 'Mute Channel').click(); + cy.get('#toggleMute').should('be.visible'); + + // # Set Desktop Notification preference to Nothing + cy.get('#CustomizeYourExperienceTour > button').click(); + cy.get('#desktopAndMobileTitle').click(); + cy.get('#sendDesktopNotificationsSection input[type=radio]').last().check(); + cy.get('#saveSetting').click(); + cy.wait(500); + triggertestNotification(); + + // * Assert that the Notification constructor was not called + cy.get('@notificationStub').should('not.be.called'); + cy.get('#accountSettingsHeader button.close').click(); + + // * Verify user still recieves a message from system bot + cy.verifySystemBotMessageRecieved(notificationMessage); + }); +}); + +function triggertestNotification() { + cy.get('.sectionNoticeContent').scrollIntoView().should('be.visible'); + cy.get('.btn-tertiary').should('be.visible').should('have.text', 'Troubleshooting docs'); + cy.get('.btn-primary').should('be.visible').should('have.text', 'Send a test notification').click(); +} diff --git a/e2e-tests/cypress/tests/support/index.js b/e2e-tests/cypress/tests/support/index.js index 4c52ecbde71..832b442ca36 100644 --- a/e2e-tests/cypress/tests/support/index.js +++ b/e2e-tests/cypress/tests/support/index.js @@ -28,6 +28,7 @@ import './fetch_commands'; import './keycloak_commands'; import './ldap_commands'; import './ldap_server_commands'; +import './notification_commands'; import './okta_commands'; import './saml_commands'; import './shell'; diff --git a/e2e-tests/cypress/tests/support/notification_commands.ts b/e2e-tests/cypress/tests/support/notification_commands.ts new file mode 100644 index 00000000000..a4f861f6dd0 --- /dev/null +++ b/e2e-tests/cypress/tests/support/notification_commands.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as TIMEOUTS from '../fixtures/timeouts'; + +/** + * permission can be 'granted', 'denied', or 'default' + */ +function stubNotificationPermission(permission: string) { + cy.window().then((win) => { + cy.stub(win.Notification, 'permission').value(permission); + cy.stub(win.Notification, 'requestPermission').resolves(permission); + cy.stub(win, 'Notification').as('notificationStub').callsFake(() => { + return { + onclick: cy.stub().as('notificationOnClick'), + onerror: cy.stub().as('notificationOnError'), + }; + }); + }); +} + +/** + * Verify the system bot message was received + */ +function notificationMessage(notificationMessage: string) { + // * Assert the unread count is correct + cy.get('.SidebarLink:contains(system-bot)').find('#unreadMentions').as('unreadCount').should('be.visible').should('have.text', '1'); + cy.get('.SidebarLink:contains(system-bot)').find('.Avatar').should('exist').click().wait(TIMEOUTS.HALF_SEC); + cy.get('@unreadCount').should('not.exist'); + + // * Assert the notification message + cy.getLastPostId().then((postId) => { + cy.get(`#postMessageText_${postId}`).scrollIntoView().should('be.visible').should('have.text', notificationMessage); + }); +} + +Cypress.Commands.add('stubNotificationPermission', stubNotificationPermission); +Cypress.Commands.add('verifySystemBotMessageRecieved', notificationMessage); diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index fb7a219aa9f..2488557be2d 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -45,6 +45,7 @@ func (api *API) InitSystem() { api.BaseRoutes.System.Handle("/timezones", api.APISessionRequired(getSupportedTimezones)).Methods(http.MethodGet) api.BaseRoutes.APIRoot.Handle("/audits", api.APISessionRequired(getAudits)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/notifications/test", api.APISessionRequired(testNotifications)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/email/test", api.APISessionRequired(testEmail)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/site_url/test", api.APISessionRequired(testSiteURL)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/file/s3_test", api.APISessionRequired(testS3)).Methods(http.MethodPost) @@ -232,6 +233,16 @@ func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) { } } +func testNotifications(c *Context, w http.ResponseWriter, r *http.Request) { + _, err := c.App.SendTestMessage(c.AppContext, c.AppContext.Session().UserId) + if err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + func testEmail(c *Context, w http.ResponseWriter, r *http.Request) { var cfg *model.Config err := json.NewDecoder(r.Body).Decode(&cfg) diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 830689a372b..baca433b63f 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -1103,6 +1103,7 @@ type AppIface interface { SendPasswordReset(rctx request.CTX, email string, siteURL string) (bool, *model.AppError) SendPersistentNotifications() error SendReportToUser(rctx request.CTX, job *model.Job, format string) *model.AppError + SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) SendTestPushNotification(deviceID string) string ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) SessionHasPermissionTo(session model.Session, permission *model.Permission) bool diff --git a/server/channels/app/notification_push.go b/server/channels/app/notification_push.go index c15c45a0db3..1a3b9137f67 100644 --- a/server/channels/app/notification_push.go +++ b/server/channels/app/notification_push.go @@ -599,6 +599,10 @@ func (a *App) getMobileAppSessions(userID string) ([]*model.Session, *model.AppE } func (a *App) ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post, isGM bool) bool { + if prop := post.GetProp(model.PostPropsForceNotification); prop != nil && prop != "" { + return true + } + if notifyPropsAllowedReason := DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned, isGM); notifyPropsAllowedReason != "" { a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypePush, notifyPropsAllowedReason, model.NotificationNoPlatform) a.NotificationsLog().Debug("Notification not sent - notify props", diff --git a/server/channels/app/notification_push_test.go b/server/channels/app/notification_push_test.go index b9db720af07..d3ddb9c3792 100644 --- a/server/channels/app/notification_push_test.go +++ b/server/channels/app/notification_push_test.go @@ -1103,6 +1103,39 @@ func TestSendPushNotifications(t *testing.T) { }) } +func TestShouldSendPushNotifications(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + t.Run("should return true if forced", func(t *testing.T) { + user := &model.User{Id: model.NewId(), Email: "unit@test.com", NotifyProps: make(map[string]string)} + user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone + + post := &model.Post{UserId: user.Id, ChannelId: model.NewId()} + post.AddProp(model.PostPropsForceNotification, model.NewId()) + + channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention} + + status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId} + + result := th.App.ShouldSendPushNotification(user, channelNotifyProps, false, status, post, false) + assert.True(t, result) + }) + + t.Run("should return false if force undefined", func(t *testing.T) { + user := &model.User{Id: model.NewId(), Email: "unit@test.com", NotifyProps: make(map[string]string)} + user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone + + post := &model.Post{UserId: user.Id, ChannelId: model.NewId()} + + channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention} + + status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId} + + result := th.App.ShouldSendPushNotification(user, channelNotifyProps, false, status, post, false) + assert.False(t, result) + }) +} + // testPushNotificationHandler is an HTTP handler to record push notifications // being sent from the client. // It records the number of requests sent to it, and stores all the requests diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 1135303f689..a0a758e30f1 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -16398,6 +16398,28 @@ func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*mode return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestMessage") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SendTestMessage(c, userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestPushNotification") diff --git a/server/channels/app/post.go b/server/channels/app/post.go index c45846aac4b..86bbfdd6002 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -242,6 +242,10 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel post.AddProp(model.PostPropsFromBot, "true") } + if flags.ForceNotification { + post.AddProp(model.PostPropsForceNotification, model.NewId()) + } + if c.Session().IsOAuth { post.AddProp(model.PostPropsFromOAuthApp, "true") } @@ -2722,3 +2726,34 @@ func (a *App) CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteBy return nil } + +func (a *App) SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) { + bot, err := a.GetSystemBot(c) + if err != nil { + return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_bot", nil, "", http.StatusInternalServerError).Wrap(err) + } + + channel, err := a.GetOrCreateDirectChannel(c, userID, bot.UserId) + if err != nil { + return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_channel", nil, "", http.StatusInternalServerError).Wrap(err) + } + + user, err := a.GetUser(userID) + if err != nil { + return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_user", nil, "", http.StatusInternalServerError).Wrap(err) + } + T := i18n.GetUserTranslations(user.Locale) + post := &model.Post{ + ChannelId: channel.Id, + Message: T("app.notifications.send_test_message.message_body"), + Type: model.PostTypeDefault, + UserId: bot.UserId, + } + + post, err = a.CreatePost(c, post, channel, model.CreatePostFlags{ForceNotification: true}) + if err != nil { + return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.create_post", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return post, nil +} diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 629624f5337..b5bac67c10f 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -1178,6 +1178,37 @@ func TestCreatePost(t *testing.T) { wg.Wait() }) + + t.Run("should sanitize the force notifications prop if the flag is not set", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.AddUserToChannel(th.BasicUser, th.BasicChannel) + + postToCreate := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "hello world", + UserId: th.BasicUser.Id, + } + postToCreate.AddProp(model.PostPropsForceNotification, model.NewId()) + createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, err) + require.Empty(t, createdPost.GetProp(model.PostPropsForceNotification)) + }) + + t.Run("should add the force notifications prop if the flag is set", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.AddUserToChannel(th.BasicUser, th.BasicChannel) + + postToCreate := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "hello world", + UserId: th.BasicUser.Id, + } + createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{ForceNotification: true}) + require.Nil(t, err) + require.NotEmpty(t, createdPost.GetProp(model.PostPropsForceNotification)) + }) } func TestPatchPost(t *testing.T) { @@ -3739,3 +3770,13 @@ func TestPermanentDeletePost(t *testing.T) { assert.Len(t, infos, 0) }) } + +func TestSendTestMessage(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + t.Run("Should create the post with the correct prop", func(t *testing.T) { + post, result := th.App.SendTestMessage(th.Context, th.BasicUser.Id) + assert.Nil(t, result) + assert.NotEmpty(t, post.GetProp(model.PostPropsForceNotification)) + }) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index d38819c53fa..7d502076e91 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5938,6 +5938,26 @@ "id": "app.notification.subject.notification.full", "translation": "[{{ .SiteName }}] Notification in {{ .TeamName}} on {{.Month}} {{.Day}}, {{.Year}}" }, + { + "id": "app.notifications.send_test_message.errors.create_post", + "translation": "The post cannot be created" + }, + { + "id": "app.notifications.send_test_message.errors.no_bot", + "translation": "Cannot get the system bot" + }, + { + "id": "app.notifications.send_test_message.errors.no_channel", + "translation": "Cannot get the system bot direct message" + }, + { + "id": "app.notifications.send_test_message.errors.no_user", + "translation": "Cannot get the user" + }, + { + "id": "app.notifications.send_test_message.message_body", + "translation": "If you received this test notification, it worked!" + }, { "id": "app.notify_admin.save.app_error", "translation": "Unable to save notify data." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 9b378151849..bc54ff2c5a3 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -344,6 +344,10 @@ func (c *Client4) testEmailRoute() string { return "/email/test" } +func (c *Client4) testNotificationRoute() string { + return "/notifications/test" +} + func (c *Client4) usageRoute() string { return "/usage" } @@ -4820,6 +4824,15 @@ func (c *Client4) TestEmail(ctx context.Context, config *Config) (*Response, err return BuildResponse(r), nil } +func (c *Client4) TestNotifications(ctx context.Context) (*Response, error) { + r, err := c.DoAPIPost(ctx, c.testNotificationRoute(), "") + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + // TestSiteURL will test the validity of a site URL. func (c *Client4) TestSiteURL(ctx context.Context, siteURL string) (*Response, error) { requestBody := make(map[string]string) diff --git a/server/public/model/post.go b/server/public/model/post.go index 7063d22ea01..b69e3c91e81 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -75,6 +75,7 @@ const ( PostPropsMentionHighlightDisabled = "mentionHighlightDisabled" PostPropsGroupHighlightDisabled = "disable_group_highlight" PostPropsPreviewedPost = "previewed_post" + PostPropsForceNotification = "force_notification" PostPriorityUrgent = "urgent" PostPropsRequestedAck = "requested_ack" @@ -339,8 +340,9 @@ func (o *Post) EncodeJSON(w io.Writer) error { } type CreatePostFlags struct { - TriggerWebhooks bool - SetOnline bool + TriggerWebhooks bool + SetOnline bool + ForceNotification bool } type GetPostsSinceOptions struct { @@ -500,6 +502,7 @@ func (o *Post) SanitizeProps() { } membersToSanitize := []string{ PropsAddChannelMember, + PostPropsForceNotification, } for _, member := range membersToSanitize { diff --git a/server/public/model/post_test.go b/server/public/model/post_test.go index 02760c215ad..9dd19a05616 100644 --- a/server/public/model/post_test.go +++ b/server/public/model/post_test.go @@ -115,29 +115,34 @@ func TestPostSanitizeProps(t *testing.T) { post1.SanitizeProps() require.Nil(t, post1.GetProp(PropsAddChannelMember)) + require.Nil(t, post1.GetProp(PostPropsForceNotification)) post2 := &Post{ Message: "test", Props: StringInterface{ - PropsAddChannelMember: "test", + PropsAddChannelMember: "test", + PostPropsForceNotification: "test", }, } post2.SanitizeProps() require.Nil(t, post2.GetProp(PropsAddChannelMember)) + require.Nil(t, post2.GetProp(PostPropsForceNotification)) post3 := &Post{ Message: "test", Props: StringInterface{ - PropsAddChannelMember: "no good", - "attachments": "good", + PropsAddChannelMember: "no good", + PostPropsForceNotification: "no good", + "attachments": "good", }, } post3.SanitizeProps() require.Nil(t, post3.GetProp(PropsAddChannelMember)) + require.Nil(t, post3.GetProp(PostPropsForceNotification)) require.NotNil(t, post3.GetProp("attachments")) } diff --git a/webapp/channels/src/actions/notification_actions.test.js b/webapp/channels/src/actions/notification_actions.test.js index 727e3825988..708461501ee 100644 --- a/webapp/channels/src/actions/notification_actions.test.js +++ b/webapp/channels/src/actions/notification_actions.test.js @@ -331,6 +331,34 @@ describe('notification_actions', () => { }); }); + test('should notify for forced notification posts on muted channels', () => { + const store = testConfigureStore(baseState); + const newPost = { + ...post, + props: { + ...post.props, + force_notification: 'test', + }, + }; + newPost.channel_id = 'muted_channel_id'; + + const newMsgProps = { + post: JSON.stringify(newPost), + channel_display_name: 'Muted Channel', + team_id: 'team_id', + }; + return store.dispatch(sendDesktopNotification(newPost, newMsgProps)).then((result) => { + expect(result).toEqual({data: {status: 'success'}}); + expect(spy).toHaveBeenCalledWith({ + body: '@username: Where is Jessica Hyde?', + requireInteraction: false, + silent: false, + title: 'Muted Channel', + onClick: expect.any(Function), + }); + }); + }); + test.each([ UserStatuses.DND, UserStatuses.OUT_OF_OFFICE, diff --git a/webapp/channels/src/actions/notification_actions.tsx b/webapp/channels/src/actions/notification_actions.tsx index 6087c780e97..c9e592f233b 100644 --- a/webapp/channels/src/actions/notification_actions.tsx +++ b/webapp/channels/src/actions/notification_actions.tsx @@ -8,6 +8,7 @@ import type {Post} from '@mattermost/types/posts'; import type {UserProfile} from '@mattermost/types/users'; import {logError} from 'mattermost-redux/actions/errors'; +import {Client4} from 'mattermost-redux/client'; import {getCurrentChannel, getMyChannelMember, makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import { @@ -117,6 +118,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp const user = getCurrentUser(state); const member = getMyChannelMember(state, post.channel_id); const isCrtReply = isCollapsedThreadsEnabled(state) && post.root_id !== ''; + const forceNotification = Boolean(post.props?.force_notification); const skipNotificationReason = shouldSkipNotification( state, @@ -125,6 +127,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp user, channel, member, + forceNotification, isCrtReply, ); if (skipNotificationReason) { @@ -156,7 +159,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp const argsAfterHooks = hookResult.data!; - if (!argsAfterHooks.notify) { + if (!argsAfterHooks.notify && !forceNotification) { return {data: {status: 'not_sent', reason: 'desktop_notification_hook', data: String(hookResult)}}; } @@ -254,6 +257,7 @@ function shouldSkipNotification( user: UserProfile, channel: Pick, member: ChannelMembership | undefined, + skipChecks: boolean, isCrtReply: boolean, ) { const currentUserId = getCurrentUserId(state); @@ -269,6 +273,10 @@ function shouldSkipNotification( return {status: 'error', reason: 'no_member'}; } + if (skipChecks) { + return undefined; + } + if (isChannelMuted(member)) { return {status: 'not_sent', reason: 'channel_muted'}; } @@ -428,3 +436,12 @@ export function notifyMe(title: string, body: string, channelId: string, teamId: } }; } + +export const sendTestNotification = async () => { + try { + const result = await Client4.sendTestNotificaiton(); + return result; + } catch (error) { + return error; + } +}; diff --git a/webapp/channels/src/components/common/hooks/use_external_link.test.ts b/webapp/channels/src/components/common/hooks/use_external_link.test.ts new file mode 100644 index 00000000000..2d1447ab51a --- /dev/null +++ b/webapp/channels/src/components/common/hooks/use_external_link.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {DeepPartial} from '@mattermost/types/utilities'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; + +import type {GlobalState} from 'types/store'; + +import {useExternalLink} from './use_external_link'; + +const baseCurrentUserId = 'someUserId'; +const baseTelemetryId = 'someTelemetryId'; + +function getBaseState(): DeepPartial { + return { + entities: { + users: { + currentUserId: baseCurrentUserId, + }, + general: { + config: { + TelemetryId: baseTelemetryId, + }, + license: { + Cloud: 'true', + }, + }, + }, + }; +} + +describe('useExternalLink', () => { + it('keep non mattermost links untouched', () => { + const url = 'https://www.someLink.com/something?query1=2#anchor'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, 'some location', {utm_source: 'something'}), getBaseState()); + expect(href).toEqual(url); + expect(queryParams).toEqual({}); + }); + + it('all base queries are set correctly', () => { + const url = 'https://www.mattermost.com/some/url'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('utm_source')).toBe('mattermost'); + expect(parsedLink.searchParams.get('utm_medium')).toBe('in-product-cloud'); + expect(parsedLink.searchParams.get('utm_content')).toBe(''); + expect(parsedLink.searchParams.get('uid')).toBe(baseCurrentUserId); + expect(parsedLink.searchParams.get('sid')).toBe(baseTelemetryId); + expect(queryParams.utm_source).toBe('mattermost'); + expect(queryParams.utm_medium).toBe('in-product-cloud'); + expect(queryParams.utm_content).toBe(''); + expect(queryParams.uid).toBe(baseCurrentUserId); + expect(queryParams.sid).toBe(baseTelemetryId); + expect(href.split('?')[0]).toBe(url); + }); + + it('provided location is added to the params', () => { + const url = 'https://www.mattermost.com/some/url'; + const location = 'someLocation'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('utm_content')).toBe(location); + expect(queryParams.utm_content).toBe(location); + }); + + it('non cloud environments set the proper utm medium', () => { + const url = 'https://www.mattermost.com/some/url'; + const state = getBaseState(); + state.entities!.general!.license!.Cloud = 'false'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), state); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('utm_medium')).toBe('in-product'); + expect(queryParams.utm_medium).toBe('in-product'); + }); + + it('keep existing query parameters untouched', () => { + const url = 'https://www.mattermost.com/some/url?myParameter=true'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('myParameter')).toBe('true'); + expect(queryParams.myParameter).toBe('true'); + }); + + it('keep anchors untouched', () => { + const url = 'https://www.mattermost.com/some/url?myParameter=true#myAnchor'; + const {result: {current: [href]}} = renderHookWithContext(() => useExternalLink(url), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.hash).toBe('#myAnchor'); + }); + + it('overwriting params gets preference over default params', () => { + const url = 'https://www.mattermost.com/some/url'; + const location = 'someLocation'; + const expectedContent = 'someOtherLocation'; + const expectedSource = 'someOtherSource'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location, {utm_content: expectedContent, utm_source: expectedSource}), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('utm_content')).toBe(expectedContent); + expect(queryParams.utm_content).toBe(expectedContent); + expect(parsedLink.searchParams.get('utm_source')).toBe(expectedSource); + expect(queryParams.utm_source).toBe(expectedSource); + }); + + it('existing params gets preference over default and overwritten params', () => { + const location = 'someLocation'; + const overwrittenContent = 'someOtherLocation'; + const overwrittenSource = 'someOtherSource'; + const expectedContent = 'differentLocation'; + const expectedSource = 'differentSource'; + const url = `https://www.mattermost.com/some/url?utm_content=${expectedContent}&utm_source=${expectedSource}`; + + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location, {utm_content: overwrittenContent, utm_source: overwrittenSource}), getBaseState()); + const parsedLink = new URL(href); + expect(parsedLink.searchParams.get('utm_content')).toBe(expectedContent); + expect(queryParams.utm_content).toBe(expectedContent); + expect(parsedLink.searchParams.get('utm_source')).toBe(expectedSource); + expect(queryParams.utm_source).toBe(expectedSource); + }); + + it('results are stable between re-renders', () => { + const url = 'https://www.mattermost.com/some/url'; + const overwriteQueryParams = {utm_content: 'overwrittenContent', utm_source: 'overwrittenSource'}; + + const {result, rerender} = renderHookWithContext(() => useExternalLink(url, 'someLocation', overwriteQueryParams), getBaseState()); + const [firstHref, firstParams] = result.current; + rerender(); + const [secondHref, secondParams] = result.current; + expect(firstHref).toBe(secondHref); + expect(firstParams).toBe(secondParams); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/use_external_link.ts b/webapp/channels/src/components/common/hooks/use_external_link.ts new file mode 100644 index 00000000000..1fc989e233b --- /dev/null +++ b/webapp/channels/src/components/common/hooks/use_external_link.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; +import {useSelector} from 'react-redux'; + +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import type {GlobalState} from 'types/store'; + +export type ExternalLinkQueryParams = { + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_content?: string; + userId?: string; +} + +export function useExternalLink(href: string, location: string = '', overwriteQueryParams: ExternalLinkQueryParams = {}): [string, Record] { + const userId = useSelector(getCurrentUserId); + const telemetryId = useSelector((state: GlobalState) => getConfig(state).TelemetryId || ''); + const isCloud = useSelector((state: GlobalState) => getLicense(state).Cloud === 'true'); + + return useMemo(() => { + if (!href?.includes('mattermost.com')) { + return [href, {}]; + } + + const parsedUrl = new URL(href); + + const existingURLSearchParams = parsedUrl.searchParams; + const existingQueryParamsObj = Object.fromEntries(existingURLSearchParams.entries()); + const queryParams = { + utm_source: 'mattermost', + utm_medium: isCloud ? 'in-product-cloud' : 'in-product', + utm_content: location, + uid: userId, + sid: telemetryId, + ...overwriteQueryParams, + ...existingQueryParamsObj, + }; + parsedUrl.search = new URLSearchParams(queryParams).toString(); + + return [parsedUrl.toString(), queryParams]; + }, [href, isCloud, location, overwriteQueryParams, telemetryId, userId]); +} diff --git a/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap b/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap index f6c1bff5cad..0e84096d24a 100644 --- a/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap +++ b/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap @@ -18,7 +18,7 @@ exports[`components/external_link should match snapshot 1`] = ` location="test" > { expect(screen.queryByText('Click Me')).toHaveAttribute( 'href', - 'https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=&test=true', + 'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=&test=true', ); }); @@ -191,7 +191,7 @@ describe('components/external_link', () => { expect(screen.queryByText('Click Me')).toHaveAttribute( 'href', - 'https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=#desktop', + 'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=#desktop', ); }); }); diff --git a/webapp/channels/src/components/external_link/index.tsx b/webapp/channels/src/components/external_link/index.tsx index eea5d8b8bf4..86d5b036d79 100644 --- a/webapp/channels/src/components/external_link/index.tsx +++ b/webapp/channels/src/components/external_link/index.tsx @@ -4,20 +4,11 @@ /* eslint-disable @mattermost/use-external-link */ import React, {forwardRef} from 'react'; -import {useSelector} from 'react-redux'; - -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {trackEvent} from 'actions/telemetry_actions'; -type ExternalLinkQueryParams = { - utm_source?: string; - utm_medium?: string; - utm_campaign?: string; - utm_content?: string; - userId?: string; -} +import type {ExternalLinkQueryParams} from 'components/common/hooks/use_external_link'; +import {useExternalLink} from 'components/common/hooks/use_external_link'; type Props = React.AnchorHTMLAttributes & { href: string; @@ -30,35 +21,7 @@ type Props = React.AnchorHTMLAttributes & { } const ExternalLink = forwardRef((props, ref) => { - const userId = useSelector(getCurrentUserId); - const config = useSelector(getConfig); - const license = useSelector(getLicense); - let href = props.href; - let queryParams = {}; - if (href?.includes('mattermost.com')) { - const existingURLSearchParams = new URL(href).searchParams; - const existingQueryParamsObj = Object.fromEntries(existingURLSearchParams.entries()); - queryParams = { - utm_source: 'mattermost', - utm_medium: license.Cloud === 'true' ? 'in-product-cloud' : 'in-product', - utm_content: props.location || '', - uid: userId, - sid: config.TelemetryId || '', - ...props.queryParams, - ...existingQueryParamsObj, - }; - const queryString = new URLSearchParams(queryParams).toString(); - - if (Object.keys(existingQueryParamsObj).length) { - // If the href already has query params, remove them before adding them back with the addition of the new ones - href = href?.split('?')[0]; - } - const anchor = new URL(href).hash; - if (anchor) { - href = href.replace(anchor, ''); - } - href = `${href}?${queryString}${anchor ?? ''}`; - } + const [href, queryParams] = useExternalLink(props.href, props.location, props.queryParams); const handleClick = (e: React.MouseEvent) => { trackEvent('link_out', 'click_external_link', queryParams); diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx index a02b13e0a40..2e25c2e487f 100644 --- a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx +++ b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/marketplace_item_plugin.test.tsx @@ -93,6 +93,7 @@ describe('components/MarketplaceItemPlugin', () => { entities: { general: { config: {}, + license: {}, }, users: { currentUserId: 'currentUserId', diff --git a/webapp/channels/src/components/section_notice/types.d.ts b/webapp/channels/src/components/section_notice/types.d.ts new file mode 100644 index 00000000000..a107ce4ff75 --- /dev/null +++ b/webapp/channels/src/components/section_notice/types.d.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type SectionNoticeButton = { + onClick: () => void; + text: string; + trailingIcon?: string; + leadingIcon?: string; + loading?: boolean; +} diff --git a/webapp/channels/src/components/user_settings/notifications/__snapshots__/send_test_notification_notice.test.tsx.snap b/webapp/channels/src/components/user_settings/notifications/__snapshots__/send_test_notification_notice.test.tsx.snap new file mode 100644 index 00000000000..0dfa0ed4f66 --- /dev/null +++ b/webapp/channels/src/components/user_settings/notifications/__snapshots__/send_test_notification_notice.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/user_settings/notifications/send_test_notification_notice should match snapshot 1`] = ` +
+
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
+
+`; diff --git a/webapp/channels/src/components/user_settings/notifications/__snapshots__/user_settings_notifications.test.tsx.snap b/webapp/channels/src/components/user_settings/notifications/__snapshots__/user_settings_notifications.test.tsx.snap index 24c742d3747..a1006af150c 100644 --- a/webapp/channels/src/components/user_settings/notifications/__snapshots__/user_settings_notifications.test.tsx.snap +++ b/webapp/channels/src/components/user_settings/notifications/__snapshots__/user_settings_notifications.test.tsx.snap @@ -258,8 +258,52 @@ Object { class="divider-light" />
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
@@ -518,8 +562,52 @@ Object { class="divider-light" />
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
, @@ -840,8 +928,52 @@ Object {
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
@@ -1103,8 +1235,52 @@ Object {
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
, @@ -1387,8 +1563,52 @@ Object { class="divider-light" />
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
@@ -1612,8 +1832,52 @@ Object { class="divider-light" />
+
+
+
+ +
+

+ Troubleshooting notifications +

+

+ Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps. +

+
+ + +
+
+
+
+
, diff --git a/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.test.tsx b/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.test.tsx new file mode 100644 index 00000000000..76ac3fdc42f --- /dev/null +++ b/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.test.tsx @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {sendTestNotification} from 'actions/notification_actions'; + +import {act, renderWithContext, screen, waitFor} from 'tests/react_testing_utils'; + +import SendTestNotificationNotice from './send_test_notification_notice'; + +jest.mock('actions/notification_actions', () => ({ + sendTestNotification: jest.fn().mockResolvedValue({status: 'OK'}), +})); + +const mockedSendTestNotification = jest.mocked(sendTestNotification); + +describe('components/user_settings/notifications/send_test_notification_notice', () => { + jest.useFakeTimers(); + it('should match snapshot', () => { + const {container} = renderWithContext(()); + expect(container).toMatchSnapshot(); + }); + it('should not show on admin mode', () => { + const {container} = renderWithContext(()); + expect(container).toBeEmptyDOMElement(); + }); + it('should send the notificaton when the send button is clicked', async () => { + renderWithContext(()); + expect(mockedSendTestNotification).not.toHaveBeenCalled(); + act(() => screen.getByText('Send a test notification').click()); + await waitFor(() => { + expect(mockedSendTestNotification).toHaveBeenCalled(); + expect(screen.getByText('Test notification sent')).toBeInTheDocument(); + }); + }); + it('should open link when the secondary button is clicked', () => { + const originalOpen = window.open; + const mockedOpen = jest.fn(); + window.open = mockedOpen; + + renderWithContext(()); + expect(mockedOpen).not.toHaveBeenCalled(); + act(() => screen.getByText('Troubleshooting docs').click()); + expect(mockedOpen).toHaveBeenCalled(); + + window.open = originalOpen; + }); + it('should show error on button when the system returns an error', async () => { + mockedSendTestNotification.mockResolvedValueOnce({status: 'NOT OK'}); + + const originalConsole = console.error; + const mockedConsole = jest.fn(); + console.error = mockedConsole; + + renderWithContext(()); + expect(mockedSendTestNotification).not.toHaveBeenCalled(); + act(() => screen.getByText('Send a test notification').click()); + await waitFor(() => { + expect(mockedSendTestNotification).toHaveBeenCalled(); + expect(screen.getByText('Error sending test notification')).toBeInTheDocument(); + expect(mockedConsole).toHaveBeenCalledWith({status: 'NOT OK'}); + }); + + console.error = originalConsole; + }); +}); diff --git a/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.tsx b/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.tsx new file mode 100644 index 00000000000..610c528b7da --- /dev/null +++ b/webapp/channels/src/components/user_settings/notifications/send_test_notification_notice.tsx @@ -0,0 +1,138 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {sendTestNotification} from 'actions/notification_actions'; + +import {useExternalLink} from 'components/common/hooks/use_external_link'; +import SectionNotice from 'components/section_notice'; + +const sectionNoticeContainerStyle: React.CSSProperties = {marginTop: 20}; + +const TIME_TO_SENDING = 500; +const TIME_TO_SEND = 500; +const TIME_TO_IDLE = 3000; + +type Props = { + adminMode?: boolean; +}; + +type ButtonState = 'idle'|'sending'|'sent'|'error'; + +const SendTestNotificationNotice = ({ + adminMode = false, +}: Props) => { + const intl = useIntl(); + const [buttonState, setButtonState] = useState('idle'); + const isSending = useRef(false); + const timeout = useRef(); + const [externalLink] = useExternalLink('https://mattermost.com/pl/troubleshoot-notifications'); + + const onGoToNotificationDocumentation = useCallback(() => { + window.open(externalLink); + }, [externalLink]); + + const onSendTestNotificationClick = useCallback(async () => { + if (isSending.current) { + return; + } + isSending.current = true; + let isShowingSending = false; + timeout.current = setTimeout(() => { + isShowingSending = true; + setButtonState('sending'); + }, TIME_TO_SENDING); + const result = await sendTestNotification(); + clearTimeout(timeout.current); + const setResult = () => { + if (result.status === 'OK') { + setButtonState('sent'); + } else { + // We want to log this error into the console mainly + // for debugging reasons. We still use the 'error' level + // because it is an unexpected error. + // eslint-disable-next-line no-console + console.error(result); + setButtonState('error'); + } + timeout.current = setTimeout(() => { + isSending.current = false; + setButtonState('idle'); + }, TIME_TO_IDLE); + }; + + if (isShowingSending) { + timeout.current = setTimeout(setResult, TIME_TO_SEND); + } else { + setResult(); + } + }, []); + + useEffect(() => { + return () => { + clearTimeout(timeout.current); + }; + }, []); + + const primaryButton = useMemo(() => { + let text; + let icon; + let loading; + switch (buttonState) { + case 'idle': + text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.send', defaultMessage: 'Send a test notification'}); + break; + case 'sending': + text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.sending', defaultMessage: 'Sending a test notification'}); + loading = true; + break; + case 'sent': + text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.sent', defaultMessage: 'Test notification sent'}); + icon = 'icon-check'; + break; + case 'error': + text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.error', defaultMessage: 'Error sending test notification'}); + icon = 'icon-alert-outline'; + } + return { + onClick: onSendTestNotificationClick, + text, + leadingIcon: icon, + loading, + }; + }, [buttonState, intl, onSendTestNotificationClick]); + + const secondaryButton = useMemo(() => { + return { + onClick: onGoToNotificationDocumentation, + text: intl.formatMessage({id: 'user_settings.notifications.test_notification.go_to_docs', defaultMessage: 'Troubleshooting docs'}), + trailingIcon: 'icon-open-in-new', + }; + }, [intl, onGoToNotificationDocumentation]); + + if (adminMode) { + return null; + } + + return ( + <> +
+
+ +
+ + ); +}; + +export default SendTestNotificationNotice; diff --git a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx index 95bd378e054..4a7ee5de57b 100644 --- a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx +++ b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx @@ -31,6 +31,7 @@ import DesktopAndMobileNotificationSettings from './desktop_and_mobile_notificat import DesktopNotificationSoundsSettings from './desktop_notification_sounds_setting'; import EmailNotificationSetting from './email_notification_setting'; import ManageAutoResponder from './manage_auto_responder/manage_auto_responder'; +import SendTestNotificationNotice from './send_test_notification_notice'; import SettingDesktopHeader from '../headers/setting_desktop_header'; import SettingMobileHeader from '../headers/setting_mobile_header'; @@ -1099,7 +1100,7 @@ class NotificationsTab extends React.PureComponent { {keywordsWithHighlightSection} )} -
+
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 19ede2b6cb3..d1e966f216e 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5484,6 +5484,13 @@ "user_profile.roleTitle.team_admin": "Team Admin", "user_profile.send.dm": "Message", "user_profile.send.dm.yourself": "Send yourself a message", + "user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if they’re working as expected. If issues persist, explore ways to solve them with troubleshooting steps.", + "user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs", + "user_settings.notifications.test_notification.send_button.error": "Error sending test notification", + "user_settings.notifications.test_notification.send_button.send": "Send a test notification", + "user_settings.notifications.test_notification.send_button.sending": "Sending a test notification", + "user_settings.notifications.test_notification.send_button.sent": "Test notification sent", + "user_settings.notifications.test_notification.title": "Troubleshooting notifications", "user.settings.advance.confirmDeactivateAccountTitle": "Confirm Deactivation", "user.settings.advance.confirmDeactivateDesc": "Are you sure you want to deactivate your account? This can only be reversed by your System Administrator.", "user.settings.advance.deactivate_member_modal.deactivateButton": "Yes, deactivate my account", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index f11b9c0aecf..8025baa483a 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3292,6 +3292,13 @@ export default class Client4 { ); }; + sendTestNotificaiton = () => { + return this.doFetch( + `${this.getBaseRoute()}/notifications/test`, + {method: 'post'}, + ); + }; + testEmail = (config?: AdminConfig) => { return this.doFetch( `${this.getBaseRoute()}/email/test`,