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;