From 4a05ae0cfdfa87fc938c20ffa0e71cdc324b0090 Mon Sep 17 00:00:00 2001
From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Date: Mon, 12 Jan 2026 15:39:24 +0100
Subject: [PATCH] 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
---
.../burn_on_read_label/index.test.tsx | 80 +++++++++++++++++++
app/components/burn_on_read_label/index.tsx | 32 ++++++++
.../draft_scheduled_post.test.tsx | 53 ++++++++++++
.../draft_scheduled_post.tsx | 56 ++++++++++---
app/components/draft_scheduled_post/index.tsx | 4 +
app/components/tag/base_tag.tsx | 8 +-
app/utils/bor.ts | 4 +-
app/utils/datetime.test.ts | 44 ++++++++++
app/utils/datetime.ts | 16 +++-
assets/base/i18n/en.json | 1 +
types/api/config.d.ts | 1 +
11 files changed, 283 insertions(+), 16 deletions(-)
create mode 100644 app/components/burn_on_read_label/index.test.tsx
create mode 100644 app/components/burn_on_read_label/index.tsx
diff --git a/app/components/burn_on_read_label/index.test.tsx b/app/components/burn_on_read_label/index.test.tsx
new file mode 100644
index 000000000..f72a4513f
--- /dev/null
+++ b/app/components/burn_on_read_label/index.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const tag = getByTestId('test_bor_label');
+ expect(tag).toHaveTextContent('BURN ON READ (30s)');
+ });
+
+ it('should format duration correctly for hours', () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const tag = getByTestId('test_bor_label');
+ expect(tag).toHaveTextContent('BURN ON READ (2h)');
+ });
+
+ it('should generate correct testID with postId', () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ expect(getByTestId('test-post_bor_label')).toBeVisible();
+ });
+
+ it('should generate correct testID without postId', () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ expect(getByTestId('bor_label')).toBeVisible();
+ });
+});
diff --git a/app/components/burn_on_read_label/index.tsx b/app/components/burn_on_read_label/index.tsx
new file mode 100644
index 000000000..dba67c49f
--- /dev/null
+++ b/app/components/burn_on_read_label/index.tsx
@@ -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 (
+
+ );
+}
diff --git a/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx
index 76b7a04bc..4cb629dbd 100644
--- a/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx
+++ b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx
@@ -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();
+ 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();
+
+ 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();
+ });
});
diff --git a/app/components/draft_scheduled_post/draft_scheduled_post.tsx b/app/components/draft_scheduled_post/draft_scheduled_post.tsx
index 49de969b1..dbfb1e839 100644
--- a/app/components/draft_scheduled_post/draft_scheduled_post.tsx
+++ b/app/components/draft_scheduled_post/draft_scheduled_post.tsx
@@ -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 = ({
isPostPriorityEnabled,
draftType,
firstItem,
+ borUserTimeLimit,
}) => {
const intl = useIntl();
const theme = useTheme();
@@ -109,6 +124,8 @@ const DraftAndScheduledPost: React.FC = ({
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
}, [channel.id, channel.teamId, post.rootId, serverUrl]);
+ const borPost = useMemo(() => isBoRPost(post), [post]);
+
return (
= ({
postScheduledAt={'scheduledAt' in post ? post.scheduledAt : undefined}
scheduledPostErrorCode={'errorCode' in post ? post.errorCode : undefined}
/>
- {showPostPriority && post.metadata?.priority &&
-
-
+
+ {showPostPriority && post.metadata?.priority &&
+
+
+
+ }
+ {borPost && borUserTimeLimit !== undefined && Boolean(borUserTimeLimit) &&
+
+
+
+ }
- }
{
const postReceiverUser = observePostReceiverUser({members, database, channelData: channel, currentUser});
+ const borUserTimeLimit = observeConfigIntValue(database, 'BurnOnReadDurationSeconds');
+
return {
post: post.observe(),
channel,
postReceiverUser,
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
+ borUserTimeLimit,
};
});
diff --git a/app/components/tag/base_tag.tsx b/app/components/tag/base_tag.tsx
index 6e179f2c8..25d31f0d5 100644
--- a/app/components/tag/base_tag.tsx
+++ b/app/components/tag/base_tag.tsx
@@ -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,
},
diff --git a/app/utils/bor.ts b/app/utils/bor.ts
index e1fb28989..d00d9229f 100644
--- a/app/utils/bor.ts
+++ b/app/utils/bor.ts
@@ -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);
}
diff --git a/app/utils/datetime.test.ts b/app/utils/datetime.test.ts
index 21db8aa25..2d57c0217 100644
--- a/app/utils/datetime.test.ts
+++ b/app/utils/datetime.test.ts
@@ -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');
+ });
+ });
});
diff --git a/app/utils/datetime.ts b/app/utils/datetime.ts
index a3f995564..f1af27214 100644
--- a/app/utils/datetime.ts
+++ b/app/utils/datetime.ts
@@ -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');
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 0f7381442..ce6578f6d 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -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",
diff --git a/types/api/config.d.ts b/types/api/config.d.ts
index 040377423..816452f57 100644
--- a/types/api/config.d.ts
+++ b/types/api/config.d.ts
@@ -211,6 +211,7 @@ interface ClientConfig {
WebsocketPort: string;
WebsocketSecurePort: string;
WebsocketURL: string;
+ BurnOnReadDurationSeconds: string;
}
type SecurityClientConfig = Pick