mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
Merge e870ec0d64 into 0263262ef4
This commit is contained in:
commit
019bdca4f8
5 changed files with 717 additions and 2 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue