mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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 <build@mattermost.com> Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
parent
30a6ddc995
commit
118d0346ee
@ -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:
|
||||
|
@ -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();
|
||||
}
|
@ -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';
|
||||
|
38
e2e-tests/cypress/tests/support/notification_commands.ts
Normal file
38
e2e-tests/cypress/tests/support/notification_commands.ts
Normal file
@ -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);
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
@ -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."
|
||||
|
@ -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)
|
||||
|
@ -75,6 +75,7 @@ const (
|
||||
PostPropsMentionHighlightDisabled = "mentionHighlightDisabled"
|
||||
PostPropsGroupHighlightDisabled = "disable_group_highlight"
|
||||
PostPropsPreviewedPost = "previewed_post"
|
||||
PostPropsForceNotification = "force_notification"
|
||||
|
||||
PostPriorityUrgent = "urgent"
|
||||
PostPropsRequestedAck = "requested_ack"
|
||||
@ -341,6 +342,7 @@ func (o *Post) EncodeJSON(w io.Writer) error {
|
||||
type CreatePostFlags struct {
|
||||
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 {
|
||||
|
@ -115,22 +115,26 @@ 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",
|
||||
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",
|
||||
PostPropsForceNotification: "no good",
|
||||
"attachments": "good",
|
||||
},
|
||||
}
|
||||
@ -138,6 +142,7 @@ func TestPostSanitizeProps(t *testing.T) {
|
||||
post3.SanitizeProps()
|
||||
|
||||
require.Nil(t, post3.GetProp(PropsAddChannelMember))
|
||||
require.Nil(t, post3.GetProp(PostPropsForceNotification))
|
||||
|
||||
require.NotNil(t, post3.GetProp("attachments"))
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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<Channel, 'type' | 'id'>,
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
@ -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<GlobalState> {
|
||||
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);
|
||||
});
|
||||
});
|
@ -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<string, string>] {
|
||||
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]);
|
||||
}
|
@ -18,7 +18,7 @@ exports[`components/external_link should match snapshot 1`] = `
|
||||
location="test"
|
||||
>
|
||||
<a
|
||||
href="https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid="
|
||||
href="https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid="
|
||||
location="test"
|
||||
onClick={[Function]}
|
||||
rel="noopener noreferrer"
|
||||
|
@ -98,7 +98,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=&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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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<HTMLAnchorElement> & {
|
||||
href: string;
|
||||
@ -30,35 +21,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
}
|
||||
|
||||
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((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<HTMLElement>) => {
|
||||
trackEvent('link_out', 'click_external_link', queryParams);
|
||||
|
@ -93,6 +93,7 @@ describe('components/MarketplaceItemPlugin', () => {
|
||||
entities: {
|
||||
general: {
|
||||
config: {},
|
||||
license: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'currentUserId',
|
||||
|
10
webapp/channels/src/components/section_notice/types.d.ts
vendored
Normal file
10
webapp/channels/src/components/section_notice/types.d.ts
vendored
Normal file
@ -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;
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/user_settings/notifications/send_test_notification_notice should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -258,8 +258,52 @@ Object {
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -518,8 +562,52 @@ Object {
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@ -840,8 +928,52 @@ Object {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1103,8 +1235,52 @@ Object {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@ -1387,8 +1563,52 @@ Object {
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1612,8 +1832,52 @@ Object {
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
class="divider-dark"
|
||||
class="divider-light"
|
||||
/>
|
||||
<div
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContainer hint"
|
||||
>
|
||||
<div
|
||||
class="sectionNoticeContent"
|
||||
>
|
||||
<i
|
||||
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
|
||||
/>
|
||||
<div
|
||||
class="sectionNoticeBody"
|
||||
>
|
||||
<h4
|
||||
class="sectionNoticeTitle"
|
||||
>
|
||||
Troubleshooting notifications
|
||||
</h4>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div
|
||||
class="sectionNoticeActions"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-primary"
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm sectionNoticeButton btn-tertiary"
|
||||
>
|
||||
Troubleshooting docs
|
||||
<i
|
||||
class="icon icon-open-in-new"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
@ -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((<SendTestNotificationNotice/>));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('should not show on admin mode', () => {
|
||||
const {container} = renderWithContext((<SendTestNotificationNotice adminMode={true}/>));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
it('should send the notificaton when the send button is clicked', async () => {
|
||||
renderWithContext((<SendTestNotificationNotice/>));
|
||||
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((<SendTestNotificationNotice/>));
|
||||
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((<SendTestNotificationNotice/>));
|
||||
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;
|
||||
});
|
||||
});
|
@ -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<ButtonState>('idle');
|
||||
const isSending = useRef(false);
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
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 (
|
||||
<>
|
||||
<div className='divider-light'/>
|
||||
<div style={sectionNoticeContainerStyle}>
|
||||
<SectionNotice
|
||||
text={intl.formatMessage({
|
||||
id: 'user_settings.notifications.test_notification.body',
|
||||
defaultMessage: '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.',
|
||||
})}
|
||||
title={intl.formatMessage({id: 'user_settings.notifications.test_notification.title', defaultMessage: 'Troubleshooting notifications'})}
|
||||
primaryButton={primaryButton}
|
||||
tertiaryButton={secondaryButton}
|
||||
type='hint'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendTestNotificationNotice;
|
@ -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<Props, State> {
|
||||
{keywordsWithHighlightSection}
|
||||
</>
|
||||
)}
|
||||
<div className='divider-dark'/>
|
||||
<SendTestNotificationNotice adminMode={this.props.adminMode}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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",
|
||||
|
@ -3292,6 +3292,13 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
sendTestNotificaiton = () => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getBaseRoute()}/notifications/test`,
|
||||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
testEmail = (config?: AdminConfig) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getBaseRoute()}/email/test`,
|
||||
|
Loading…
Reference in New Issue
Block a user