MM-66244 - add BoR visual components to message editor (#34455)

* MM-66244 - add BoR visual components to message editor

* add test coverage and fix linter

* fix linter

* implement pr ux feedback

* add translation

* fix unit test

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Pablo Vélez 2025-11-20 18:10:08 +01:00 committed by GitHub
parent 1bbad99867
commit 2c997945b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1242 additions and 433 deletions

View file

@ -148,6 +148,8 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["PersistentNotificationMaxCount"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxCount), 10)
props["PersistentNotificationIntervalMinutes"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationIntervalMinutes), 10)
props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10)
props["EnableBurnOnRead"] = strconv.FormatBool(*c.ServiceSettings.EnableBurnOnRead)
props["BurnOnReadDurationMinutes"] = *c.ServiceSettings.BurnOnReadDurationMinutes
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
props["YoutubeReferrerPolicy"] = strconv.FormatBool(*c.ExperimentalSettings.YoutubeReferrerPolicy)

View file

@ -383,6 +383,46 @@ func TestGetClientConfig(t *testing.T) {
"EnableUserManagedAttributes": "false",
},
},
{
"burn on read enabled",
&model.Config{
ServiceSettings: model.ServiceSettings{
EnableBurnOnRead: model.NewPointer(true),
BurnOnReadDurationMinutes: model.NewPointer("30"),
},
},
"",
nil,
map[string]string{
"EnableBurnOnRead": "true",
"BurnOnReadDurationMinutes": "30",
},
},
{
"burn on read disabled",
&model.Config{
ServiceSettings: model.ServiceSettings{
EnableBurnOnRead: model.NewPointer(false),
BurnOnReadDurationMinutes: model.NewPointer("10"),
},
},
"",
nil,
map[string]string{
"EnableBurnOnRead": "false",
"BurnOnReadDurationMinutes": "10",
},
},
{
"burn on read default",
&model.Config{},
"",
nil,
map[string]string{
"EnableBurnOnRead": "false",
"BurnOnReadDurationMinutes": "10",
},
},
}
for _, testCase := range testCases {

View file

@ -416,20 +416,22 @@ type ServiceSettings struct {
EnableAPIUserDeletion *bool
EnableAPIPostDeletion *bool
EnableDesktopLandingPage *bool
ExperimentalEnableHardenedMode *bool `access:"experimental_features"`
ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
EnableEmailInvitations *bool `access:"authentication_signup"`
DisableBotsWhenOwnerIsDeactivated *bool `access:"integrations_bot_accounts"`
EnableBotAccountCreation *bool `access:"integrations_bot_accounts"`
EnableSVGs *bool `access:"site_posts"`
EnableLatex *bool `access:"site_posts"`
EnableInlineLatex *bool `access:"site_posts"`
PostPriority *bool `access:"site_posts"`
AllowPersistentNotifications *bool `access:"site_posts"`
AllowPersistentNotificationsForGuests *bool `access:"site_posts"`
PersistentNotificationIntervalMinutes *int `access:"site_posts"`
PersistentNotificationMaxCount *int `access:"site_posts"`
PersistentNotificationMaxRecipients *int `access:"site_posts"`
ExperimentalEnableHardenedMode *bool `access:"experimental_features"`
ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
EnableEmailInvitations *bool `access:"authentication_signup"`
DisableBotsWhenOwnerIsDeactivated *bool `access:"integrations_bot_accounts"`
EnableBotAccountCreation *bool `access:"integrations_bot_accounts"`
EnableSVGs *bool `access:"site_posts"`
EnableLatex *bool `access:"site_posts"`
EnableInlineLatex *bool `access:"site_posts"`
PostPriority *bool `access:"site_posts"`
AllowPersistentNotifications *bool `access:"site_posts"`
AllowPersistentNotificationsForGuests *bool `access:"site_posts"`
PersistentNotificationIntervalMinutes *int `access:"site_posts"`
PersistentNotificationMaxCount *int `access:"site_posts"`
PersistentNotificationMaxRecipients *int `access:"site_posts"`
EnableBurnOnRead *bool `access:"site_posts"`
BurnOnReadDurationMinutes *string `access:"site_posts"`
EnableAPIChannelDeletion *bool
EnableLocalMode *bool `access:"cloud_restrictable"`
LocalModeSocketLocation *string `access:"cloud_restrictable"` // telemetry: none
@ -975,6 +977,14 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
s.RefreshPostStatsRunTime = NewPointer("00:00")
}
if s.EnableBurnOnRead == nil {
s.EnableBurnOnRead = NewPointer(false)
}
if s.BurnOnReadDurationMinutes == nil {
s.BurnOnReadDurationMinutes = NewPointer("10")
}
if s.MaximumPayloadSizeBytes == nil {
s.MaximumPayloadSizeBytes = NewPointer(int64(300000))
}

View file

@ -55,7 +55,6 @@ import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchable
import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info';
import CompanyInfoEdit from './billing/company_info_edit';
import BrandImageSetting from './brand_image_setting/brand_image_setting';
import BurnOnReadUserGroupSelector from './burn_on_read_user_group_selector';
import ClientSideUserIdsSetting from './client_side_userids_setting';
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
import CustomEnableDisableGuestAccountsMagicLinkSetting from './custom_enable_disable_guest_accounts_magic_link_setting';
@ -3195,62 +3194,6 @@ const AdminDefinition: AdminDefinitionType = {
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
),
},
{
type: 'radio',
key: 'ServiceSettings.BurnOnReadAllowedUsers',
label: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.title', defaultMessage: 'Users Allowed to Send Burn-on-Read Messages'}),
options: [
{
value: 'all',
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.all', defaultMessage: 'Allow for all users'}),
},
{
value: 'allow_selected',
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.allowSelected', defaultMessage: 'Allow selected users'}),
},
{
value: 'block_selected',
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.blockSelected', defaultMessage: 'Block selected users'}),
},
],
onConfigLoad: (value: any) => value ?? 'all',
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
),
},
{
type: 'custom',
key: 'ServiceSettings.BurnOnReadAllowedUsersList',
label: defineMessage({id: 'admin.posts.burnOnRead.usersList.title', defaultMessage: 'Selected users and groups'}),
help_text: defineMessage({id: 'admin.posts.burnOnRead.usersList.desc', defaultMessage: 'Choose users or groups that will be allowed or blocked from sending burn-on-read messages, depending on your selection above.'}),
component: BurnOnReadUserGroupSelector,
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
),
isHidden: it.any(
it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all'),
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
),
validate: (value) => {
const isEmpty = !value ||
(typeof value === 'string' && value.trim() === '') ||
(Array.isArray(value) && value.length === 0);
if (isEmpty) {
return new ValidationResult(
false,
defineMessage({
id: 'admin.posts.burnOnRead.usersList.required',
defaultMessage: 'At least one user or group must be selected.',
}),
);
}
return new ValidationResult(true, '');
},
},
],
},
{

View file

@ -27,13 +27,9 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
// Find Burn-on-Read settings
const enableSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.EnableBurnOnRead');
const durationSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadDurationMinutes');
const allowedUsersSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsers');
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
expect(enableSetting).toBeDefined();
expect(durationSetting).toBeDefined();
expect(allowedUsersSetting).toBeDefined();
expect(usersListSetting).toBeDefined();
});
test('EnableBurnOnRead setting should have correct configuration', () => {
@ -73,36 +69,6 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
}
});
test('BurnOnReadAllowedUsers setting should have correct radio options', () => {
const allSettings = getAllSettings();
const allowedUsersSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsers');
expect(allowedUsersSetting?.type).toBe('radio');
expect(allowedUsersSetting?.onConfigLoad).toBeDefined();
// Check that all expected radio options are present
if ('options' in allowedUsersSetting!) {
const options = allowedUsersSetting.options;
const optionValues = options?.map((opt: any) => opt.value);
expect(optionValues).toContain('all');
expect(optionValues).toContain('allow_selected');
expect(optionValues).toContain('block_selected');
}
});
test('BurnOnReadAllowedUsersList setting should be custom component', () => {
const allSettings = getAllSettings();
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
expect(usersListSetting?.type).toBe('custom');
if (usersListSetting && 'component' in usersListSetting) {
expect(usersListSetting.component).toBeDefined();
}
expect(usersListSetting?.label).toBeDefined();
expect(usersListSetting?.help_text).toBeDefined();
});
test('all Burn-on-Read settings should have proper permission checks', () => {
const allSettings = getAllSettings();
const burnOnReadSettings = allSettings.filter((s: AdminDefinitionSetting) =>
@ -156,11 +122,5 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
expect(burnOnReadSection?.componentProps?.requiredSku).toBe(LicenseSkus.EnterpriseAdvanced);
expect(burnOnReadSection?.componentProps?.featureDiscoveryConfig).toBeDefined();
expect(burnOnReadSection?.componentProps?.featureDiscoveryConfig?.featureName).toBe('burn_on_read');
// User selector should have visibility logic based on EnableBurnOnRead and AllowedUsers settings
const allSettings = getAllSettings();
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
expect(usersListSetting?.isHidden).toBeDefined();
expect(typeof usersListSetting?.isHidden).toBe('function');
});
});

View file

@ -6,41 +6,41 @@ import {it} from './admin_definition_helpers';
describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => {
test('should return true when state value equals expected value', () => {
const state = {
'ServiceSettings.BurnOnReadAllowedUsers': 'all',
'ServiceSettings.BurnOnReadDurationMinutes': '10',
};
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
expect(checker({}, state)).toBe(true);
});
test('should return true when state value is null and expected value equals default', () => {
const state = {
'ServiceSettings.BurnOnReadAllowedUsers': null,
'ServiceSettings.BurnOnReadDurationMinutes': null,
};
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
expect(checker({}, state)).toBe(true);
});
test('should return true when state value is undefined and expected value equals default', () => {
const state = {};
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
expect(checker({}, state)).toBe(true);
});
test('should return false for non-matching values', () => {
const mismatchedState = {'ServiceSettings.BurnOnReadAllowedUsers': 'block_selected'};
const nullStateWithDifferentExpected = {'ServiceSettings.BurnOnReadAllowedUsers': null};
const mismatchedState = {'ServiceSettings.BurnOnReadDurationMinutes': '30'};
const nullStateWithDifferentExpected = {'ServiceSettings.BurnOnReadDurationMinutes': null};
const undefinedStateWithDifferentExpected = {};
// Checking for 'allow_selected' when state has 'block_selected' should return false
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'allow_selected', 'all');
// Checking for '5' when state has '30' should return false
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '5', '10');
expect(checker({}, mismatchedState)).toBe(false);
// When checking for 'allow_selected' with default 'all', null/undefined should return false
// because null/undefined would be treated as 'all' (the default), not 'allow_selected'
// When checking for '5' with default '10', null/undefined should return false
// because null/undefined would be treated as '10' (the default), not '5'
expect(checker({}, nullStateWithDifferentExpected)).toBe(false);
expect(checker({}, undefinedStateWithDifferentExpected)).toBe(false);
});

View file

@ -1,225 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import BurnOnReadUserGroupSelector from './burn_on_read_user_group_selector';
// Mock the UserSelector component from content_flagging
jest.mock('../content_flagging/user_multiselector/user_multiselector', () => ({
UserSelector: ({id, isMulti, multiSelectInitialValue, multiSelectOnChange, placeholder, enableGroups, enableTeams, disabled}: any) => {
const handleClick = () => {
// Simulate UserSelector onChange with array of IDs
if (multiSelectOnChange && !disabled) {
multiSelectOnChange(['user1', 'user2']);
}
};
return (
<div data-testid='user-selector'>
<input
data-testid={id}
data-is-multi={String(isMulti)}
data-initial-value={JSON.stringify(multiSelectInitialValue)}
data-enable-groups={String(enableGroups)}
data-enable-teams={String(enableTeams)}
data-disabled={String(disabled)}
placeholder={placeholder}
disabled={Boolean(disabled)}
onClick={handleClick}
readOnly={true}
/>
</div>
);
},
}));
describe('components/admin_console/burn_on_read_user_group_selector/BurnOnReadUserGroupSelector', () => {
const baseProps = {
id: 'ServiceSettings.BurnOnReadAllowedUsersList',
label: 'Selected Users and Groups',
helpText: 'Choose specific users or groups...',
value: '',
onChange: jest.fn(),
disabled: false,
setByEnv: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
test('should render with basic props', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
expect(screen.getByText('Selected Users and Groups')).toBeInTheDocument();
expect(screen.getByText('Choose specific users or groups...')).toBeInTheDocument();
expect(screen.getByTestId('user-selector')).toBeInTheDocument();
});
test('should pass enableGroups=true to UserSelector', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1]; // Get the input element (last one)
expect(input.getAttribute('data-enable-groups')).toBe('true');
});
test('should parse comma-separated string value into array', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
value='user1,user2,group1'
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
expect(initialValue).toEqual(['user1', 'user2', 'group1']);
});
test('should handle array value directly', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
value={['user1', 'user2']}
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
expect(initialValue).toEqual(['user1', 'user2']);
});
test('should handle empty string value', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
value=''
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
expect(initialValue).toEqual([]);
});
test('should handle undefined value', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
value={undefined}
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
expect(initialValue).toEqual([]);
});
test('should filter out empty strings when parsing comma-separated value', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
value='user1,,user2,'
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
expect(initialValue).toEqual(['user1', 'user2']);
});
test('should convert array onChange callback to comma-separated string', () => {
const onChange = jest.fn();
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
onChange={onChange}
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1] as HTMLInputElement;
input.click(); // Trigger the mock's onClick handler
expect(onChange).toHaveBeenCalledWith(baseProps.id, 'user1,user2');
});
test('should pass disabled prop to UserSelector', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
disabled={true}
/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
expect(input.getAttribute('data-disabled')).toBe('true');
expect(input).toBeDisabled();
});
test('should pass setByEnv to Setting component', () => {
renderWithContext(
<BurnOnReadUserGroupSelector
{...baseProps}
setByEnv={true}
/>,
);
// Setting component should render with setByEnv indicator
expect(screen.getByText('Selected Users and Groups')).toBeInTheDocument();
});
test('should use correct placeholder text', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
expect(screen.getByPlaceholderText('Start typing to search for users, groups, and teams...')).toBeInTheDocument();
});
test('should pass isMulti=true to UserSelector', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
expect(input.getAttribute('data-is-multi')).toBe('true');
});
// Team support tests
test('should enable teams in UserSelector', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1];
expect(input.getAttribute('data-enable-teams')).toBe('true');
});
test('should have placeholder mentioning teams', () => {
renderWithContext(
<BurnOnReadUserGroupSelector {...baseProps}/>,
);
const inputs = screen.getAllByTestId(baseProps.id);
const input = inputs[inputs.length - 1] as HTMLInputElement;
expect(input.placeholder).toContain('teams');
});
});

View file

@ -1,69 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Setting from 'components/admin_console/setting';
import {UserSelector} from '../content_flagging/user_multiselector/user_multiselector';
type Props = {
id: string;
label: React.ReactNode;
helpText: React.ReactNode;
value?: string | string[];
onChange: (id: string, value: string) => void;
disabled?: boolean;
setByEnv?: boolean;
};
const BurnOnReadUserGroupSelector: React.FC<Props> = ({
id,
label,
helpText,
value,
onChange,
disabled = false,
setByEnv = false,
}) => {
// Parse value - can be string (comma-separated) or string array
// Content flagging UserSelector expects array of user IDs
const parsedValue = React.useMemo(() => {
if (!value) {
return [];
}
if (typeof value === 'string') {
return value.split(',').filter(Boolean);
}
return value;
}, [value]);
// Handle onChange from UserSelector
// UserSelector passes array of user IDs, we need to convert to comma-separated string
const handleChange = React.useCallback((selectedUserIds: string[]) => {
const stringValue = selectedUserIds.join(',');
onChange(id, stringValue);
}, [onChange, id]);
return (
<Setting
label={label}
helpText={helpText}
inputId={id}
setByEnv={setByEnv}
>
<UserSelector
id={id}
isMulti={true}
multiSelectInitialValue={parsedValue}
multiSelectOnChange={handleChange}
placeholder='Start typing to search for users, groups, and teams...'
enableGroups={true}
enableTeams={true}
disabled={disabled}
/>
</Setting>
);
};
export default BurnOnReadUserGroupSelector;

View file

@ -195,6 +195,14 @@
}
}
&__labels {
display: flex;
flex-wrap: wrap;
align-items: center;
padding-left: 16px;
gap: 8px;
}
}
@media screen and (max-width: 768px) {

View file

@ -77,6 +77,8 @@ import SendButton from './send_button';
import ShowFormat from './show_formatting';
import TexteditorActions from './texteditor_actions';
import ToggleFormattingBar from './toggle_formatting_bar';
import UnifiedLabelsWrapper from './unified_labels_wrapper';
import useBurnOnRead from './use_burn_on_read';
import useEditorEmojiPicker from './use_editor_emoji_picker';
import useKeyHandler from './use_key_handler';
import useOrientationHandler from './use_orientation_handler';
@ -350,7 +352,11 @@ const AdvancedTextEditor = ({
additionalControl: priorityAdditionalControl,
isValidPersistentNotifications,
onSubmitCheck: prioritySubmitCheck,
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview);
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview, false);
const {
labels: burnOnReadLabels,
additionalControl: burnOnReadAdditionalControl,
} = useBurnOnRead(draft, handleDraftChange, focusTextbox, showPreview, false);
const [handleSubmit, errorClass] = useSubmit(
draft,
postError,
@ -396,6 +402,22 @@ const AdvancedTextEditor = ({
});
}, [handleDraftChange, channelId, rootId]);
// Unified handler to remove all labels (priority + burn on read)
const handleRemoveAllLabels = useCallback(() => {
// Remove both priority and burn_on_read in a single draft update
const updatedDraft = {
...draft,
};
// Remove both priority and burn_on_read from metadata
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
const {priority: _priority, burn_on_read: _burnOnRead, ...restMetadata} = updatedDraft.metadata || {};
updatedDraft.metadata = restMetadata;
handleDraftChange(updatedDraft, {instant: true});
focusTextbox();
}, [draft, handleDraftChange, focusTextbox]);
const handleSubmitWrapper = useCallback(() => {
const isEmptyPost = isPostDraftEmpty(draft);
@ -687,8 +709,9 @@ const AdvancedTextEditor = ({
const additionalControls = useMemo(() => [
!isInEditMode && priorityAdditionalControl,
aiRewriteEnabled && aiRewriteAdditionalControl,
!isInEditMode && burnOnReadAdditionalControl,
...(pluginItems || []),
].filter(Boolean), [pluginItems, priorityAdditionalControl, aiRewriteAdditionalControl, isInEditMode, aiRewriteEnabled]);
].filter(Boolean), [pluginItems, priorityAdditionalControl, aiRewriteAdditionalControl, isInEditMode, aiRewriteEnabled, burnOnReadAdditionalControl]);
const formattingBar = (
<AutoHeightSwitcher
@ -784,9 +807,18 @@ const AdvancedTextEditor = ({
tabIndex={-1}
className='AdvancedTextEditor__cell a11y__region'
>
{!isInEditMode && priorityLabels}
{!isInEditMode && (priorityLabels || burnOnReadLabels) && (
<div className='AdvancedTextEditor__labels'>
<UnifiedLabelsWrapper
priorityLabels={priorityLabels}
burnOnReadLabels={burnOnReadLabels}
onRemoveAll={handleRemoveAllLabels}
canRemove={!showPreview}
/>
</div>
)}
<Textbox
hasLabels={isInEditMode ? false : Boolean(priorityLabels)}
hasLabels={isInEditMode ? false : Boolean(priorityLabels || burnOnReadLabels)}
suggestionList={location === Locations.RHS_COMMENT ? RhsSuggestionList : SuggestionList}
onChange={handleChange}
onKeyPress={postMsgKeyPress}

View file

@ -1,7 +1,7 @@
.priorityLabelsContainer {
display: flex;
align-items: center;
padding: 14px 16px 0;
padding: 16px 0;
gap: 6px;
&:hover {

View file

@ -0,0 +1,37 @@
.UnifiedLabelsWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
&__close {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease;
svg {
display: block;
}
}
&:hover &__close {
opacity: 1;
}
&__close:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
&__close:active {
background: rgba(var(--center-channel-color-rgb), 0.16);
}
}

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {CloseIcon} from '@mattermost/compass-icons/components';
import './unified_labels_wrapper.scss';
type Props = {
priorityLabels?: JSX.Element;
burnOnReadLabels?: JSX.Element;
onRemoveAll?: () => void;
canRemove: boolean;
};
const UnifiedLabelsWrapper = ({
priorityLabels,
burnOnReadLabels,
onRemoveAll,
canRemove,
}: Props) => {
const {formatMessage} = useIntl();
// Handle click and prevent form submission
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
onRemoveAll?.();
}, [onRemoveAll]);
// Don't render if no labels
if (!priorityLabels && !burnOnReadLabels) {
return null;
}
return (
<div className='UnifiedLabelsWrapper'>
{priorityLabels}
{burnOnReadLabels}
{canRemove && onRemoveAll && (
<button
type='button'
className='UnifiedLabelsWrapper__close'
onClick={handleClick}
aria-label={formatMessage({
id: 'unified_labels.remove_all',
defaultMessage: 'Remove all labels',
})}
>
<CloseIcon size={14}/>
</button>
)}
</div>
);
};
export default UnifiedLabelsWrapper;

View file

@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useSelector} from 'react-redux';
import {
isBurnOnReadEnabled,
getBurnOnReadDurationMinutes,
canUserSendBurnOnRead,
} from 'selectors/burn_on_read';
import BurnOnReadButton from 'components/burn_on_read/burn_on_read_button';
import BurnOnReadLabel from 'components/burn_on_read/burn_on_read_label';
import BurnOnReadTourTip from 'components/burn_on_read/burn_on_read_tour_tip';
import 'components/burn_on_read/burn_on_read_control.scss';
import type {PostDraft} from 'types/store/draft';
/**
* Hook that manages Burn-on-Read functionality in the message composer.
* Provides the BoR button, label, and tour tip components, along with handlers
* to toggle BoR mode on/off for the current draft.
*
* @param draft - The current post draft
* @param handleDraftChange - Callback to update the draft
* @param focusTextbox - Callback to refocus the text editor
* @param shouldShowPreview - Whether the preview mode is active
* @param showIndividualCloseButton - Whether to show individual close button on label
* @returns Object containing label and control components, plus handlers
*/
const useBurnOnRead = (
draft: PostDraft,
handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void,
focusTextbox: (keepFocus?: boolean) => void,
shouldShowPreview: boolean,
showIndividualCloseButton = true,
) => {
const rootId = draft.rootId;
const isEnabled = useSelector(isBurnOnReadEnabled);
const durationMinutes = useSelector(getBurnOnReadDurationMinutes);
const canSend = useSelector(canUserSendBurnOnRead);
// Check if BoR is active in draft
const hasBurnOnReadSet = isEnabled &&
draft.metadata?.burn_on_read?.enabled === true;
// Handler to toggle BoR mode
const handleBurnOnReadApply = useCallback((enabled: boolean) => {
const updatedDraft = {
...draft,
};
if (enabled) {
updatedDraft.metadata = {
...updatedDraft.metadata,
burn_on_read: {
enabled: true,
},
};
} else {
// Remove burn_on_read from metadata
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
const {burn_on_read: _, ...restMetadata} = updatedDraft.metadata || {};
updatedDraft.metadata = restMetadata;
}
handleDraftChange(updatedDraft, {instant: true});
focusTextbox();
}, [draft, handleDraftChange, focusTextbox]);
const handleRemoveBurnOnRead = useCallback(() => {
handleBurnOnReadApply(false);
}, [handleBurnOnReadApply]);
// Label component (shows above editor when active)
const labels = useMemo(() => (
(hasBurnOnReadSet && !rootId) ? (
<BurnOnReadLabel
canRemove={showIndividualCloseButton && !shouldShowPreview}
onRemove={handleRemoveBurnOnRead}
durationMinutes={durationMinutes}
/>
) : undefined
), [hasBurnOnReadSet, rootId, showIndividualCloseButton, shouldShowPreview, handleRemoveBurnOnRead, durationMinutes]);
// Button component with tour tip wrapper (in formatting bar)
const additionalControl = useMemo(() =>
(!rootId && isEnabled && canSend ? (
<div
key='burn-on-read-control-key'
className='BurnOnReadControl'
>
<BurnOnReadButton
key='burn-on-read-button-key'
enabled={hasBurnOnReadSet}
onToggle={handleBurnOnReadApply}
disabled={shouldShowPreview}
durationMinutes={durationMinutes}
/>
<BurnOnReadTourTip
key='burn-on-read-tour-tip-key'
onTryItOut={() => handleBurnOnReadApply(true)}
/>
</div>
) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
return {
labels,
additionalControl,
handleBurnOnReadApply,
handleRemoveBurnOnRead,
};
};
export default useBurnOnRead;

View file

@ -30,6 +30,7 @@ const usePriority = (
handleDraftChange: ((draft: PostDraft, options: { instant?: boolean; show?: boolean }) => void),
focusTextbox: (keepFocus?: boolean) => void,
shouldShowPreview: boolean,
showIndividualCloseButton = true,
) => {
const dispatch = useDispatch();
const rootId = draft.rootId;
@ -87,6 +88,7 @@ const usePriority = (
if (settings?.priority || settings?.requested_ack) {
updatedDraft.metadata = {
...updatedDraft.metadata,
priority: {
...settings,
priority: settings!.priority || '',
@ -94,7 +96,10 @@ const usePriority = (
},
};
} else {
updatedDraft.metadata = {};
// Remove priority but keep other metadata
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {priority, ...restMetadata} = updatedDraft.metadata || {};
updatedDraft.metadata = restMetadata;
}
handleDraftChange(updatedDraft, {instant: true});
@ -137,7 +142,7 @@ const usePriority = (
const labels = useMemo(() => (
(hasPrioritySet && !rootId) ? (
<PriorityLabels
canRemove={!shouldShowPreview}
canRemove={showIndividualCloseButton && !shouldShowPreview}
hasError={!isValidPersistentNotifications}
specialMentions={specialMentions}
onRemove={handleRemovePriority}
@ -146,7 +151,7 @@ const usePriority = (
requestedAck={draft!.metadata!.priority?.requested_ack}
/>
) : undefined
), [hasPrioritySet, rootId, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
), [hasPrioritySet, rootId, showIndividualCloseButton, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
const additionalControl = useMemo(() =>
!rootId && isPostPriorityEnabled && (
@ -164,6 +169,7 @@ const usePriority = (
additionalControl,
isValidPersistentNotifications,
onSubmitCheck,
handleRemovePriority,
};
};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
import BurnOnReadButton from './burn_on_read_button';
jest.mock('components/with_tooltip', () => {
return ({children}: { children: React.ReactNode }) => <div>{children}</div>;
});
describe('BurnOnReadButton', () => {
const defaultProps = {
enabled: false,
onToggle: jest.fn(),
disabled: false,
durationMinutes: 10,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render correctly when disabled', () => {
renderWithContext(
<BurnOnReadButton {...defaultProps}/>,
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('id', 'burnOnReadButton');
});
it('should render correctly when enabled', () => {
renderWithContext(
<BurnOnReadButton
{...defaultProps}
enabled={true}
/>,
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('control');
});
it('should call onToggle with true when clicked while disabled', () => {
const onToggle = jest.fn();
renderWithContext(
<BurnOnReadButton
{...defaultProps}
enabled={false}
onToggle={onToggle}
/>,
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onToggle).toHaveBeenCalledWith(true);
});
it('should call onToggle with false when clicked while enabled', () => {
const onToggle = jest.fn();
renderWithContext(
<BurnOnReadButton
{...defaultProps}
enabled={true}
onToggle={onToggle}
/>,
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onToggle).toHaveBeenCalledWith(false);
});
it('should not be clickable when disabled prop is true', () => {
const onToggle = jest.fn();
renderWithContext(
<BurnOnReadButton
{...defaultProps}
disabled={true}
onToggle={onToggle}
/>,
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
it('should display correct duration in tooltip', () => {
renderWithContext(
<BurnOnReadButton
{...defaultProps}
durationMinutes={15}
/>,
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', expect.stringContaining('15 minutes'));
});
});

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {useIntl} from 'react-intl';
import {FireIcon} from '@mattermost/compass-icons/components';
import {IconContainer} from 'components/advanced_text_editor/formatting_bar/formatting_icon';
import WithTooltip from 'components/with_tooltip';
type Props = {
// Whether Burn-on-Read mode is currently enabled for the draft
enabled: boolean;
// Callback when the button is clicked to toggle BoR on/off
onToggle: (enabled: boolean) => void;
// Whether the button should be disabled (e.g., in preview mode)
disabled: boolean;
// The configured duration in minutes for BoR messages
durationMinutes: number;
}
const BurnOnReadButton = ({enabled, onToggle, disabled, durationMinutes}: Props) => {
const {formatMessage} = useIntl();
const handleClick = () => {
onToggle(!enabled);
};
const tooltipTitle = formatMessage(
{
id: 'burn_on_read.button.tooltip.title',
defaultMessage: 'Burn-on-read',
},
);
const tooltipHint = formatMessage(
{
id: 'burn_on_read.button.tooltip.hint',
defaultMessage: 'Message will be deleted for a recipient {duration} minutes after they open it',
},
{duration: durationMinutes},
);
const tooltipMessage = `${tooltipTitle}: ${tooltipHint}`;
return (
<WithTooltip
title={tooltipTitle}
hint={tooltipHint}
>
<IconContainer
id='burnOnReadButton'
className='control'
disabled={disabled}
type='button'
aria-label={tooltipMessage}
onClick={handleClick}
>
<FireIcon
size={18}
color='currentColor'
/>
</IconContainer>
</WithTooltip>
);
};
export default memo(BurnOnReadButton);

View file

@ -1,4 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './burn_on_read_user_group_selector';
.BurnOnReadControl {
position: relative;
display: inline-flex;
align-items: center;
}

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.BurnOnReadLabel {
display: flex;
width: fit-content;
align-items: center;
padding: 16px 0px;
gap: 4px;
&__badge {
display: flex;
height: 16px;
align-items: center;
padding: 0 6px;
border-radius: 4px;
background-color: rgba(var(--error-text-color-rgb), 0.08);
color: var(--error-text);
font-size: 10px;
font-weight: 600;
gap: 4px;
}
&__icon {
display: flex;
align-items: center;
color: var(--error-text);
fill: currentColor;
}
&__text {
letter-spacing: 0.5px;
}
&__close {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease;
}
&:hover &__close {
opacity: 1;
}
&__close:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
color: rgb(var(--button-bg-rgb));
}
&__close:active {
background-color: rgba(var(--button-bg-rgb), 0.16);
color: rgb(var(--button-bg-rgb));
}
}

View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
import BurnOnReadLabel from './burn_on_read_label';
describe('BurnOnReadLabel', () => {
const defaultProps = {
canRemove: true,
onRemove: jest.fn(),
durationMinutes: 10,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render correctly with duration', () => {
renderWithContext(
<BurnOnReadLabel {...defaultProps}/>,
);
expect(screen.getByText(/BURN ON READ \(10m\)/i)).toBeInTheDocument();
});
it('should display custom duration', () => {
renderWithContext(
<BurnOnReadLabel
{...defaultProps}
durationMinutes={15}
/>,
);
expect(screen.getByText(/BURN ON READ \(15m\)/i)).toBeInTheDocument();
});
it('should render close button when canRemove is true', () => {
renderWithContext(
<BurnOnReadLabel
{...defaultProps}
canRemove={true}
/>,
);
const closeButton = screen.getByRole('button');
expect(closeButton).toBeInTheDocument();
expect(closeButton).toHaveAttribute('aria-label', 'Remove burn-on-read');
});
it('should not render close button when canRemove is false', () => {
renderWithContext(
<BurnOnReadLabel
{...defaultProps}
canRemove={false}
/>,
);
const closeButton = screen.queryByRole('button');
expect(closeButton).not.toBeInTheDocument();
});
it('should call onRemove when close button is clicked', () => {
const onRemove = jest.fn();
renderWithContext(
<BurnOnReadLabel
{...defaultProps}
onRemove={onRemove}
/>,
);
const closeButton = screen.getByRole('button');
fireEvent.click(closeButton);
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('should have correct CSS classes', () => {
const {container} = renderWithContext(
<BurnOnReadLabel {...defaultProps}/>,
);
expect(container.querySelector('.BurnOnReadLabel')).toBeInTheDocument();
expect(container.querySelector('.BurnOnReadLabel__badge')).toBeInTheDocument();
expect(container.querySelector('.BurnOnReadLabel__icon')).toBeInTheDocument();
expect(container.querySelector('.BurnOnReadLabel__text')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {useIntl} from 'react-intl';
import {CloseIcon, FireIcon} from '@mattermost/compass-icons/components';
import './burn_on_read_label.scss';
type Props = {
// Whether the close button should be shown
canRemove: boolean;
// Callback when the close button is clicked
onRemove: () => void;
// The configured duration in minutes for BoR messages
durationMinutes: number;
}
const BurnOnReadLabel = ({canRemove, onRemove, durationMinutes}: Props) => {
const {formatMessage} = useIntl();
return (
<div className='BurnOnReadLabel'>
<div className='BurnOnReadLabel__badge'>
<FireIcon
size={10}
className='BurnOnReadLabel__icon'
/>
<span className='BurnOnReadLabel__text'>
{formatMessage(
{
id: 'burn_on_read.label.text',
defaultMessage: 'BURN ON READ ({duration}m)',
},
{duration: durationMinutes},
)}
</span>
</div>
{canRemove && (
<button
className='BurnOnReadLabel__close'
onClick={onRemove}
aria-label={formatMessage({
id: 'burn_on_read.label.remove',
defaultMessage: 'Remove burn-on-read',
})}
>
<CloseIcon size={14}/>
</button>
)}
</div>
);
};
export default memo(BurnOnReadLabel);

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.BurnOnReadTourTip {
&__title {
display: flex;
align-items: center;
gap: 8px;
}
&__badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
background-color: var(--online-indicator);
color: var(--button-color);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
&__demo {
display: flex;
border-radius: 4px;
margin-top: 16px !important;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
&__demo-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: rgba(var(--center-channel-color-rgb), 0.56);
gap: 8px;
}
&__demo-icon {
font-size: 24px;
}
&__demo-text {
font-size: 13px;
font-style: italic;
}
}

View file

@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {TourTip, useMeasurePunchouts} from '@mattermost/components';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {hasSeenBurnOnReadTourTip, BURN_ON_READ_TOUR_TIP_PREFERENCE, getBurnOnReadDurationMinutes} from 'selectors/burn_on_read';
import BurnOnReadSVG from './burn_on_read.svg';
import './burn_on_read_tour_tip.scss';
type Props = {
// Callback when user clicks "Try it out" button to enable BoR
onTryItOut: () => void;
}
const BurnOnReadTourTip = ({onTryItOut}: Props) => {
const dispatch = useDispatch();
const currentUserId = useSelector(getCurrentUserId);
const hasSeenTip = useSelector(hasSeenBurnOnReadTourTip);
const durationMinutes = useSelector(getBurnOnReadDurationMinutes);
// Track whether the pulsating dot has been clicked
const [showTourTip, setShowTourTip] = useState(false);
// Measure the button position - targeting the button element
const overlayPunchOut = useMeasurePunchouts(['burnOnReadButton'], []);
// Save preference that user has seen the tour tip
const markTourTipAsSeen = useCallback(() => {
const preferences = [{
user_id: currentUserId,
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
name: currentUserId,
value: '1',
}];
dispatch(savePreferences(currentUserId, preferences));
}, [currentUserId, dispatch]);
// Handle pulsating dot click - show the tour tip
const handleOpen = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowTourTip(true);
}, []);
// Handle "Dismiss" button
const handleDismiss = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markTourTipAsSeen();
setShowTourTip(false);
}, [markTourTipAsSeen]);
// Handle "Try it out" button
const handleTryItOut = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markTourTipAsSeen();
onTryItOut();
}, [markTourTipAsSeen, onTryItOut]);
// Don't show tour tip if already seen
if (hasSeenTip) {
return null;
}
const title = (
<div className='BurnOnReadTourTip__title'>
<FormattedMessage
id='burn_on_read.tour_tip.title'
defaultMessage='Burn-on-read messages'
/>
<span className='BurnOnReadTourTip__badge'>
<FormattedMessage
id='burn_on_read.tour_tip.badge'
defaultMessage='NEW'
/>
</span>
</div>
);
const screen = (
<>
<p>
<FormattedMessage
id='burn_on_read.tour_tip.description'
defaultMessage='Burn-on-read messages are sent in a masked state. Recipients must click on them to reveal the actual message contents. They will be deleted automatically for each recipient {duration} minutes after being opened.'
values={{duration: durationMinutes}}
/>
</p>
<div className='BurnOnReadTourTip__demo'>
<BurnOnReadSVG/>
</div>
</>
);
// Custom dismiss button
const dismissBtn = (
<FormattedMessage
id='burn_on_read.tour_tip.dismiss'
defaultMessage='Dismiss'
/>
);
// Custom "Try it out" button
const tryItOutBtn = (
<FormattedMessage
id='burn_on_read.tour_tip.try_it_out'
defaultMessage='Try it out'
/>
);
return (
<TourTip
show={showTourTip}
screen={screen}
title={title}
overlayPunchOut={overlayPunchOut}
placement='top-start'
pulsatingDotPlacement='top-start'
pulsatingDotTranslate={{x: 7, y: 0}}
step={1}
singleTip={true}
showOptOut={false}
interactivePunchOut={false}
handleOpen={handleOpen}
handleDismiss={handleDismiss}
handlePrevious={handleDismiss}
handleNext={handleTryItOut}
prevBtn={dismissBtn}
nextBtn={tryItOutBtn}
width={352}
tippyBlueStyle={true}
hideBackdrop={true}
className='BurnOnReadTourTip'
/>
);
};
export default BurnOnReadTourTip;

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as BurnOnReadButton} from './burn_on_read_button';
export {default as BurnOnReadLabel} from './burn_on_read_label';
export {default as BurnOnReadTourTip} from './burn_on_read_tour_tip';

View file

@ -2301,10 +2301,6 @@
"admin.plugins.settings.marketplaceUrlDesc.empty": " Marketplace URL is a required field.",
"admin.plugins.settings.requirePluginSignature": "Require Plugin Signature:",
"admin.plugins.settings.requirePluginSignatureDesc": "When true, uploading plugins is disabled and may only be installed through the Marketplace. Plugins are always verified during Mattermost server startup and initialization. See <link>documentation</link> to learn more.",
"admin.posts.burnOnRead.allowedUsers.all": "Allow for all users",
"admin.posts.burnOnRead.allowedUsers.allowSelected": "Allow selected users",
"admin.posts.burnOnRead.allowedUsers.blockSelected": "Block selected users",
"admin.posts.burnOnRead.allowedUsers.title": "Users Allowed to Send Burn-on-Read Messages",
"admin.posts.burnOnRead.duration.10min": "10 minutes",
"admin.posts.burnOnRead.duration.1hour": "1 hour",
"admin.posts.burnOnRead.duration.1min": "1 minute",
@ -2315,9 +2311,6 @@
"admin.posts.burnOnRead.duration.title": "Burn-on-Read Duration",
"admin.posts.burnOnRead.enable.desc": "When enabled, users are allowed to send burn-on-read messages in channels, direct messages, and group messages. If disabled, the option to send a Burn-on-Read message will not be available.",
"admin.posts.burnOnRead.enable.title": "Enable Burn-on-Read Messages",
"admin.posts.burnOnRead.usersList.desc": "Choose users or groups that will be allowed or blocked from sending burn-on-read messages, depending on your selection above.",
"admin.posts.burnOnRead.usersList.required": "At least one user or group must be selected.",
"admin.posts.burnOnRead.usersList.title": "Selected users and groups",
"admin.posts.persistentNotifications.desc": "When enabled, users can trigger repeating notifications for the recipients of urgent messages. Learn more about message priority and persistent notifications in our <link>documentation</link>.",
"admin.posts.persistentNotifications.title": "Persistent Notifications",
"admin.posts.persistentNotificationsGuests.desc": "Whether a guest is able to require persistent notifications. Learn more about message priority and persistent notifications in our <link>documentation</link>.",
@ -3563,6 +3556,15 @@
"bots.token.confirm": "Delete",
"bots.token.confirm_text": "Are you sure you want to delete the token?",
"bots.token.delete": "Delete Token",
"burn_on_read.button.tooltip.hint": "Message will be deleted for a recipient {duration} minutes after they open it",
"burn_on_read.button.tooltip.title": "Burn-on-read",
"burn_on_read.label.remove": "Remove burn-on-read",
"burn_on_read.label.text": "BURN ON READ ({duration}m)",
"burn_on_read.tour_tip.badge": "NEW",
"burn_on_read.tour_tip.description": "Burn-on-read messages are sent in a masked state. Recipients must click on them to reveal the actual message contents. They will be deleted automatically for each recipient {duration} minutes after being opened.",
"burn_on_read.tour_tip.dismiss": "Dismiss",
"burn_on_read.tour_tip.title": "Burn-on-read messages",
"burn_on_read.tour_tip.try_it_out": "Try it out",
"call_button.menuAriaLabel": "Call type selector",
"carousel.nextButton": "Next",
"carousel.PreviousButton": "Previous",
@ -6073,6 +6075,7 @@
"unarchive_channel.confirm": "Confirm UNARCHIVE Channel",
"unarchive_channel.del": "Unarchive",
"unarchiveChannelModal.viewArchived.question": "Are you sure you wish to unarchive the <b>{display_name}</b> channel?",
"unified_labels.remove_all": "Remove all labels",
"update_command.confirm": "Edit Slash Command",
"update_command.question": "Your changes may break the existing slash command. Are you sure you would like to update it?",
"update_command.update": "Update",

View file

@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import TestHelper from 'packages/mattermost-redux/test/test_helper';
import type {GlobalState} from 'types/store';
import {
isBurnOnReadEnabled,
getBurnOnReadDurationMinutes,
canUserSendBurnOnRead,
hasSeenBurnOnReadTourTip,
BURN_ON_READ_TOUR_TIP_PREFERENCE,
} from './burn_on_read';
describe('selectors/burn_on_read', () => {
let state: GlobalState;
let user: ReturnType<typeof TestHelper.fakeUserWithId>;
beforeEach(() => {
user = TestHelper.fakeUserWithId();
state = {
entities: {
general: {
config: {},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId: user.id,
profiles: {
[user.id]: user,
},
},
},
} as unknown as GlobalState;
});
describe('isBurnOnReadEnabled', () => {
it('should return true when config.EnableBurnOnRead is true', () => {
state.entities.general.config.EnableBurnOnRead = 'true';
const result = isBurnOnReadEnabled(state);
expect(result).toBe(true);
});
it('should return false when config.EnableBurnOnRead is false', () => {
state.entities.general.config.EnableBurnOnRead = 'false';
const result = isBurnOnReadEnabled(state);
expect(result).toBe(false);
});
it('should return false when config.EnableBurnOnRead is not set', () => {
const result = isBurnOnReadEnabled(state);
expect(result).toBe(false);
});
});
describe('getBurnOnReadDurationMinutes', () => {
it('should return default 10 minutes when not configured', () => {
const result = getBurnOnReadDurationMinutes(state);
expect(result).toBe(10);
});
it('should return configured duration', () => {
state.entities.general.config.BurnOnReadDurationMinutes = '15';
const result = getBurnOnReadDurationMinutes(state);
expect(result).toBe(15);
});
it('should parse different duration values', () => {
state.entities.general.config.BurnOnReadDurationMinutes = '30';
const result = getBurnOnReadDurationMinutes(state);
expect(result).toBe(30);
});
});
describe('canUserSendBurnOnRead', () => {
it('should return true when feature is enabled', () => {
state.entities.general.config.EnableBurnOnRead = 'true';
const result = canUserSendBurnOnRead(state);
expect(result).toBe(true);
});
it('should return false when feature is disabled', () => {
state.entities.general.config.EnableBurnOnRead = 'false';
const result = canUserSendBurnOnRead(state);
expect(result).toBe(false);
});
it('should return false when feature is not configured', () => {
const result = canUserSendBurnOnRead(state);
expect(result).toBe(false);
});
});
describe('hasSeenBurnOnReadTourTip', () => {
it('should return false when user has not seen tour tip', () => {
const result = hasSeenBurnOnReadTourTip(state);
expect(result).toBe(false);
});
it('should return true when user has seen tour tip', () => {
const pref = {
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
name: user.id,
user_id: user.id,
value: '1',
};
state.entities.preferences.myPreferences = {
[getPreferenceKey(BURN_ON_READ_TOUR_TIP_PREFERENCE, user.id)]: pref,
};
const result = hasSeenBurnOnReadTourTip(state);
expect(result).toBe(true);
});
it('should return false when preference value is 0', () => {
const pref = {
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
name: user.id,
user_id: user.id,
value: '0',
};
state.entities.preferences.myPreferences = {
[getPreferenceKey(BURN_ON_READ_TOUR_TIP_PREFERENCE, user.id)]: pref,
};
const result = hasSeenBurnOnReadTourTip(state);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
import type {GlobalState} from 'types/store';
// Preference category for storing whether user has seen the Burn-on-Read tour tip
export const BURN_ON_READ_TOUR_TIP_PREFERENCE = 'burn_on_read_tour_tip';
/**
* Returns whether the Burn-on-Read feature is enabled system-wide.
* When enabled, users can send messages that auto-delete after being read by recipients.
*/
export const isBurnOnReadEnabled = (state: GlobalState): boolean => {
const config = getConfig(state);
return config.EnableBurnOnRead === 'true';
};
/**
* Returns the configured duration (in minutes) that Burn-on-Read messages
* remain visible after being opened by a recipient before auto-deleting.
*/
export const getBurnOnReadDurationMinutes = (state: GlobalState): number => {
const config = getConfig(state);
return parseInt(config.BurnOnReadDurationMinutes || '10', 10);
};
/**
* Returns whether the current user has permission to send Burn-on-Read messages.
* In the current MVP implementation, all users can send BoR messages when the feature is enabled.
* Future versions may implement user/group-level restrictions.
*/
export const canUserSendBurnOnRead = (state: GlobalState): boolean => {
// For MVP: All users can send BoR when feature is enabled
return isBurnOnReadEnabled(state);
};
/**
* Returns whether the current user has already seen the Burn-on-Read feature tour tip.
* Used to determine if the tour tip pulsating dot should be displayed.
*/
export const hasSeenBurnOnReadTourTip = (state: GlobalState): boolean => {
const currentUserId = getCurrentUserId(state);
const value = getInt(state, BURN_ON_READ_TOUR_TIP_PREFERENCE, currentUserId, 0);
return value === 1;
};

View file

@ -29,6 +29,9 @@ export type PostDraft = {
requested_ack?: boolean;
persistent_notifications?: boolean;
};
burn_on_read?: {
enabled: boolean;
};
files?: FileInfo[];
};
};
@ -38,7 +41,17 @@ export function isPostDraftEmpty(draft: PostDraft): boolean {
const hasAttachment = draft.fileInfos?.length > 0;
const hasUploadingFiles = draft.uploadsInProgress?.length > 0;
return !hasMessage && !hasAttachment && !hasUploadingFiles;
// Check for priority metadata
const hasPriority = draft.metadata?.priority && (
draft.metadata.priority.priority ||
draft.metadata.priority.requested_ack ||
draft.metadata.priority.persistent_notifications
);
// Check for burn-on-read metadata
const hasBurnOnRead = draft.metadata?.burn_on_read?.enabled;
return !hasMessage && !hasAttachment && !hasUploadingFiles && !hasPriority && !hasBurnOnRead;
}
export function scheduledPostToPostDraft(scheduledPost: ScheduledPost): PostDraft {

View file

@ -228,6 +228,10 @@ export type ClientConfig = {
DeleteAccountLink: string;
ContentFlaggingEnabled: 'true' | 'false';
// Burn on Read Settings
EnableBurnOnRead: string;
BurnOnReadDurationMinutes: string;
// Access Control Settings
EnableAttributeBasedAccessControl: string;
EnableChannelScopeAccessControl: string;