This commit is contained in:
Pablo Vélez 2026-02-04 03:04:39 +02:00 committed by GitHub
commit 019bdca4f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 717 additions and 2 deletions

View file

@ -602,6 +602,49 @@ func (a *App) FillInPostProps(rctx request.CTX, post *model.Post, channel *model
return model.NewAppError("FillInPostProps", "api.post.fill_in_post_props.burn_on_read.config.app_error", nil, "", http.StatusNotImplemented)
}
// Get user to check if they're a bot
user, err := a.GetUser(post.UserId)
if err != nil {
return model.NewAppError("FillInPostProps", "api.post.fill_in_post_props.burn_on_read.user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Burn-on-read is not allowed for bot users
if user.IsBot {
return model.NewAppError("FillInPostProps", "api.post.fill_in_post_props.burn_on_read.bot.app_error", nil, "", http.StatusBadRequest)
}
// Get channel if not provided - needed for validation
if channel == nil {
ch, err := a.GetChannel(rctx, post.ChannelId)
if err != nil {
return model.NewAppError("FillInPostProps", "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(post.UserId, post.UserId)
if channel.Name == selfDMName {
return model.NewAppError("FillInPostProps", "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(post.UserId)
if otherUserId != "" && otherUserId != post.UserId {
otherUser, err := a.GetUser(otherUserId)
if err != nil {
// Data integrity issue: can't validate the other user (e.g., deleted user, DB error)
// Block the burn-on-read post as we can't ensure it's valid
return model.NewAppError("FillInPostProps", "api.post.fill_in_post_props.burn_on_read.user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if otherUser.IsBot {
return model.NewAppError("FillInPostProps", "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", nil, "", http.StatusBadRequest)
}
}
}
// Apply burn-on-read expiration settings from configuration
maxTTLSeconds := int64(model.SafeDereference(a.Config().ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds))
readDurationSeconds := int64(model.SafeDereference(a.Config().ServiceSettings.BurnOnReadDurationSeconds))

View file

@ -5322,3 +5322,206 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) {
require.Greater(t, post.Metadata.ExpireAt, model.GetMillis())
})
}
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 reject 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 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)
// Try to create a burn-on-read post as bot
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This is a burn-on-read message from bot",
UserId: botUser.Id,
Type: model.PostTypeBurnOnRead,
}
_, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.bot.app_error", err.Id)
})
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)
})
}

View file

@ -2760,6 +2760,18 @@
"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.app_error",
"translation": "Bot users cannot send burn-on-read posts."
},
{
"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."
@ -2768,6 +2780,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."

View file

@ -0,0 +1,408 @@
// 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<GlobalState> = {}) => ({
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}) => (
<Provider store={createMockStore()}>
{children}
</Provider>
);
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 hide button when user is a bot', () => {
(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).toBeUndefined();
});
});
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 handle missing channel gracefully', () => {
(getChannel as jest.Mock).mockReturnValue(null);
const {result} = renderHook(
() => useBurnOnRead(
createMockDraft(),
mockHandleDraftChange,
mockFocusTextbox,
false,
true,
),
{wrapper},
);
// Should show button when channel is not available (defaults to true)
expect(result.current.additionalControl).toBeDefined();
});
});
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');
});
});
});

View file

@ -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,46 @@ 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 for bot users
const isNotBot = !currentUser?.is_bot;
// 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 true;
}
// 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 +114,7 @@ const useBurnOnRead = (
// Button component with tour tip wrapper (in formatting bar)
const additionalControl = useMemo(() =>
(!rootId && isEnabled && canSend ? (
(!rootId && isEnabled && canSend && isAllowedInChannel && isNotBot ? (
<div
key='burn-on-read-control-key'
className='BurnOnReadControl'
@ -90,7 +131,7 @@ const useBurnOnRead = (
onTryItOut={() => handleBurnOnReadApply(true)}
/>
</div>
) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
) : undefined), [rootId, isEnabled, canSend, isAllowedInChannel, isNotBot, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
return {
labels,