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