diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index 7999156167e..952c1d980c8 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -81,6 +81,11 @@ func createPostChecks(where string, c *Context, post *model.Post) { } postPriorityCheckWithContext(where, c, post.GetPriority(), post.RootId) + if c.Err != nil { + return + } + + postBurnOnReadCheckWithContext(where, c, post, nil) } func createPost(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/server/channels/api4/post_utils.go b/server/channels/api4/post_utils.go index edc5ee720d4..9fc40da80be 100644 --- a/server/channels/api4/post_utils.go +++ b/server/channels/api4/post_utils.go @@ -42,6 +42,14 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post } } +func postBurnOnReadCheckWithContext(where string, c *Context, post *model.Post, channel *model.Channel) { + appErr := app.PostBurnOnReadCheckWithApp(where, c.App, c.AppContext, post.UserId, post.ChannelId, post.Type, channel) + if appErr != nil { + appErr.Where = where + c.Err = appErr + } +} + // checkUploadFilePermissionForNewFiles checks upload_file permission only when // adding new files to a post, preventing permission bypass via cross-channel file attachments. func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) { diff --git a/server/channels/api4/scheduled_post.go b/server/channels/api4/scheduled_post.go index 84a9f0818cd..dcbf27a5209 100644 --- a/server/channels/api4/scheduled_post.go +++ b/server/channels/api4/scheduled_post.go @@ -40,6 +40,17 @@ func scheduledPostChecks(where string, c *Context, scheduledPost *model.Schedule } postPriorityCheckWithContext(where, c, scheduledPost.GetPriority(), scheduledPost.RootId) + if c.Err != nil { + return + } + + // Validate burn-on-read restrictions for scheduled post + post := &model.Post{ + ChannelId: scheduledPost.ChannelId, + UserId: scheduledPost.UserId, + Type: scheduledPost.Type, + } + postBurnOnReadCheckWithContext(where, c, post, nil) } func requireScheduledPostsEnabled(c *Context) { diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 9c3cbeeae06..3007e32d250 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -165,6 +165,12 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan return nil, false, model.NewAppError("CreatePost", "app.post.create_post.shared_dm_or_gm.app_error", nil, "", http.StatusBadRequest) } + // Validate burn-on-read restrictions (self-DMs, DMs with bots) + err = PostBurnOnReadCheckWithApp("App.CreatePost", a, rctx, post.UserId, post.ChannelId, post.Type, channel) + if err != nil { + return nil, false, err + } + foundPost, err := a.deduplicateCreatePost(rctx, post) if err != nil { return nil, false, err diff --git a/server/channels/app/post_permission_utils.go b/server/channels/app/post_permission_utils.go index 6bc5c5084cf..cdd98b95772 100644 --- a/server/channels/app/post_permission_utils.go +++ b/server/channels/app/post_permission_utils.go @@ -114,3 +114,47 @@ func userCreatePostPermissionCheckWithApp(rctx request.CTX, a *App, userId, chan return nil } + +// PostBurnOnReadCheckWithApp validates whether a burn-on-read post can be created +// based on channel type and participants. This is called from the API layer before +// post creation to enforce burn-on-read restrictions. +func PostBurnOnReadCheckWithApp(where string, a *App, rctx request.CTX, userId, channelId, postType string, channel *model.Channel) *model.AppError { + // Only validate if this is a burn-on-read post + if postType != model.PostTypeBurnOnRead { + return nil + } + + // Get channel if not provided + if channel == nil { + ch, err := a.GetChannel(rctx, channelId) + if err != nil { + return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + channel = ch + } + + // Burn-on-read is not allowed in self-DMs or DMs with bots (including AI agents, plugins) + if channel.Type == model.ChannelTypeDirect { + // Check if it's a self-DM by comparing the channel name with the expected self-DM name + selfDMName := model.GetDMNameFromIds(userId, userId) + if channel.Name == selfDMName { + return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", nil, "", http.StatusBadRequest) + } + + // Check if the DM is with a bot (AI agents, plugins, etc.) + otherUserId := channel.GetOtherUserIdForDM(userId) + if otherUserId != "" && otherUserId != userId { + otherUser, err := a.GetUser(otherUserId) + if err != nil { + // Failed to retrieve the other user (user not found, DB error, etc.) + // Block burn-on-read post as we cannot validate the recipient + return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.user.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if otherUser.IsBot { + return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", nil, "", http.StatusBadRequest) + } + } + } + + return nil +} diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index cd3691b2e4f..e29c4d1ecf4 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -5368,6 +5368,210 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) { }) } +func TestBurnOnReadRestrictionsForDMsAndBots(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true") + defer func() { + os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD") + }() + + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true) + cfg.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = model.NewPointer(600) + cfg.ServiceSettings.BurnOnReadDurationSeconds = model.NewPointer(600) + }) + + t.Run("should allow burn-on-read posts in direct messages with another user", func(t *testing.T) { + // Create a direct message channel between two different users + dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + require.Equal(t, model.ChannelTypeDirect, dmChannel.Type) + + post := &model.Post{ + ChannelId: dmChannel.Id, + Message: "This is a burn-on-read message in DM", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + createdPost, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.NotNil(t, createdPost) + require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type) + }) + + t.Run("should allow burn-on-read posts in group messages", func(t *testing.T) { + // Create a group message channel with at least 3 users + user3 := th.CreateUser(t) + th.LinkUserToTeam(t, user3, th.BasicTeam) + gmChannel := th.CreateGroupChannel(t, th.BasicUser2, user3) + require.Equal(t, model.ChannelTypeGroup, gmChannel.Type) + + // This should succeed - group messages allow BoR + post := &model.Post{ + ChannelId: gmChannel.Id, + Message: "This is a burn-on-read message in GM", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + createdPost, _, err := th.App.CreatePost(th.Context, post, gmChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.NotNil(t, createdPost) + require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type) + }) + + t.Run("should allow burn-on-read posts from bot users", func(t *testing.T) { + // Create a bot user + bot := &model.Bot{ + Username: "testbot", + DisplayName: "Test Bot", + Description: "Test Bot for burn-on-read (bots can send BoR for OTP, integrations, etc.)", + OwnerId: th.BasicUser.Id, + } + createdBot, appErr := th.App.CreateBot(th.Context, bot) + require.Nil(t, appErr) + + // Get the bot user + botUser, appErr := th.App.GetUser(createdBot.UserId) + require.Nil(t, appErr) + require.True(t, botUser.IsBot) + + // Create a burn-on-read post as bot (should succeed - bots can send BoR) + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "This is a burn-on-read message from bot", + UserId: botUser.Id, + Type: model.PostTypeBurnOnRead, + } + + createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.NotNil(t, createdPost) + require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type) + }) + + t.Run("should reject burn-on-read posts in self DMs", func(t *testing.T) { + // Create a self DM channel (user messaging themselves) + selfDMChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser.Id) + require.Nil(t, appErr) + require.Equal(t, model.ChannelTypeDirect, selfDMChannel.Type) + + // Try to create a burn-on-read post in self DM + post := &model.Post{ + ChannelId: selfDMChannel.Id, + Message: "This is a burn-on-read message to myself", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + _, _, err := th.App.CreatePost(th.Context, post, selfDMChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, err) + require.Equal(t, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", err.Id) + }) + + t.Run("should reject burn-on-read posts in DMs with bots/AI agents", func(t *testing.T) { + // Create a bot user + bot := &model.Bot{ + Username: "aiagent", + DisplayName: "AI Agent", + Description: "Test AI Agent for burn-on-read restrictions", + OwnerId: th.BasicUser.Id, + } + createdBot, appErr := th.App.CreateBot(th.Context, bot) + require.Nil(t, appErr) + + // Get the bot user + botUser, appErr := th.App.GetUser(createdBot.UserId) + require.Nil(t, appErr) + require.True(t, botUser.IsBot) + + // Create a DM channel between the regular user and the bot + dmWithBotChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, botUser.Id) + require.Nil(t, appErr) + require.Equal(t, model.ChannelTypeDirect, dmWithBotChannel.Type) + + // Try to create a burn-on-read post in DM with bot (regular user sending) + post := &model.Post{ + ChannelId: dmWithBotChannel.Id, + Message: "This is a burn-on-read message to AI agent", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + _, _, err := th.App.CreatePost(th.Context, post, dmWithBotChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, err) + require.Equal(t, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", err.Id) + }) + + t.Run("should reject burn-on-read posts in DMs with deleted users", func(t *testing.T) { + // Create a user that we'll delete + userToDelete := th.CreateUser(t) + th.LinkUserToTeam(t, userToDelete, th.BasicTeam) + + // Create a DM channel between the regular user and the user we'll delete + dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, userToDelete.Id) + require.Nil(t, appErr) + require.Equal(t, model.ChannelTypeDirect, dmChannel.Type) + + // Delete the user + appErr = th.App.PermanentDeleteUser(th.Context, userToDelete) + require.Nil(t, appErr) + + // Try to create a burn-on-read post in DM with deleted user + post := &model.Post{ + ChannelId: dmChannel.Id, + Message: "This is a burn-on-read message to deleted user", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + // This should fail because we can't validate the other user (deleted) + _, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, err) + require.Equal(t, "api.post.fill_in_post_props.burn_on_read.user.app_error", err.Id) + }) + + t.Run("should allow burn-on-read posts in public channels", func(t *testing.T) { + // This should succeed - public channel, regular user + require.Equal(t, model.ChannelTypeOpen, th.BasicChannel.Type) + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "This is a burn-on-read message in public channel", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.NotNil(t, createdPost) + require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type) + }) + + t.Run("should allow burn-on-read posts in private channels", func(t *testing.T) { + // Create a private channel using helper + createdPrivateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + require.Equal(t, model.ChannelTypePrivate, createdPrivateChannel.Type) + + // This should succeed - private channel, regular user + post := &model.Post{ + ChannelId: createdPrivateChannel.Id, + Message: "This is a burn-on-read message in private channel", + UserId: th.BasicUser.Id, + Type: model.PostTypeBurnOnRead, + } + + createdPost, _, err := th.App.CreatePost(th.Context, post, createdPrivateChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.NotNil(t, createdPost) + require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type) + }) +} + func TestGetBurnOnReadPost(t *testing.T) { t.Run("success - temporary post found", func(t *testing.T) { th := Setup(t).InitBasic(t) diff --git a/server/channels/app/scheduled_post_job.go b/server/channels/app/scheduled_post_job.go index 21ad5c9c6a4..36d3cafb93c 100644 --- a/server/channels/app/scheduled_post_job.go +++ b/server/channels/app/scheduled_post_job.go @@ -314,6 +314,19 @@ func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.Schedu return model.ScheduledPostErrorInvalidPost, nil } + // Validate burn-on-read restrictions for scheduled post + if appErr := PostBurnOnReadCheckWithApp("ScheduledPostJob.postChecks", a, rctx, scheduledPost.UserId, scheduledPost.ChannelId, scheduledPost.Type, channel); appErr != nil { + rctx.Logger().Debug( + "canPostScheduledPost burn-on-read check failed", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorInvalidPost), + mlog.Err(appErr), + ) + return model.ScheduledPostErrorInvalidPost, nil + } + return "", nil } diff --git a/server/i18n/en.json b/server/i18n/en.json index a5c83d84bcc..4155b177ba4 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2800,6 +2800,14 @@ "id": "api.post.error_get_post_id.pending", "translation": "Unable to get the pending post." }, + { + "id": "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", + "translation": "Burn-on-read posts are not allowed in direct messages with bots or AI agents." + }, + { + "id": "api.post.fill_in_post_props.burn_on_read.channel.app_error", + "translation": "An error occurred while validating the channel for burn-on-read post." + }, { "id": "api.post.fill_in_post_props.burn_on_read.config.app_error", "translation": "Burn-on-read posts are not enabled. Please enable the feature flag and service setting." @@ -2808,6 +2816,14 @@ "id": "api.post.fill_in_post_props.burn_on_read.license.app_error", "translation": "Burn-on-read posts require an Enterprise Advanced license." }, + { + "id": "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", + "translation": "Burn-on-read posts are not allowed when messaging yourself." + }, + { + "id": "api.post.fill_in_post_props.burn_on_read.user.app_error", + "translation": "An error occurred while validating the user for burn-on-read post." + }, { "id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error", "translation": "The AI-generated user must be either the post creator or a bot." diff --git a/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.test.tsx b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.test.tsx new file mode 100644 index 00000000000..9ea9f200297 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.test.tsx @@ -0,0 +1,428 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {renderHook} from '@testing-library/react'; +import React from 'react'; +import {Provider} from 'react-redux'; + +import type {Channel} from '@mattermost/types/channels'; +import type {PostType} from '@mattermost/types/posts'; + +import {PostTypes} from 'mattermost-redux/constants/posts'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; + +import { + isBurnOnReadEnabled, + getBurnOnReadDurationMinutes, + canUserSendBurnOnRead, +} from 'selectors/burn_on_read'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +import useBurnOnRead from './use_burn_on_read'; + +// Mock the selectors +jest.mock('selectors/burn_on_read', () => ({ + isBurnOnReadEnabled: jest.fn(), + getBurnOnReadDurationMinutes: jest.fn(), + canUserSendBurnOnRead: jest.fn(), +})); + +jest.mock('mattermost-redux/selectors/entities/channels', () => ({ + getChannel: jest.fn(), +})); + +jest.mock('mattermost-redux/selectors/entities/users', () => ({ + getCurrentUser: jest.fn(), + getUser: jest.fn(), +})); + +// Import mocked selectors + +describe('useBurnOnRead', () => { + const mockHandleDraftChange = jest.fn(); + const mockFocusTextbox = jest.fn(); + + const createMockStore = (state: Partial = {}) => ({ + getState: () => state, + dispatch: jest.fn(), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + }); + + const createMockChannel = (type: 'O' | 'P' | 'D' | 'G', name?: string): Channel => ({ + id: 'channel-id', + create_at: 0, + update_at: 0, + delete_at: 0, + team_id: 'team-id', + type, + display_name: 'Test Channel', + name: name || 'test-channel', + header: '', + purpose: '', + last_post_at: 0, + last_root_post_at: 0, + creator_id: 'user-id', + scheme_id: '', + group_constrained: false, + }); + + const createMockDraft = (type?: PostType): PostDraft => ({ + message: 'test message', + fileInfos: [], + uploadsInProgress: [], + channelId: 'channel-id', + rootId: '', + type, + props: {}, + createAt: 0, + updateAt: 0, + show: true, + }); + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + (isBurnOnReadEnabled as jest.Mock).mockReturnValue(true); + (getBurnOnReadDurationMinutes as jest.Mock).mockReturnValue(10); + (canUserSendBurnOnRead as jest.Mock).mockReturnValue(true); + (getCurrentUser as jest.Mock).mockReturnValue({id: 'user-id', is_bot: false}); + }); + + describe('button visibility in different channel types', () => { + it('should show burn-on-read button in direct messages (DM) with another user', () => { + // DM with another user - channel name is user-id__other-user-id + const dmChannel = createMockChannel('D', 'other-user-id__user-id'); + (getChannel as jest.Mock).mockReturnValue(dmChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + // DMs with another user should show the button + expect(result.current.additionalControl).toBeDefined(); + }); + + it('should hide burn-on-read button in self-DMs', () => { + // Self-DM - channel name is user-id__user-id + const selfDMChannel = createMockChannel('D', 'user-id__user-id'); + (getChannel as jest.Mock).mockReturnValue(selfDMChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + // Self-DMs should hide the button + expect(result.current.additionalControl).toBeUndefined(); + }); + + it('should hide burn-on-read button in DMs with bots/AI agents', () => { + const {getUser} = require('mattermost-redux/selectors/entities/users'); + + // DM with a bot - channel name is user-id__bot-id + const dmWithBotChannel = createMockChannel('D', 'bot-id__user-id'); + (getChannel as jest.Mock).mockReturnValue(dmWithBotChannel); + (getUser as jest.Mock).mockReturnValue({id: 'bot-id', is_bot: true}); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + // DMs with bots should hide the button + expect(result.current.additionalControl).toBeUndefined(); + }); + + it('should show burn-on-read button in group messages (GM)', () => { + const gmChannel = createMockChannel('G'); + (getChannel as jest.Mock).mockReturnValue(gmChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeDefined(); + }); + + it('should show burn-on-read button in public channels', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeDefined(); + }); + + it('should show burn-on-read button in private channels', () => { + const privateChannel = createMockChannel('P'); + (getChannel as jest.Mock).mockReturnValue(privateChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeDefined(); + }); + }); + + describe('button visibility with feature flags', () => { + it('should hide button when burn-on-read is disabled', () => { + (isBurnOnReadEnabled as jest.Mock).mockReturnValue(false); + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeUndefined(); + }); + + it('should hide button when user cannot send burn-on-read', () => { + (canUserSendBurnOnRead as jest.Mock).mockReturnValue(false); + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeUndefined(); + }); + + it('should show button when user is a bot (bots can send BoR for OTP, integrations)', () => { + (getCurrentUser as jest.Mock).mockReturnValue({id: 'bot-user-id', is_bot: true}); + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeDefined(); + }); + }); + + describe('button visibility in threads', () => { + it('should hide burn-on-read button in thread replies', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const draftWithRootId: PostDraft = { + ...createMockDraft(), + rootId: 'root-post-id', + }; + + const {result} = renderHook( + () => useBurnOnRead( + draftWithRootId, + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.additionalControl).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should hide button when channel is missing (fail-closed)', () => { + (getChannel as jest.Mock).mockReturnValue(null); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + // Should hide button when channel is missing (fail-closed: if we can't validate, don't show) + expect(result.current.additionalControl).toBeUndefined(); + }); + + it('should hide button when currentUser is missing (fail-closed principle)', () => { + (getCurrentUser as jest.Mock).mockReturnValue(null); + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + // Should hide button when currentUser is missing (fail-closed: if we can't validate, don't show) + expect(result.current.additionalControl).toBeUndefined(); + }); + }); + + describe('label visibility', () => { + it('should show label when burn-on-read is active', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const draftWithBoR = createMockDraft(PostTypes.BURN_ON_READ); + + const {result} = renderHook( + () => useBurnOnRead( + draftWithBoR, + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.labels).toBeDefined(); + }); + + it('should hide label in thread replies even if burn-on-read is active', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const draftWithBoRAndRootId: PostDraft = { + ...createMockDraft(PostTypes.BURN_ON_READ), + rootId: 'root-post-id', + }; + + const {result} = renderHook( + () => useBurnOnRead( + draftWithBoRAndRootId, + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.labels).toBeUndefined(); + }); + }); + + describe('handlers', () => { + it('should provide handleBurnOnReadApply handler', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.handleBurnOnReadApply).toBeDefined(); + expect(typeof result.current.handleBurnOnReadApply).toBe('function'); + }); + + it('should provide handleRemoveBurnOnRead handler', () => { + const publicChannel = createMockChannel('O'); + (getChannel as jest.Mock).mockReturnValue(publicChannel); + + const {result} = renderHook( + () => useBurnOnRead( + createMockDraft(), + mockHandleDraftChange, + mockFocusTextbox, + false, + true, + ), + {wrapper}, + ); + + expect(result.current.handleRemoveBurnOnRead).toBeDefined(); + expect(typeof result.current.handleRemoveBurnOnRead).toBe('function'); + }); + }); +}); diff --git a/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx index e175e393972..d751c93caf5 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx @@ -5,6 +5,9 @@ import React, {useCallback, useMemo} from 'react'; import {useSelector} from 'react-redux'; import {PostTypes} from 'mattermost-redux/constants/posts'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentUser, getUser} from 'mattermost-redux/selectors/entities/users'; +import {getDirectChannelName, getUserIdFromChannelName, isDirectChannel} from 'mattermost-redux/utils/channel_utils'; import { isBurnOnReadEnabled, @@ -18,6 +21,7 @@ import BurnOnReadTourTip from 'components/burn_on_read/burn_on_read_tour_tip'; import 'components/burn_on_read/burn_on_read_control.scss'; +import type {GlobalState} from 'types/store'; import type {PostDraft} from 'types/store/draft'; /** @@ -40,9 +44,43 @@ const useBurnOnRead = ( showIndividualCloseButton = true, ) => { const rootId = draft.rootId; + const channelId = draft.channelId; const isEnabled = useSelector(isBurnOnReadEnabled); const durationMinutes = useSelector(getBurnOnReadDurationMinutes); const canSend = useSelector(canUserSendBurnOnRead); + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); + const currentUser = useSelector(getCurrentUser); + + // Burn-on-read is not allowed in self-DMs or DMs with bots (AI agents, plugins, etc.) + const otherUserId = useMemo(() => { + if (!channel || !currentUser || !isDirectChannel(channel)) { + return null; + } + return getUserIdFromChannelName(currentUser.id, channel.name); + }, [channel, currentUser]); + + const otherUser = useSelector((state: GlobalState) => (otherUserId ? getUser(state, otherUserId) : null)); + + const isAllowedInChannel = useMemo(() => { + if (!channel || !currentUser) { + return false; // Fail-closed: if we can't validate, don't show the button + } + + // Check if it's a self-DM by comparing channel name with expected self-DM name + if (isDirectChannel(channel)) { + const selfDMName = getDirectChannelName(currentUser.id, currentUser.id); + if (channel.name === selfDMName) { + return false; // Block self-DMs + } + + // Block DMs with bots (AI agents, plugins, etc.) + if (otherUser?.is_bot) { + return false; + } + } + + return true; // Allow all other channel types + }, [channel, currentUser, otherUser]); const hasBurnOnReadSet = isEnabled && draft.type === PostTypes.BURN_ON_READ; @@ -73,7 +111,7 @@ const useBurnOnRead = ( // Button component with tour tip wrapper (in formatting bar) const additionalControl = useMemo(() => - (!rootId && isEnabled && canSend ? ( + (!rootId && isEnabled && canSend && isAllowedInChannel ? (
handleBurnOnReadApply(true)} />
- ) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]); + ) : undefined), [rootId, isEnabled, canSend, isAllowedInChannel, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]); return { labels,