Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('Verify Accessibility Support in different sections in Settings and Pro
{key: 'desktopAndMobile', label: 'Desktop and mobile notifications', type: 'radio'},
{key: 'desktopNotificationSound', label: 'Desktop notification sounds', type: 'radio'},
{key: 'email', label: 'Email notifications', type: 'radio'},
{key: 'channelMentionAutoFollow', label: 'Auto-follow threads on channel-wide mentions', type: 'radio'},
{key: 'keywordsAndMentions', label: 'Keywords that trigger notifications', type: 'checkbox'},
{key: 'keywordsAndHighlight', label: 'Keywords that get highlighted (without notifications)', type: 'checkbox'},
{key: 'replyNotifications', label: 'Reply notifications', type: 'radio'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('User Management', () => {

verifyManageUserSettingModal(testUser, true);

cy.get('#replyNotificationsTitle').should('be.visible').should('have.text', 'Reply notifications').click();
cy.findByRole('heading', {name: 'Reply notifications'}).scrollIntoView().click();
cy.get('#notificationCommentsNever').should('be.checked');
cy.get('#notificationCommentsAny').check();
cy.get('button#saveSetting').last().scrollIntoView().click();
Expand All @@ -56,7 +56,7 @@ describe('User Management', () => {

cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
cy.get('[aria-label="Settings"]').click();
cy.get('#replyNotificationsTitle').should('be.visible').should('have.text', 'Reply notifications').click();
cy.findByRole('heading', {name: 'Reply notifications'}).scrollIntoView().click();
cy.get('#notificationCommentsAny').should('be.checked');
cy.apiLogout();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default class NotificationsSettings {
readonly desktopAndMobileEditButton;
readonly desktopNotificationSoundEditButton;
readonly emailEditButton;
readonly channelMentionAutoFollowEditButton;
readonly keywordsTriggerNotificationsEditButton;
readonly keywordsGetHighlightedEditButton;

Expand All @@ -35,6 +36,9 @@ export default class NotificationsSettings {
this.desktopAndMobileEditButton = container.locator('#desktopAndMobileEdit');
this.desktopNotificationSoundEditButton = container.locator('#desktopNotificationSoundEdit');
this.emailEditButton = container.locator('#emailEdit');
this.channelMentionAutoFollowEditButton = container.getByRole('button', {
name: 'Auto-follow threads on channel-wide mentions Edit',
});
this.keywordsTriggerNotificationsEditButton = container.locator('#keywordsAndMentionsEdit');
this.keywordsGetHighlightedEditButton = container.locator('#keywordsAndHighlightEdit');

Expand Down
12 changes: 12 additions & 0 deletions e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ export default class ContentReviewPage {
await expect(this.reportCard!.locator('.row:has-text("Message") .post-message__text')).toHaveText(expected);
}

async verifyFlaggedPostMessageInRHS(expected: string) {
await expect(this.rhsCard.locator('.row:has-text("Message") .post-message__text')).toHaveText(expected);
}

async verifyFlaggedPostMessageInCenter(postID: string, expected: string) {
const centerCard = this.page
.getByTestId('channel_view')
.locator('div.DataSpillageReport')
.filter({has: this.page.locator(`#postMessageText_${postID}`)});
await expect(centerCard.locator('.row:has-text("Message") .post-message__text')).toHaveText(expected);
}

async clickKeepMessage() {
await this.keepMessageButton.scrollIntoViewIfNeeded();
await this.keepMessageButton.click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ test(
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.emailEditButton);

// # Press Tab to move focus to Auto-follow threads on channel-wide mentions button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.channelMentionAutoFollowEditButton);

// # Press Tab to move focus to Keywords that trigger notifications button
await page.keyboard.press('Tab');
await pw.toBeFocusedWithFocusVisible(notificationsSettings.keywordsTriggerNotificationsEditButton);
Expand Down Expand Up @@ -110,6 +114,9 @@ test(
- text: "\\"Bing\\" for messages"
- heading "Email notifications" [level=4]
- button "Email notifications Edit"
- heading "Auto-follow threads on channel-wide mentions" [level=4]
- button "Auto-follow threads on channel-wide mentions Edit"
- text: "On"
- heading "Keywords that trigger notifications" [level=4]
- button "Keywords that trigger notifications Edit"
- text: "\\"@${user.username}\\", \\"@channel\\", \\"@all\\", \\"@here\\""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
await secondContentReviewPage.confirmRemovePermanently();
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);

// After the remove action succeeds, the reviewer's own view of the flagged
// post should be replaced with the moderation placeholder rather than the
// original message that is now cleared from the redux store (MM-69043).
// The placeholder appears both in the RHS detail view and in the report
// card shown in the @content-review center channel — assert each scope
// separately so a regression in either location is caught.
await secondContentReviewPage.verifyFlaggedPostMessageInRHS(contentModerationMessage);
await secondContentReviewPage.verifyFlaggedPostMessageInCenter(post.id, contentModerationMessage);

const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
await pw.testBrowser.login(thirdUser);
await verifyAuthorNotification(
Expand Down
19 changes: 19 additions & 0 deletions server/channels/app/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -2018,6 +2018,25 @@ func (a *App) sendFileDownloadRejectedEvent(info *model.FileInfo, userID string,
a.Publish(message)
}

// sendFileUploadRejectedEvent sends a websocket event to notify the user that their file upload was
// rejected by a plugin. It mirrors sendFileDownloadRejectedEvent so the webapp can surface the
// rejection as a toast instead of an inline composer error. When connectionID is provided, the event
// is only sent to that specific connection.
func (a *App) sendFileUploadRejectedEvent(info *model.FileInfo, userID string, connectionID string, rejectionReason string) {
if userID == "" {
return
}

message := model.NewWebSocketEvent(model.WebsocketEventFileUploadRejected, "", info.ChannelId, userID, nil, "")
if connectionID != "" {
message.GetBroadcast().ConnectionId = connectionID
}
message.Add("file_name", info.Name)
message.Add("rejection_reason", rejectionReason)
message.Add("channel_id", info.ChannelId)
a.Publish(message)
}

// RunFileWillBeDownloadedHook executes the FileWillBeDownloaded hook with a timeout.
// Returns empty string to allow download, or a rejection reason to block it.
func (a *App) RunFileWillBeDownloadedHook(rctx request.CTX, fileInfo *model.FileInfo, userID string, connectionID string, downloadType model.FileDownloadType) string {
Expand Down
50 changes: 50 additions & 0 deletions server/channels/app/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1207,3 +1207,53 @@ func TestFilterFilesByChannelPermissions_ABAC(t *testing.T) {
mockACS.AssertNotCalled(t, "AccessEvaluation")
})
}

func TestSendFileUploadRejectedEvent(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

info := &model.FileInfo{
Id: model.NewId(),
Name: "secret.tdf",
ChannelId: th.BasicChannel.Id,
CreatorId: th.BasicUser.Id,
}

t.Run("publishes the event with populated fields", func(t *testing.T) {
wsMessages, closeWS := connectFakeWebSocket(t, th, th.BasicUser.Id, "", []model.WebsocketEventType{model.WebsocketEventFileUploadRejected})
defer closeWS()

th.App.sendFileUploadRejectedEvent(info, th.BasicUser.Id, "", "blocked by policy")

var received *model.WebSocketEvent
require.Eventually(t, func() bool {
select {
case received = <-wsMessages:
return true
default:
return false
}
}, 5*time.Second, 100*time.Millisecond, "did not receive file_upload_rejected event in time")

data := received.GetData()
assert.Equal(t, info.Name, data["file_name"])
assert.Equal(t, "blocked by policy", data["rejection_reason"])
assert.Equal(t, info.ChannelId, data["channel_id"])
})

t.Run("does not publish when userID is empty", func(t *testing.T) {
wsMessages, closeWS := connectFakeWebSocket(t, th, th.BasicUser.Id, "", []model.WebsocketEventType{model.WebsocketEventFileUploadRejected})
defer closeWS()

th.App.sendFileUploadRejectedEvent(info, "", "", "blocked by policy")

require.Never(t, func() bool {
select {
case <-wsMessages:
return true
default:
return false
}
}, 1*time.Second, 100*time.Millisecond, "should not publish file_upload_rejected event when userID is empty")
})
}
14 changes: 12 additions & 2 deletions server/channels/app/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,22 @@ func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model.
}
if channel.Type != model.ChannelTypeDirect {
rootMentions = getExplicitMentions(rootPost, keywords)
for id := range rootMentions.Mentions {
for id, mentionType := range rootMentions.Mentions {
if mentionType == ChannelMention {
if profile, ok := profileMap[id]; ok && profile.NotifyProps[model.ChannelMentionAutoFollowThreadsProp] == "false" {
continue
}
}
threadParticipants[id] = true
}
}
}
for id := range mentions.Mentions {
for id, mentionType := range mentions.Mentions {
if mentionType == ChannelMention {
if profile, ok := profileMap[id]; ok && profile.NotifyProps[model.ChannelMentionAutoFollowThreadsProp] == "false" {
continue
}
}
threadParticipants[id] = true
}

Expand Down
104 changes: 104 additions & 0 deletions server/channels/app/notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3022,6 +3022,110 @@ func TestChannelAutoFollowThreads(t *testing.T) {
assert.False(t, threadMembership.Following)
}

func TestChannelMentionAutoFollowThreads(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

u1 := th.BasicUser
u2 := th.BasicUser2
u3 := th.CreateUser(t)
th.LinkUserToTeam(t, u3, th.BasicTeam)
c1 := th.BasicChannel
th.AddUserToChannel(t, u2, c1)
th.AddUserToChannel(t, u3, c1)

rootPost := &model.Post{
ChannelId: c1.Id,
Message: "root post by user3",
UserId: u3.Id,
}
rpost, _, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)

t.Run("channel mention auto-follow enabled (default)", func(t *testing.T) {
// u2 has default notify props (channel_mention_auto_follow_threads = "true")
require.Equal(t, "true", u2.NotifyProps[model.ChannelMentionAutoFollowThreadsProp])

replyPost := &model.Post{
ChannelId: c1.Id,
Message: "@channel reply by user1",
UserId: u1.Id,
RootId: rpost.Id,
}
_, _, appErr = th.App.CreatePost(th.Context, replyPost, c1, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)

// u2 should be auto-following because channel_mention_auto_follow_threads is enabled
threadMembership, getThreadErr := th.App.GetThreadMembershipForUser(u2.Id, rpost.Id)
require.Nil(t, getThreadErr)
require.NotNil(t, threadMembership)
assert.True(t, threadMembership.Following)
})

t.Run("channel mention auto-follow disabled for user", func(t *testing.T) {
// Disable auto-follow for u2
u2.NotifyProps[model.ChannelMentionAutoFollowThreadsProp] = "false"
u2, appErr = th.App.UpdateUser(th.Context, u2, false)
require.Nil(t, appErr)
require.Equal(t, "false", u2.NotifyProps[model.ChannelMentionAutoFollowThreadsProp])

// Reset u2 membership so the prior sub-test doesn't interfere
_, err := th.App.Srv().Store().Thread().MaintainMembership(u2.Id, rpost.Id, store.ThreadMembershipOpts{
Following: false,
UpdateFollowing: true,
})
require.NoError(t, err)

replyPost := &model.Post{
ChannelId: c1.Id,
Message: "@channel reply by user1 again",
UserId: u1.Id,
RootId: rpost.Id,
}
_, _, appErr = th.App.CreatePost(th.Context, replyPost, c1, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)

// u2 should NOT be auto-following because they opted out
threadMembership, getThreadErr := th.App.GetThreadMembershipForUser(u2.Id, rpost.Id)
require.Nil(t, getThreadErr)
if threadMembership != nil {
assert.False(t, threadMembership.Following)
}
})

t.Run("channel mention auto-follow undefined (old default)", func(t *testing.T) {
// Remove the auto-follow setting for u2 to mimic a user created before this setting was added
delete(u2.NotifyProps, model.ChannelMentionAutoFollowThreadsProp)
u2, appErr = th.App.UpdateUser(th.Context, u2, false)
require.Nil(t, appErr)

_, ok := u2.NotifyProps[model.ChannelMentionAutoFollowThreadsProp]
require.False(t, ok)

// Reset u2 membership so the prior sub-test doesn't interfere
_, err := th.App.Srv().Store().Thread().MaintainMembership(u2.Id, rpost.Id, store.ThreadMembershipOpts{
Following: false,
UpdateFollowing: true,
})
require.NoError(t, err)

replyPost := &model.Post{
ChannelId: c1.Id,
Message: "@channel reply by user1",
UserId: u1.Id,
RootId: rpost.Id,
}
_, _, appErr = th.App.CreatePost(th.Context, replyPost, c1, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)

// u2 should be auto-following because channel_mention_auto_follow_threads isn't defined
threadMembership, getThreadErr := th.App.GetThreadMembershipForUser(u2.Id, rpost.Id)
require.Nil(t, getThreadErr)
require.NotNil(t, threadMembership)
assert.True(t, threadMembership.Following)
})
}

func TestRemoveNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
Expand Down
1 change: 1 addition & 0 deletions server/channels/app/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (a *App) runPluginsHook(rctx request.CTX, info *model.FileInfo, file io.Rea
if rejStr != "" {
rejErr = model.NewAppError("runPluginsHook", "app.upload.run_plugins_hook.rejected",
map[string]any{"Filename": info.Name, "Reason": rejStr}, "", http.StatusBadRequest)
a.sendFileUploadRejectedEvent(info, info.CreatorId, rctx.ConnectionId(), rejStr)
return false
}
if newInfo != nil {
Expand Down
31 changes: 26 additions & 5 deletions server/channels/store/sqlstore/integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func createCompliance(ss store.Store, userId string) *model.Compliance {
func createEmoji(ss store.Store, userId string) *model.Emoji {
m := model.Emoji{}
m.CreatorId = userId
m.Name = "emoji"
m.Name = "emoji" + model.NewId()
emoji, _ := ss.Emoji().Save(&m)
return emoji
}
Expand Down Expand Up @@ -1214,26 +1214,47 @@ func TestCheckUsersEmojiIntegrity(t *testing.T) {
dbmap := store.GetMaster()

t.Run("should generate a report with no records", func(t *testing.T) {
user := createUser(rctx, ss)
emoji := createEmoji(ss, user.Id)
t.Cleanup(func() {
_, err := dbmap.Exec(`DELETE FROM Emoji WHERE Id=?`, emoji.Id)
require.NoError(t, err)
})
t.Cleanup(func() {
_, err := dbmap.Exec(`DELETE FROM Users WHERE Id=?`, user.Id)
require.NoError(t, err)
})

result := checkUsersEmojiIntegrity(store)
require.NoError(t, result.Err)
data := result.Data.(model.RelationalIntegrityCheckData)
require.Empty(t, data.Records)
records := orphanedRecordsWithChildIDs(data.Records, emoji.Id)
require.Empty(t, records)
})

t.Run("should generate a report with one record", func(t *testing.T) {
user := createUser(rctx, ss)
userId := user.Id
emoji := createEmoji(ss, userId)
t.Cleanup(func() {
_, err := dbmap.Exec(`DELETE FROM Emoji WHERE Id=?`, emoji.Id)
require.NoError(t, err)
})
t.Cleanup(func() {
_, err := dbmap.Exec(`DELETE FROM Users WHERE Id=?`, user.Id)
require.NoError(t, err)
})

dbmap.Exec(`DELETE FROM Users WHERE Id=?`, user.Id)
result := checkUsersEmojiIntegrity(store)
require.NoError(t, result.Err)
data := result.Data.(model.RelationalIntegrityCheckData)
require.Len(t, data.Records, 1)
records := orphanedRecordsWithChildIDs(data.Records, emoji.Id)
require.Len(t, records, 1)
require.Equal(t, model.OrphanedRecord{
ParentId: &userId,
ChildId: &emoji.Id,
}, data.Records[0])
dbmap.Exec(`DELETE FROM Emoji WHERE Id=?`, emoji.Id)
}, records[0])
})
})
}
Expand Down
Loading
Loading