Added BoR indicator in scheduled posts (#9355)

* Added type column to scheduled post and draft table

* removed console log

* fixed a test

* fixes

* Restored ununtended changes

* Added BoR indicator in scheduled posts

* used fixed padding

* used Tag for BoR chip

* test: add comprehensive tests for formatTime humanReadable parameter

* test: add initial test file for burn on read label component

* test: add comprehensive tests for BoRLabel component

* test: update BoRLabel tests to use deep rendering and remove mocks

* test: fix renderWithIntl import and usage in BoRLabel test

* refactor: Update BoRLabel tests to use renderWithIntl and remove formatTime mock

* refactor: simplify imports and remove renderWithIntl mock in test file

* Updated tests

* reset unintended changes

* reset unintended changes

* reset unintended changes

* i18n fixes

* fixed tests

* Matched BOR tag size to post priority tag

* Review fixes
This commit is contained in:
Harshil Sharma 2026-01-12 15:39:24 +01:00 committed by GitHub
parent f024e00ca6
commit 4a05ae0cfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 283 additions and 16 deletions

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntl} from '@test/intl-test-helper';
import BoRLabel from './index';
describe('BoRLabel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with correct message and props', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel
durationSeconds={300}
id='test-post-id'
/>,
);
const tag = getByTestId('test-post-id_bor_label');
expect(tag).toBeVisible();
expect(tag).toHaveTextContent('BURN ON READ (5m)');
});
it('should render without postId', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel durationSeconds={60}/>,
);
const tag = getByTestId('bor_label');
expect(tag).toBeVisible();
expect(tag).toHaveTextContent('BURN ON READ (1m)');
});
it('should format duration correctly for seconds', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel
durationSeconds={30}
id='test'
/>,
);
const tag = getByTestId('test_bor_label');
expect(tag).toHaveTextContent('BURN ON READ (30s)');
});
it('should format duration correctly for hours', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel
durationSeconds={7200}
id='test'
/>,
);
const tag = getByTestId('test_bor_label');
expect(tag).toHaveTextContent('BURN ON READ (2h)');
});
it('should generate correct testID with postId', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel
durationSeconds={120}
id='test-post'
/>,
);
expect(getByTestId('test-post_bor_label')).toBeVisible();
});
it('should generate correct testID without postId', () => {
const {getByTestId} = renderWithIntl(
<BoRLabel durationSeconds={120}/>,
);
expect(getByTestId('bor_label')).toBeVisible();
});
});

View file

@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import Tag from '@components/tag';
import {formatTime} from '@utils/datetime';
type Props = {
durationSeconds: number;
id?: string;
}
export default function BoRLabel({durationSeconds, id}: Props) {
const {formatMessage} = useIntl();
const message = formatMessage({
id: 'burn_on_read.label.title',
defaultMessage: 'BURN ON READ ({duration})',
}, {duration: formatTime(durationSeconds, true)});
return (
<Tag
message={message}
icon='fire'
type='dangerDim'
testID={`${id ? id + '_' : ''}bor_label`}
size='xs'
/>
);
}

View file

@ -217,4 +217,57 @@ describe('DraftAndScheduledPost', () => {
expect(mockOpenAsBottonSheet).toHaveBeenCalled();
expect(mockOpenAsBottonSheet.mock.calls[0][0].props?.draftType).toBe(DRAFT_TYPE_SCHEDULED);
});
it('renders BoR indicator for BoR scheduled post', () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
post: TestHelper.fakeScheduledPostModel({
id: 'post_id_1',
rootId: '',
updateAt: 1234567890,
metadata: {},
files: [] as FileInfo[],
scheduledAt: 1234567890,
errorCode: '',
type: 'burn_on_read',
}),
borUserTimeLimit: 100000,
};
renderWithIntlAndTheme(<DraftAndScheduledPost {...props}/>);
expect(screen.getByTestId('post_id_1_bor_label')).toBeVisible();
});
it('renders BoR indicator with post priority if set', () => {
const props = {
...baseProps,
draftType: DRAFT_TYPE_SCHEDULED,
isPostPriorityEnabled: true,
post: TestHelper.fakeScheduledPostModel({
id: 'post_id_1',
rootId: '',
updateAt: 1234567890,
metadata: {
priority: {
priority: 'urgent',
requested_ack: true,
persistent_notifications: true,
},
},
files: [] as FileInfo[],
scheduledAt: 1234567890,
errorCode: '',
type: 'burn_on_read',
}),
borUserTimeLimit: 100000,
};
renderWithIntlAndTheme(<DraftAndScheduledPost {...props}/>);
expect(screen.getByTestId('post_id_1_bor_label')).toBeVisible();
expect(screen.getByTestId('draft_post.priority')).toBeVisible();
expect(screen.getByTestId('urgent_post_priority_label')).toBeVisible();
expect(screen.getByTestId('drafts.requested_ack.icon')).toBeVisible();
expect(screen.getByTestId('drafts.persistent_notifications.icon')).toBeVisible();
});
});

View file

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, TouchableHighlight, View} from 'react-native';
import {switchToThread} from '@actions/local/thread';
import {switchToChannelById} from '@actions/remote/channel';
import BoRLabel from '@components/burn_on_read_label';
import DraftAndScheduledPostHeader from '@components/draft_scheduled_post_header';
import Header from '@components/post_draft/draft_input/header';
import {Screens} from '@constants';
@ -15,6 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_scheduled_post_options';
import {openAsBottomSheet} from '@screens/navigation';
import {isBoRPost} from '@utils/bor';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DraftAndScheduledPostContainer from './draft_scheduled_post_container';
@ -34,6 +36,7 @@ type Props = {
isPostPriorityEnabled: boolean;
draftType: DraftType;
firstItem?: boolean;
borUserTimeLimit?: number;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -53,10 +56,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
pressInContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
postPriority: {
marginTop: 10,
indicatorContainer: {
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
gap: 7,
},
priorityIndicator: {
marginLeft: -12,
},
indicatorItem: {
marginTop: 10,
},
errorLine: {
backgroundColor: theme.errorTextColor,
position: 'absolute',
@ -65,6 +76,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
left: 0,
height: '100%',
},
borIndicator: {
paddingVertical: 2,
},
};
});
@ -77,6 +91,7 @@ const DraftAndScheduledPost: React.FC<Props> = ({
isPostPriorityEnabled,
draftType,
firstItem,
borUserTimeLimit,
}) => {
const intl = useIntl();
const theme = useTheme();
@ -109,6 +124,8 @@ const DraftAndScheduledPost: React.FC<Props> = ({
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
}, [channel.id, channel.teamId, post.rootId, serverUrl]);
const borPost = useMemo(() => isBoRPost(post), [post]);
return (
<TouchableHighlight
onLongPress={onLongPress}
@ -136,17 +153,30 @@ const DraftAndScheduledPost: React.FC<Props> = ({
postScheduledAt={'scheduledAt' in post ? post.scheduledAt : undefined}
scheduledPostErrorCode={'errorCode' in post ? post.errorCode : undefined}
/>
{showPostPriority && post.metadata?.priority &&
<View
style={style.postPriority}
testID='draft_post.priority'
>
<Header
noMentionsError={false}
postPriority={post.metadata?.priority}
/>
<View style={style.indicatorContainer}>
{showPostPriority && post.metadata?.priority &&
<View
style={[style.indicatorItem, style.priorityIndicator]}
testID='draft_post.priority'
>
<Header
noMentionsError={false}
postPriority={post.metadata?.priority}
/>
</View>
}
{borPost && borUserTimeLimit !== undefined && Boolean(borUserTimeLimit) &&
<View
style={style.indicatorItem}
testID='draft_post.bor_indicator'
>
<BoRLabel
id={post.id}
durationSeconds={borUserTimeLimit}
/>
</View>
}
</View>
}
<DraftAndScheduledPostContainer
post={post}
location={location}

View file

@ -7,6 +7,7 @@ import {switchMap, of} from 'rxjs';
import {observeChannel, observeChannelMembers} from '@queries/servers/channel';
import {observeIsPostPriorityEnabled} from '@queries/servers/post';
import {observeConfigIntValue} from '@queries/servers/system';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
import DraftAndScheduledPost from './draft_scheduled_post';
@ -77,11 +78,14 @@ const observePostReceiverUser = ({
const enhance = withObservables(['channel', 'members', 'post'], ({channel, database, currentUser, members, post}: Props) => {
const postReceiverUser = observePostReceiverUser({members, database, channelData: channel, currentUser});
const borUserTimeLimit = observeConfigIntValue(database, 'BurnOnReadDurationSeconds');
return {
post: post.observe(),
channel,
postReceiverUser,
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
borUserTimeLimit,
};
});

View file

@ -15,7 +15,7 @@ import type {MessageDescriptor} from 'react-intl';
type TagProps = {
message: MessageDescriptor | string;
icon?: string;
type?: 'general' | 'info' | 'danger' | 'success' | 'warning' | 'infoDim';
type?: 'general' | 'info' | 'danger' | 'dangerDim' | 'success' | 'warning' | 'infoDim';
size?: 'xs' | 's' | 'm';
uppercase?: boolean;
testID?: string;
@ -48,6 +48,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
dangerText: {
color: theme.buttonColor,
},
dangerDimContainer: {
backgroundColor: changeOpacity(theme.dndIndicator, 0.12),
},
dangerDimText: {
color: theme.dndIndicator,
},
successContainer: {
backgroundColor: theme.onlineIndicator,
},

View file

@ -4,10 +4,12 @@
import {Post} from '@constants';
import {ensureNumber} from '@utils/types';
import type DraftModel from '@typings/database/models/servers/draft';
import type PostModel from '@typings/database/models/servers/post';
import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import type UserModel from '@typings/database/models/servers/user';
export function isBoRPost(post: PostModel | Post): boolean {
export function isBoRPost(post: PostModel | DraftModel | ScheduledPostModel | Post): boolean {
return Boolean(post.type && post.type === Post.POST_TYPES.BURN_ON_READ);
}

View file

@ -124,4 +124,48 @@ describe('formatTime', () => {
expect(formatTime(65)).toBe('1:05');
expect(formatTime(605)).toBe('10:05');
});
describe('text time format', () => {
it('should format seconds only in text time format', () => {
expect(formatTime(30, true)).toBe('30s');
expect(formatTime(5, true)).toBe('5s');
expect(formatTime(59, true)).toBe('59s');
});
it('should format minutes and seconds in text time format', () => {
expect(formatTime(90, true)).toBe('1m 30s');
expect(formatTime(125, true)).toBe('2m 5s');
expect(formatTime(3599, true)).toBe('59m 59s');
});
it('should format hours, minutes, and seconds in text time format', () => {
expect(formatTime(3600, true)).toBe('1h');
expect(formatTime(3661, true)).toBe('1h 1m 1s');
expect(formatTime(7325, true)).toBe('2h 2m 5s');
expect(formatTime(36000, true)).toBe('10h');
});
it('should format hours and minutes only in text time format', () => {
expect(formatTime(3660, true)).toBe('1h 1m');
expect(formatTime(7200, true)).toBe('2h');
});
it('should format hours and seconds only in text time format', () => {
expect(formatTime(3605, true)).toBe('1h 5s');
});
it('should format minutes only in text time format', () => {
expect(formatTime(60, true)).toBe('1m');
expect(formatTime(120, true)).toBe('2m');
});
it('should handle zero seconds in text time format', () => {
expect(formatTime(0, true)).toBe('0s');
});
it('should handle negative values in text time format', () => {
expect(formatTime(-30, true)).toBe('0s');
expect(formatTime(-3600, true)).toBe('0s');
});
});
});

View file

@ -56,11 +56,25 @@ export function getReadableTimestamp(timestamp: number, timeZone: string, isMili
return date.toLocaleString(currentUserLocale, options);
}
export function formatTime(seconds: number) {
export function formatTime(seconds: number, textTime: boolean = false) {
const h = Math.max(Math.floor(seconds / 3600), 0);
const m = Math.max(Math.floor((seconds % 3600) / 60), 0);
const s = Math.max(Math.floor(seconds % 60), 0);
if (textTime) {
const parts: string[] = [];
if (h > 0) {
parts.push(`${h}h`);
}
if (m > 0) {
parts.push(`${m}m`);
}
if (s > 0) {
parts.push(`${s}s`);
}
return parts.length > 0 ? parts.join(' ') : '0s';
}
const hh = h > 0 ? `${h}:` : '';
const mm = h > 0 ? `${m.toString().padStart(2, '0')}` : `${m}`;
const ss = s.toString().padStart(2, '0');

View file

@ -111,6 +111,7 @@
"browse_channels.showPublicChannels": "Show: Public Channels",
"browse_channels.showSharedChannels": "Show: Shared Channels",
"browse_channels.title": "Browse channels",
"burn_on_read.label.title": "BURN ON READ ({duration})",
"calls.join_call_avatars.bottom_sheet_title": "Call participants",
"camera_type.photo.option": "Capture Photo",
"camera_type.video.option": "Record Video",

View file

@ -211,6 +211,7 @@ interface ClientConfig {
WebsocketPort: string;
WebsocketSecurePort: string;
WebsocketURL: string;
BurnOnReadDurationSeconds: string;
}
type SecurityClientConfig = Pick<ClientConfig, 'MobileEnableBiometrics' | 'MobileJailbreakProtection' | 'MobilePreventScreenCapture' | 'SiteName'>