diff --git a/server/config/client.go b/server/config/client.go
index 16c2e7abbb4..de027400d64 100644
--- a/server/config/client.go
+++ b/server/config/client.go
@@ -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)
diff --git a/server/config/client_test.go b/server/config/client_test.go
index 3182bb51efc..32205c44ce4 100644
--- a/server/config/client_test.go
+++ b/server/config/client_test.go
@@ -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 {
diff --git a/server/public/model/config.go b/server/public/model/config.go
index 4099079be92..0e09a7c7c46 100644
--- a/server/public/model/config.go
+++ b/server/public/model/config.go
@@ -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))
}
diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx
index 720c40d9c0b..d9a2dd74f1c 100644
--- a/webapp/channels/src/components/admin_console/admin_definition.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition.tsx
@@ -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, '');
- },
- },
],
},
{
diff --git a/webapp/channels/src/components/admin_console/admin_definition_burn_on_read.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_burn_on_read.test.tsx
index 186d4de7caa..981dbbc07fc 100644
--- a/webapp/channels/src/components/admin_console/admin_definition_burn_on_read.test.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition_burn_on_read.test.tsx
@@ -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');
});
});
diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx
index fce78265f7f..cecd0e980fc 100644
--- a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx
@@ -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);
});
diff --git a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.test.tsx b/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.test.tsx
deleted file mode 100644
index 467d9b35d5d..00000000000
--- a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.test.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
- },
-}));
-
-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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- // Setting component should render with setByEnv indicator
- expect(screen.getByText('Selected Users and Groups')).toBeInTheDocument();
- });
-
- test('should use correct placeholder text', () => {
- renderWithContext(
- ,
- );
-
- expect(screen.getByPlaceholderText('Start typing to search for users, groups, and teams...')).toBeInTheDocument();
- });
-
- test('should pass isMulti=true to UserSelector', () => {
- renderWithContext(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- const inputs = screen.getAllByTestId(baseProps.id);
- const input = inputs[inputs.length - 1] as HTMLInputElement;
- expect(input.placeholder).toContain('teams');
- });
-});
diff --git a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.tsx b/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.tsx
deleted file mode 100644
index 1769f7ca0bb..00000000000
--- a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/burn_on_read_user_group_selector.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
-
-
- );
-};
-
-export default BurnOnReadUserGroupSelector;
diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
index b0b4a2a1e36..9be8e654bdd 100644
--- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
+++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
@@ -195,6 +195,14 @@
}
}
+ &__labels {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ padding-left: 16px;
+ gap: 8px;
+ }
+
}
@media screen and (max-width: 768px) {
diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
index a1ac55c0d0e..1d53638873e 100644
--- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
@@ -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 = (
- {!isInEditMode && priorityLabels}
+ {!isInEditMode && (priorityLabels || burnOnReadLabels) && (
+
+
+
+ )}
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) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onRemoveAll?.();
+ }, [onRemoveAll]);
+
+ // Don't render if no labels
+ if (!priorityLabels && !burnOnReadLabels) {
+ return null;
+ }
+
+ return (
+
+ {priorityLabels}
+ {burnOnReadLabels}
+ {canRemove && onRemoveAll && (
+
+ )}
+
+ );
+};
+
+export default UnifiedLabelsWrapper;
diff --git a/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx
new file mode 100644
index 00000000000..a84b21b131e
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/use_burn_on_read.tsx
@@ -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) ? (
+
+ ) : undefined
+ ), [hasBurnOnReadSet, rootId, showIndividualCloseButton, shouldShowPreview, handleRemoveBurnOnRead, durationMinutes]);
+
+ // Button component with tour tip wrapper (in formatting bar)
+ const additionalControl = useMemo(() =>
+ (!rootId && isEnabled && canSend ? (
+
+
+ handleBurnOnReadApply(true)}
+ />
+
+ ) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
+
+ return {
+ labels,
+ additionalControl,
+ handleBurnOnReadApply,
+ handleRemoveBurnOnRead,
+ };
+};
+
+export default useBurnOnRead;
diff --git a/webapp/channels/src/components/advanced_text_editor/use_priority.tsx b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx
index 969bb368319..bf6b38bc43b 100644
--- a/webapp/channels/src/components/advanced_text_editor/use_priority.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx
@@ -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) ? (
) : 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,
};
};
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read.svg.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read.svg.tsx
new file mode 100644
index 00000000000..354dbf67f0a
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read.svg.tsx
@@ -0,0 +1,83 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+type SvgProps = {
+ width?: number;
+ height?: number;
+}
+
+const BurnOnReadSVG = (props: SvgProps) => (
+
+
+);
+
+export default BurnOnReadSVG;
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_button.test.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read_button.test.tsx
new file mode 100644
index 00000000000..4f4b594b051
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_button.test.tsx
@@ -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 }) => {children}
;
+});
+
+describe('BurnOnReadButton', () => {
+ const defaultProps = {
+ enabled: false,
+ onToggle: jest.fn(),
+ disabled: false,
+ durationMinutes: 10,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render correctly when disabled', () => {
+ renderWithContext(
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute('id', 'burnOnReadButton');
+ });
+
+ it('should render correctly when enabled', () => {
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toBeDisabled();
+ });
+
+ it('should display correct duration in tooltip', () => {
+ renderWithContext(
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-label', expect.stringContaining('15 minutes'));
+ });
+});
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_button.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read_button.tsx
new file mode 100644
index 00000000000..c9cf3bb097a
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_button.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+export default memo(BurnOnReadButton);
diff --git a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/index.ts b/webapp/channels/src/components/burn_on_read/burn_on_read_control.scss
similarity index 53%
rename from webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/index.ts
rename to webapp/channels/src/components/burn_on_read/burn_on_read_control.scss
index e03deb3cab0..6ff58d15603 100644
--- a/webapp/channels/src/components/admin_console/burn_on_read_user_group_selector/index.ts
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_control.scss
@@ -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;
+}
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_label.scss b/webapp/channels/src/components/burn_on_read/burn_on_read_label.scss
new file mode 100644
index 00000000000..790c44d251b
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_label.scss
@@ -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));
+ }
+}
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_label.test.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read_label.test.tsx
new file mode 100644
index 00000000000..ee81b684a4b
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_label.test.tsx
@@ -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(
+ ,
+ );
+
+ expect(screen.getByText(/BURN ON READ \(10m\)/i)).toBeInTheDocument();
+ });
+
+ it('should display custom duration', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText(/BURN ON READ \(15m\)/i)).toBeInTheDocument();
+ });
+
+ it('should render close button when canRemove is true', () => {
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const closeButton = screen.queryByRole('button');
+ expect(closeButton).not.toBeInTheDocument();
+ });
+
+ it('should call onRemove when close button is clicked', () => {
+ const onRemove = jest.fn();
+ renderWithContext(
+ ,
+ );
+
+ const closeButton = screen.getByRole('button');
+ fireEvent.click(closeButton);
+
+ expect(onRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it('should have correct CSS classes', () => {
+ const {container} = renderWithContext(
+ ,
+ );
+
+ expect(container.querySelector('.BurnOnReadLabel')).toBeInTheDocument();
+ expect(container.querySelector('.BurnOnReadLabel__badge')).toBeInTheDocument();
+ expect(container.querySelector('.BurnOnReadLabel__icon')).toBeInTheDocument();
+ expect(container.querySelector('.BurnOnReadLabel__text')).toBeInTheDocument();
+ });
+});
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_label.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read_label.tsx
new file mode 100644
index 00000000000..9287b2832c6
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_label.tsx
@@ -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 (
+
+
+
+
+ {formatMessage(
+ {
+ id: 'burn_on_read.label.text',
+ defaultMessage: 'BURN ON READ ({duration}m)',
+ },
+ {duration: durationMinutes},
+ )}
+
+
+ {canRemove && (
+
+ )}
+
+ );
+};
+
+export default memo(BurnOnReadLabel);
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.scss b/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.scss
new file mode 100644
index 00000000000..96a84bfaef7
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.scss
@@ -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;
+ }
+}
diff --git a/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.tsx b/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.tsx
new file mode 100644
index 00000000000..8619fae8dd8
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/burn_on_read_tour_tip.tsx
@@ -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 = (
+
+
+
+
+
+
+ );
+
+ const screen = (
+ <>
+
+
+
+
+
+
+ >
+ );
+
+ // Custom dismiss button
+ const dismissBtn = (
+
+ );
+
+ // Custom "Try it out" button
+ const tryItOutBtn = (
+
+ );
+
+ return (
+
+ );
+};
+
+export default BurnOnReadTourTip;
diff --git a/webapp/channels/src/components/burn_on_read/index.ts b/webapp/channels/src/components/burn_on_read/index.ts
new file mode 100644
index 00000000000..422aaf7c63d
--- /dev/null
+++ b/webapp/channels/src/components/burn_on_read/index.ts
@@ -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';
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index 75b74a9ad0b..82f8def0272 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -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 documentation 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 documentation.",
"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 documentation.",
@@ -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 {display_name} 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",
diff --git a/webapp/channels/src/selectors/burn_on_read.test.ts b/webapp/channels/src/selectors/burn_on_read.test.ts
new file mode 100644
index 00000000000..f9ac832290a
--- /dev/null
+++ b/webapp/channels/src/selectors/burn_on_read.test.ts
@@ -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;
+
+ 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);
+ });
+ });
+});
diff --git a/webapp/channels/src/selectors/burn_on_read.ts b/webapp/channels/src/selectors/burn_on_read.ts
new file mode 100644
index 00000000000..71ac2fc8f0d
--- /dev/null
+++ b/webapp/channels/src/selectors/burn_on_read.ts
@@ -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;
+};
diff --git a/webapp/channels/src/types/store/draft.ts b/webapp/channels/src/types/store/draft.ts
index 9d36c3b4d22..687bc88bd86 100644
--- a/webapp/channels/src/types/store/draft.ts
+++ b/webapp/channels/src/types/store/draft.ts
@@ -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 {
diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts
index 733ec0c64c9..b174bafdd0f 100644
--- a/webapp/platform/types/src/config.ts
+++ b/webapp/platform/types/src/config.ts
@@ -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;