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:
parent
f024e00ca6
commit
4a05ae0cfd
11 changed files with 283 additions and 16 deletions
80
app/components/burn_on_read_label/index.test.tsx
Normal file
80
app/components/burn_on_read_label/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
32
app/components/burn_on_read_label/index.tsx
Normal file
32
app/components/burn_on_read_label/index.tsx
Normal 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'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -211,6 +211,7 @@ interface ClientConfig {
|
|||
WebsocketPort: string;
|
||||
WebsocketSecurePort: string;
|
||||
WebsocketURL: string;
|
||||
BurnOnReadDurationSeconds: string;
|
||||
}
|
||||
|
||||
type SecurityClientConfig = Pick<ClientConfig, 'MobileEnableBiometrics' | 'MobileJailbreakProtection' | 'MobilePreventScreenCapture' | 'SiteName'>
|
||||
|
|
|
|||
Loading…
Reference in a new issue